Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/menubar/Codogotchi.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
38FE947AA40D419AADC09E4B /* CodogotchiPetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3CE84B951CBE68217B4CC3F /* CodogotchiPetTests.swift */; };
3996ADE8B389AEB45AAB6827 /* RPGHUDPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C041CB2EE71340F3F263BFB /* RPGHUDPreviewView.swift */; };
3B94CBC8F0510C0545FF7EB9 /* AboutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0EC260ED23DDA79BF1888FA /* AboutViewModel.swift */; };
3FBC13E4B9A0AC33B73BBA8F /* WindowActionRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAC0E973502BF1A8691836E /* WindowActionRouter.swift */; };
4121304416B4360FAF08294C /* MenubarMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EEE8949FDE061D125E5EB7D /* MenubarMenu.swift */; };
430CAFACBB46008FC2F3AAF7 /* ConfigFileWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0726E3AFB7210BA52BF1ACFD /* ConfigFileWriterTests.swift */; };
43791C4A0AB326A9F055049C /* GateBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE11B737B6F0D401A7F9696 /* GateBadgeView.swift */; };
Expand Down Expand Up @@ -96,6 +97,7 @@
6DA9D60EB6FA048B64876A79 /* RPGHUDPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349466D86E4C96C4D4A098BD /* RPGHUDPanel.swift */; };
708062CE5FDA296BC26AF17C /* PetTabLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777877051ED12A63B5FD6ACB /* PetTabLayoutTests.swift */; };
74348CC405B92950FC9E967D /* ConfigFileWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F8A5A89CFE5F72C7CB6896 /* ConfigFileWriter.swift */; };
756CD4993E0950C3ACB78E88 /* WindowActionRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC69F56318CF5679B36BC371 /* WindowActionRouterTests.swift */; };
766A1353C309F8F7551B6A3C /* FloatingPetWindowPoolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977437677E31BB6CF62E58C /* FloatingPetWindowPoolTests.swift */; };
774F5A301F72D6FC82A66844 /* CustomizationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1805A0BD2255F37C552E1A4 /* CustomizationStore.swift */; };
7843F584962C072B18F8B391 /* MinimalistBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933225DDA49AB5637263654E /* MinimalistBadgeView.swift */; };
Expand Down Expand Up @@ -255,6 +257,7 @@
36558142C9BA2BEABDDCA036 /* PetImportHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PetImportHelper.swift; sourceTree = "<group>"; };
37BBC3DE729053A5A25EFCF7 /* SessionSelectionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionSelectionPolicyTests.swift; sourceTree = "<group>"; };
3954DCF1280C2621AB22EC87 /* StateJsonWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateJsonWriterTests.swift; sourceTree = "<group>"; };
3BAC0E973502BF1A8691836E /* WindowActionRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowActionRouter.swift; sourceTree = "<group>"; };
3D25A5BC6A9880AD71D04A83 /* FloatingPetHidePrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPetHidePrompt.swift; sourceTree = "<group>"; };
40C0B908B205CD27328B8354 /* RPGProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RPGProgressBarView.swift; sourceTree = "<group>"; };
41B41CAA48264D60619035FE /* PerPlatformSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerPlatformSnapshot.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -376,6 +379,7 @@
D97B6CBA3971EF14695D74F9 /* GateJsonReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GateJsonReaderTests.swift; sourceTree = "<group>"; };
DA2D403DBB111D997748F847 /* LivePollingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePollingTests.swift; sourceTree = "<group>"; };
DBBC8A9F0A42DAF1E086D27A /* PerSessionReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerSessionReaderTests.swift; sourceTree = "<group>"; };
DC69F56318CF5679B36BC371 /* WindowActionRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowActionRouterTests.swift; sourceTree = "<group>"; };
DF6F5C69EBB5D3D17F5D1483 /* SettingsControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsControllerTests.swift; sourceTree = "<group>"; };
DFAAE832D91C20A69CA7D80B /* WindowKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowKeyTests.swift; sourceTree = "<group>"; };
E06FB02C825E53E28CCC8205 /* MenuItemsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemsTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -412,6 +416,7 @@
B86CEF42917F1F47CF4166F3 /* RenderKeyResolver.swift */,
AC5C744A6A25DDB45C495A36 /* SessionLifecycle.swift */,
0F252B3C5E2C4512BEF298E0 /* SessionSelectionPolicy.swift */,
3BAC0E973502BF1A8691836E /* WindowActionRouter.swift */,
77AD5E672E750D5D5DC5CED8 /* WindowKey.swift */,
);
path = Pool;
Expand Down Expand Up @@ -482,6 +487,7 @@
9E5A328D013E03984657DE8F /* StateJsonReaderTests.swift */,
3954DCF1280C2621AB22EC87 /* StateJsonWriterTests.swift */,
5BC62595488CFA83A5ED66AF /* TransitionLogTests.swift */,
DC69F56318CF5679B36BC371 /* WindowActionRouterTests.swift */,
DFAAE832D91C20A69CA7D80B /* WindowKeyTests.swift */,
);
path = MenubarTests;
Expand Down Expand Up @@ -929,6 +935,7 @@
B06C74474A2E6AC6365223CA /* TabStripView.swift in Sources */,
567FFA2D3A08D9FB1A48E0E1 /* TimerIconChipView.swift in Sources */,
CE9190882611899407182DC2 /* TransitionLog.swift in Sources */,
3FBC13E4B9A0AC33B73BBA8F /* WindowActionRouter.swift in Sources */,
2D82A09D121559AB1DDB275E /* WindowKey.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -999,6 +1006,7 @@
C4F4151A25F6ED0D587172FD /* StateJsonReaderTests.swift in Sources */,
F82F0F3BD0350BC38F97CA27 /* StateJsonWriterTests.swift in Sources */,
E88FAD21E3543D52D6F693BB /* TransitionLogTests.swift in Sources */,
756CD4993E0950C3ACB78E88 /* WindowActionRouterTests.swift in Sources */,
9188D02613B670C3AA3D6E05 /* WindowKeyTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
118 changes: 39 additions & 79 deletions apps/menubar/Sources/App/MenubarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ final class MenubarApp: NSObject, NSApplicationDelegate {
/// renderer at a time.
var livePollingDriver: LivePollingDriver?

/// Owns the session/combined/plain-origin targeting policy for
/// `onAttentionDismissed` / `onForceIdle`, shared by the own-window and
/// minimalist-window factories so that policy is expressed exactly once
/// (P17.05). Constructed once pool assets are available.
var windowActionRouter: WindowActionRouter?

/// Held strongly so the periodic `state.d/` prune `Timer` survives. Nil in
/// demo mode (the sandboxed fixture dir is not pruned).
var slicePruneScheduler: SlicePruneScheduler?
Expand Down Expand Up @@ -214,6 +220,23 @@ final class MenubarApp: NSObject, NSApplicationDelegate {
self.codexPet = codexPet
let resolver = PetAssetResolver()
self.petAssetResolver = resolver
// Closures capture `self` weakly and read `floatingPetWindowPool`
// lazily — safe even though the pool below is constructed after
// the router, because these bodies only run once the pool (and
// the window factories that use the router) exist.
let router = WindowActionRouter(
stateDir: { config.pollingTarget.path },
resetPromptTimer: { [weak self] key in
self?.floatingPetWindowPool?.resetPromptTimer(forWindowKey: key)
},
combinedModeOrigins: { [weak self] in
self?.floatingPetWindowPool?.combinedModeOrigins() ?? []
},
clearAttentionBubbles: { [weak self] key in
self?.floatingPetWindowPool?.clearAttentionBubbles(sharingOriginWith: key)
}
)
self.windowActionRouter = router
let pool = FloatingPetWindowPool(
windowFactory: { [weak self] origin, petId in
guard let self else {
Expand Down Expand Up @@ -257,56 +280,20 @@ final class MenubarApp: NSObject, NSApplicationDelegate {
controller.onVisibilityChanged = { [weak self] _ in
self?.updateAppNapOptOut()
}
// Persist the dismiss/focus so the next poll tick does not re-read
// the still-present `attention` and re-show the bubble. Focus/dismiss
// on a session-keyed window can only act on the platform app as a
// whole (no window-level API names a specific agent thread), so it
// idles every sibling session's slice for that origin, not just the
// clicked one, and clears their bubbles immediately rather than
// waiting a poll tick. A plain-origin or combined window has no
// single session to name, so it clears the winner slice per origin.
let stateDir = config.pollingTarget.path
// Targeting policy for both handlers below lives in
// `WindowActionRouter` (P17.05) — see its doc comments
// for the session/combined/plain-origin asymmetries.
panel.onAttentionDismissed = { [weak self] in
// Reset the pool-owned prompt timer before the on-disk rewrite:
// the reset's real-current-time stamp is what stops a next-tick
// poll of the pre-rewrite slice from restarting the timer.
self?.floatingPetWindowPool?.resetPromptTimer(forWindowKey: origin)
if let identity = origin.sessionIdentity {
StateJsonWriter.dismissAllSessionsAttention(at: stateDir, origin: identity.origin)
self?.floatingPetWindowPool?.clearAttentionBubbles(sharingOriginWith: origin)
} else {
StateJsonWriter.dismissAttention(
at: stateDir,
origins: self?.resolveWindowOrigins(windowKey: origin) ?? [origin.origin]
)
}
self?.windowActionRouter?.handleAttentionDismissed(for: origin)
}
// Right-click "Force Idle" escape hatch: rewrite this pet's displayed
// slice back to idle so a stuck (rate-limited / manually-stopped)
// animation clears. A session-keyed window targets exactly its own
// slice — never a sibling session's, which right-clicking Force Idle
// used to reset if that sibling happened to be the freshest slice for
// the origin. A combined window folds several origins into one pet, so
// it resets exactly that combined set; a plain own window resets just
// its origin's winner slice. Never all slices — that idles unrelated
// pets and resurrects aged-out ones by refreshing their mtimes.
// Also clears this window's own SOA badges (ticket/gate) and
// conflict speech bubble — they're driven by separate
// .gate.json/.context.json polling state that the state.d/
// idle rewrite above never touches, so without this the stuck
// badge/bubble would survive the "escape hatch" click.
// .gate.json/.context.json polling state that the router's
// state.d/ idle rewrite never touches, so without this the
// stuck badge/bubble would survive the "escape hatch" click.
panel.onForceIdle = { [weak self, weak panel] in
// Pool-tracker reset first — see onAttentionDismissed above.
self?.floatingPetWindowPool?.resetPromptTimer(forWindowKey: origin)
if let identity = origin.sessionIdentity {
StateJsonWriter.forceIdle(
at: stateDir, origin: identity.origin, sessionId: identity.sessionId)
} else {
StateJsonWriter.forceIdle(
at: stateDir,
origins: self?.resolveWindowOrigins(windowKey: origin) ?? [origin.origin]
)
}
self?.windowActionRouter?.handleForceIdle(for: origin)
panel?.applyGateBadge(content: nil)
panel?.applyConflictBubble(nil)
}
Expand Down Expand Up @@ -446,46 +433,19 @@ final class MenubarApp: NSObject, NSApplicationDelegate {
// tab re-read the file for every pixel of the drag.
self?.customizationStore.setMinimalistBadgeScale(scale, notify: isFinal)
}
// Focus/dismiss on a session-keyed minimalist badge can only act on
// the platform app as a whole, so it idles every sibling session's
// slice for that origin (not just the clicked one) and clears their
// bubbles immediately; the combined-minimalist window folds several
// origins into one badge, so it scopes both writes to that combined
// set (or the single plain origin).
// Targeting policy for both handlers below (session-keyed
// badge vs. combined vs. plain origin) lives in
// `WindowActionRouter` (P17.05), shared verbatim with the
// Own-mode factory above.
panel.onAttentionDismissed = { [weak self] in
// Reset the pool-owned prompt timer before the on-disk rewrite:
// the reset's real-current-time stamp is what stops a next-tick
// poll of the pre-rewrite slice from restarting the timer.
self?.floatingPetWindowPool?.resetPromptTimer(forWindowKey: origin)
if let identity = origin.sessionIdentity {
StateJsonWriter.dismissAllSessionsAttention(at: stateDir, origin: identity.origin)
self?.floatingPetWindowPool?.clearAttentionBubbles(sharingOriginWith: origin)
} else {
StateJsonWriter.dismissAttention(
at: stateDir,
origins: self?.resolveWindowOrigins(windowKey: origin) ?? [origin.origin]
)
}
self?.windowActionRouter?.handleAttentionDismissed(for: origin)
}
// Right-click "Force Idle" escape hatch on the minimalist badge,
// mirroring Own mode: a session-keyed badge resets exactly its own
// slice, never a fresher sibling session's; otherwise resets the
// combined set for the combined window, or just this origin.
// Also clears this window's own SOA badges (ticket/gate) and
// conflict speech bubble — see the matching Own-mode comment
// above for why the state.d/ idle rewrite alone can't do this.
// above for why the router's state.d/ idle rewrite alone can't
// do this.
panel.onForceIdle = { [weak self, weak panel] in
// Pool-tracker reset first — see onAttentionDismissed above.
self?.floatingPetWindowPool?.resetPromptTimer(forWindowKey: origin)
if let identity = origin.sessionIdentity {
StateJsonWriter.forceIdle(
at: stateDir, origin: identity.origin, sessionId: identity.sessionId)
} else {
StateJsonWriter.forceIdle(
at: stateDir,
origins: self?.resolveWindowOrigins(windowKey: origin) ?? [origin.origin]
)
}
self?.windowActionRouter?.handleForceIdle(for: origin)
panel?.applyGateBadge(content: nil)
panel?.applyConflictBubble(nil)
}
Expand Down
90 changes: 90 additions & 0 deletions apps/menubar/Sources/Pool/WindowActionRouter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import Foundation

/// Owns "which slices/origins does this window's action touch" — the
/// session / combined / plain-origin targeting policy that used to live
/// duplicated inline in `MenubarApp`'s own-window and minimalist-window
/// factory closures (see ticket P17.05). Both factories construct one
/// shared router and delegate their `onAttentionDismissed` /
/// `onForceIdle` handlers to it, so the targeting asymmetries below are
/// expressed exactly once.
@MainActor
final class WindowActionRouter {
private let stateDir: () -> String
private let resetPromptTimer: (WindowKey) -> Void
private let combinedModeOrigins: () -> [String]
private let clearAttentionBubbles: (WindowKey) -> Void

init(
stateDir: @escaping () -> String,
resetPromptTimer: @escaping (WindowKey) -> Void,
combinedModeOrigins: @escaping () -> [String],
clearAttentionBubbles: @escaping (WindowKey) -> Void
) {
self.stateDir = stateDir
self.resetPromptTimer = resetPromptTimer
self.combinedModeOrigins = combinedModeOrigins
self.clearAttentionBubbles = clearAttentionBubbles
}

/// Resolves a window's `state.d/` origins for the winner-only writers
/// (`forceIdle` / `dismissAttention`). The shared combined window folds
/// every combined-mode origin into one pet, so it expands to that live
/// set; any other window key (plain origin or per-session
/// `origin:session_id`) resolves to its owning origin, whose winner
/// slice the writers target.
func resolveWindowOrigins(_ windowKey: WindowKey) -> Set<String> {
if windowKey == .combined {
return Set(combinedModeOrigins())
}
return [windowKey.origin]
}

/// Persist the dismiss/focus so the next poll tick does not re-read the
/// still-present `attention` and re-show the bubble. Focus/dismiss on a
/// session-keyed window can only act on the platform app as a whole (no
/// window-level API names a specific agent thread), so it idles every
/// sibling session's slice for that origin, not just the clicked one,
/// and clears their bubbles immediately rather than waiting a poll
/// tick. A plain-origin or combined window has no single session to
/// name, so it clears the winner slice per origin.
///
/// `completion` runs on the main queue once the underlying write settles
/// — production call sites don't need it (the writer is fire-and-forget
/// against a 1Hz poll loop); tests use it to await the async write
/// deterministically instead of racing an arbitrary delay.
func handleAttentionDismissed(for windowKey: WindowKey, completion: (() -> Void)? = nil) {
// Reset the pool-owned prompt timer before the on-disk rewrite: the
// reset's real-current-time stamp is what stops a next-tick poll of
// the pre-rewrite slice from restarting the timer.
resetPromptTimer(windowKey)
if let identity = windowKey.sessionIdentity {
StateJsonWriter.dismissAllSessionsAttention(at: stateDir(), origin: identity.origin, completion: completion)
clearAttentionBubbles(windowKey)
} else {
StateJsonWriter.dismissAttention(
at: stateDir(), origins: resolveWindowOrigins(windowKey), completion: completion)
}
}

/// Right-click "Force Idle" escape hatch: rewrite this pet's displayed
/// slice back to idle so a stuck (rate-limited / manually-stopped)
/// animation clears. A session-keyed window targets exactly its own
/// slice — never a sibling session's, which right-clicking Force Idle
/// used to reset if that sibling happened to be the freshest slice for
/// the origin. A combined window folds several origins into one pet, so
/// it resets exactly that combined set; a plain own window resets just
/// its origin's winner slice. Never all slices — that idles unrelated
/// pets and resurrects aged-out ones by refreshing their mtimes.
///
/// `completion` — see `handleAttentionDismissed` above.
func handleForceIdle(for windowKey: WindowKey, completion: (() -> Void)? = nil) {
// Pool-tracker reset first — see handleAttentionDismissed above.
resetPromptTimer(windowKey)
if let identity = windowKey.sessionIdentity {
StateJsonWriter.forceIdle(
at: stateDir(), origin: identity.origin, sessionId: identity.sessionId, completion: completion)
} else {
StateJsonWriter.forceIdle(at: stateDir(), origins: resolveWindowOrigins(windowKey), completion: completion)
}
}
}
Loading