@lattices/cli 0.4.10 → 0.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -13
  3. package/{app → apps/mac}/Lattices.app/Contents/Info.plist +10 -2
  4. package/{app → apps/mac}/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/{app → apps/mac}/Package.swift +2 -1
  6. package/apps/mac/Resources/Pets/assistant-spark/pet.json +62 -0
  7. package/apps/mac/Resources/Pets/assistant-spark/spritesheet.webp +0 -0
  8. package/apps/mac/Resources/Pets/scout-ranger/pet.json +6 -0
  9. package/apps/mac/Resources/Pets/scout-ranger/spritesheet.webp +0 -0
  10. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +27 -0
  11. package/apps/mac/Sources/AppShell/AppDelegate.swift +189 -0
  12. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +25 -0
  13. package/{app → apps/mac}/Sources/AppShell/AppShellView.swift +18 -3
  14. package/{app → apps/mac}/Sources/AppShell/AppUpdater.swift +4 -3
  15. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +87 -0
  16. package/{app → apps/mac}/Sources/AppShell/LatticesRuntime.swift +43 -0
  17. package/{app → apps/mac}/Sources/AppShell/MainView.swift +116 -63
  18. package/apps/mac/Sources/AppShell/MenuBarController.swift +177 -0
  19. package/{app → apps/mac}/Sources/AppShell/OnboardingView.swift +72 -60
  20. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +366 -0
  21. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +70 -0
  22. package/{app → apps/mac}/Sources/AppShell/Preferences.swift +37 -2
  23. package/{app → apps/mac}/Sources/AppShell/SettingsView.swift +815 -156
  24. package/{app → apps/mac}/Sources/AppShell/SettingsWindow.swift +10 -0
  25. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +13 -0
  26. package/{app → apps/mac}/Sources/Core/Actions/HotkeyStore.swift +6 -1
  27. package/{app → apps/mac}/Sources/Core/Actions/IntentEngine.swift +2 -0
  28. package/{app → apps/mac}/Sources/Core/Daemon/DaemonServer.swift +5 -0
  29. package/{app → apps/mac}/Sources/Core/Daemon/LatticesApi.swift +365 -0
  30. package/{app → apps/mac}/Sources/Core/Desktop/OcrModel.swift +17 -13
  31. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +33 -0
  32. package/{app → apps/mac}/Sources/Core/Desktop/WindowDragSnapController.swift +18 -217
  33. package/{app → apps/mac}/Sources/Core/Desktop/WindowPreviewStore.swift +4 -5
  34. package/{app → apps/mac}/Sources/Core/Desktop/WindowTiler.swift +19 -13
  35. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +124 -0
  36. package/apps/mac/Sources/Core/Input/EventTapThread.swift +54 -0
  37. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +20 -0
  38. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +335 -0
  39. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +141 -0
  40. package/{app → apps/mac}/Sources/Core/Input/MouseGestureConfig.swift +155 -20
  41. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +2271 -0
  42. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +170 -0
  43. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +39 -0
  44. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +624 -0
  45. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +56 -0
  46. package/{app → apps/mac}/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +8 -8
  47. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +1264 -0
  48. package/{app → apps/mac}/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +11 -23
  49. package/{app → apps/mac}/Sources/Core/Pi/PiChatDock.swift +90 -43
  50. package/{app → apps/mac}/Sources/Core/Pi/PiChatSession.swift +676 -43
  51. package/{app → apps/mac}/Sources/Core/Pi/PiProviderSetupCallout.swift +5 -5
  52. package/{app → apps/mac}/Sources/Core/Pi/PiWorkspaceView.swift +93 -44
  53. package/apps/mac/Sources/Core/System/Capability.swift +79 -0
  54. package/{app → apps/mac}/Sources/Core/System/PermissionChecker.swift +43 -8
  55. package/{app → apps/mac}/Sources/Core/Voice/AudioProvider.swift +225 -56
  56. package/bin/handsoff-infer.ts +14 -5
  57. package/bin/handsoff-worker.ts +11 -7
  58. package/bin/infer.ts +406 -0
  59. package/bin/lattices-app.ts +57 -7
  60. package/bin/lattices-dev +40 -1
  61. package/bin/lattices.ts +1 -1
  62. package/docs/agent-execution-plan.md +9 -9
  63. package/docs/api.md +119 -0
  64. package/docs/app.md +1 -0
  65. package/docs/companion-deck.md +1 -1
  66. package/docs/gesture-customization-proposal.md +520 -0
  67. package/docs/mouse-gestures.md +79 -0
  68. package/docs/overview.md +2 -2
  69. package/docs/presentation-execution-review.md +9 -9
  70. package/docs/proposals/LAT-001-gesture-visual-customization.md +522 -0
  71. package/docs/proposals/LAT-002-shared-overlay-canvas.md +353 -0
  72. package/docs/proposals/LAT-003-menu-bar-controller-architecture.md +291 -0
  73. package/docs/proposals/LAT-004-interactive-overlay-actors.md +534 -0
  74. package/docs/reference/dewey.config.ts +74 -0
  75. package/docs/reference/install-agent.md +79 -0
  76. package/docs/repo-structure.md +100 -0
  77. package/docs/voice-error-model.md +7 -7
  78. package/docs/voice.md +18 -0
  79. package/package.json +23 -13
  80. package/swift/Package.swift +20 -0
  81. package/swift/Sources/DeckKit/DeckAction.swift +51 -0
  82. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +152 -0
  83. package/swift/Sources/DeckKit/DeckCockpit.swift +82 -0
  84. package/swift/Sources/DeckKit/DeckHost.swift +7 -0
  85. package/swift/Sources/DeckKit/DeckManifest.swift +145 -0
  86. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +533 -0
  87. package/swift/Sources/DeckKit/DeckTrackpad.swift +63 -0
  88. package/swift/Sources/DeckKit/DeckValue.swift +93 -0
  89. package/swift/Sources/DeckKit/DeckVoiceError.swift +88 -0
  90. package/swift/Tests/DeckKitTests/DeckKitTests.swift +286 -0
  91. package/app/Sources/AppShell/AppDelegate.swift +0 -408
  92. package/app/Sources/Core/Input/KeyboardRemapController.swift +0 -184
  93. package/app/Sources/Core/Input/KeyboardRemapStore.swift +0 -84
  94. package/app/Sources/Core/Input/MouseGestureController.swift +0 -1203
  95. package/app/Sources/Core/Input/MouseShortcutStore.swift +0 -107
  96. /package/{app → apps/mac}/Info.plist +0 -0
  97. /package/{app → apps/mac}/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  98. /package/{app → apps/mac}/Lattices.app/Contents/Resources/tap.wav +0 -0
  99. /package/{app → apps/mac}/Lattices.app/Contents/_CodeSignature/CodeResources +0 -0
  100. /package/{app → apps/mac}/Lattices.entitlements +0 -0
  101. /package/{app → apps/mac}/Resources/tap.wav +0 -0
  102. /package/{app → apps/mac}/Sources/AppShell/App.swift +0 -0
  103. /package/{app → apps/mac}/Sources/AppShell/CliActionLauncher.swift +0 -0
  104. /package/{app → apps/mac}/Sources/AppShell/HomeDashboardView.swift +0 -0
  105. /package/{app → apps/mac}/Sources/AppShell/KeyRecorderView.swift +0 -0
  106. /package/{app → apps/mac}/Sources/AppShell/MainWindow.swift +0 -0
  107. /package/{app → apps/mac}/Sources/Core/Actions/HotkeyManager.swift +0 -0
  108. /package/{app → apps/mac}/Sources/Core/Actions/IntentSchema.swift +0 -0
  109. /package/{app → apps/mac}/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -0
  110. /package/{app → apps/mac}/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -0
  111. /package/{app → apps/mac}/Sources/Core/Actions/Intents/FocusIntent.swift +0 -0
  112. /package/{app → apps/mac}/Sources/Core/Actions/Intents/HelpIntent.swift +0 -0
  113. /package/{app → apps/mac}/Sources/Core/Actions/Intents/KillIntent.swift +0 -0
  114. /package/{app → apps/mac}/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -0
  115. /package/{app → apps/mac}/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -0
  116. /package/{app → apps/mac}/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -0
  117. /package/{app → apps/mac}/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -0
  118. /package/{app → apps/mac}/Sources/Core/Actions/Intents/ScanIntent.swift +0 -0
  119. /package/{app → apps/mac}/Sources/Core/Actions/Intents/SearchIntent.swift +0 -0
  120. /package/{app → apps/mac}/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -0
  121. /package/{app → apps/mac}/Sources/Core/Actions/Intents/TileIntent.swift +0 -0
  122. /package/{app → apps/mac}/Sources/Core/Actions/PaletteCommand.swift +0 -0
  123. /package/{app → apps/mac}/Sources/Core/Actions/VoiceIntentResolver.swift +0 -0
  124. /package/{app → apps/mac}/Sources/Core/Companion/CompanionActivityLog.swift +0 -0
  125. /package/{app → apps/mac}/Sources/Core/Companion/CompanionKeyboardController.swift +0 -0
  126. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -0
  127. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -0
  128. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -0
  129. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -0
  130. /package/{app → apps/mac}/Sources/Core/Companion/LatticesDeckHost.swift +0 -0
  131. /package/{app → apps/mac}/Sources/Core/Daemon/DaemonProtocol.swift +0 -0
  132. /package/{app → apps/mac}/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -0
  133. /package/{app → apps/mac}/Sources/Core/Desktop/AppTypeClassifier.swift +0 -0
  134. /package/{app → apps/mac}/Sources/Core/Desktop/DesktopModel.swift +0 -0
  135. /package/{app → apps/mac}/Sources/Core/Desktop/DesktopModelTypes.swift +0 -0
  136. /package/{app → apps/mac}/Sources/Core/Desktop/InventoryManager.swift +0 -0
  137. /package/{app → apps/mac}/Sources/Core/Desktop/InventoryPath.swift +0 -0
  138. /package/{app → apps/mac}/Sources/Core/Desktop/MouseFinder.swift +0 -0
  139. /package/{app → apps/mac}/Sources/Core/Desktop/OcrStore.swift +0 -0
  140. /package/{app → apps/mac}/Sources/Core/Desktop/PlacementSpec.swift +0 -0
  141. /package/{app → apps/mac}/Sources/Core/Desktop/SessionWindowLocator.swift +0 -0
  142. /package/{app → apps/mac}/Sources/Core/Desktop/TilePickerView.swift +0 -0
  143. /package/{app → apps/mac}/Sources/Core/Desktop/WindowPreviewCard.swift +0 -0
  144. /package/{app → apps/mac}/Sources/Core/Desktop/WindowSelectionStore.swift +0 -0
  145. /package/{app → apps/mac}/Sources/Core/Input/KeyboardRemapConfig.swift +0 -0
  146. /package/{app → apps/mac}/Sources/Core/Input/MouseInputDeviceStore.swift +0 -0
  147. /package/{app → apps/mac}/Sources/Core/Input/MouseInputEventViewer.swift +0 -0
  148. /package/{app → apps/mac}/Sources/Core/Overlays/AppWindowShell.swift +0 -0
  149. /package/{app → apps/mac}/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -0
  150. /package/{app → apps/mac}/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -0
  151. /package/{app → apps/mac}/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -0
  152. /package/{app → apps/mac}/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -0
  153. /package/{app → apps/mac}/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -0
  154. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -0
  155. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -0
  156. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDController.swift +0 -0
  157. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -0
  158. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -0
  159. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -0
  160. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDState.swift +0 -0
  161. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -0
  162. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -0
  163. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -0
  164. /package/{app → apps/mac}/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -0
  165. /package/{app → apps/mac}/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -0
  166. /package/{app → apps/mac}/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -0
  167. /package/{app → apps/mac}/Sources/Core/Overlays/OverlayPanelShell.swift +0 -0
  168. /package/{app → apps/mac}/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -0
  169. /package/{app → apps/mac}/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -0
  170. /package/{app → apps/mac}/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -0
  171. /package/{app → apps/mac}/Sources/Core/Pi/PiAuthPromptCard.swift +0 -0
  172. /package/{app → apps/mac}/Sources/Core/Pi/PiInstallCallout.swift +0 -0
  173. /package/{app → apps/mac}/Sources/Core/System/DiagnosticLog.swift +0 -0
  174. /package/{app → apps/mac}/Sources/Core/System/EventBus.swift +0 -0
  175. /package/{app → apps/mac}/Sources/Core/System/ProcessModel.swift +0 -0
  176. /package/{app → apps/mac}/Sources/Core/System/ProcessQuery.swift +0 -0
  177. /package/{app → apps/mac}/Sources/Core/System/SystemTelemetryMonitor.swift +0 -0
  178. /package/{app → apps/mac}/Sources/Core/Voice/AdvisorLearningStore.swift +0 -0
  179. /package/{app → apps/mac}/Sources/Core/Voice/AgentSession.swift +0 -0
  180. /package/{app → apps/mac}/Sources/Core/Voice/HandsOffSession.swift +0 -0
  181. /package/{app → apps/mac}/Sources/Core/Voice/VoiceChatView.swift +0 -0
  182. /package/{app → apps/mac}/Sources/Core/Voice/VoxClient.swift +0 -0
  183. /package/{app → apps/mac}/Sources/Core/Workspace/Project.swift +0 -0
  184. /package/{app → apps/mac}/Sources/Core/Workspace/ProjectScanner.swift +0 -0
  185. /package/{app → apps/mac}/Sources/Core/Workspace/SessionLayerStore.swift +0 -0
  186. /package/{app → apps/mac}/Sources/Core/Workspace/SessionManager.swift +0 -0
  187. /package/{app → apps/mac}/Sources/Core/Workspace/Terminal/Terminal.swift +0 -0
  188. /package/{app → apps/mac}/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -0
  189. /package/{app → apps/mac}/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -0
  190. /package/{app → apps/mac}/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -0
  191. /package/{app → apps/mac}/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -0
  192. /package/{app → apps/mac}/Sources/Core/Workspace/WorkspaceManager.swift +0 -0
  193. /package/{app → apps/mac}/Sources/UI/ActionRow.swift +0 -0
  194. /package/{app → apps/mac}/Sources/UI/OrphanRow.swift +0 -0
  195. /package/{app → apps/mac}/Sources/UI/ProjectRow.swift +0 -0
  196. /package/{app → apps/mac}/Sources/UI/TabGroupRow.swift +0 -0
  197. /package/{app → apps/mac}/Sources/UI/Theme.swift +0 -0
  198. /package/{app → apps/mac}/Tests/StageDragTests.swift +0 -0
  199. /package/{app → apps/mac}/Tests/StageJoinTests.swift +0 -0
  200. /package/{app → apps/mac}/Tests/StageManagerTests.swift +0 -0
  201. /package/{app → apps/mac}/Tests/StageTileTests.swift +0 -0
@@ -0,0 +1,1264 @@
1
+ import AppKit
2
+
3
+ struct ScreenOverlayLayerID: Hashable {
4
+ let rawValue: String
5
+
6
+ init(_ rawValue: String) {
7
+ self.rawValue = rawValue
8
+ }
9
+ }
10
+
11
+ enum ScreenOverlayOwner: String {
12
+ case dragSnap
13
+ case mouseGesture
14
+ case hotkeyHints
15
+ case focusHighlight
16
+ case agentApi
17
+ }
18
+
19
+ enum ScreenOverlayScreenTarget: Equatable {
20
+ case screen(id: String)
21
+ case all
22
+ }
23
+
24
+ struct ScreenOverlayLayerSnapshot {
25
+ let id: ScreenOverlayLayerID
26
+ let owner: ScreenOverlayOwner
27
+ let screen: ScreenOverlayScreenTarget
28
+ let zIndex: Int
29
+ let opacity: CGFloat
30
+ let payload: ScreenOverlayPayload
31
+ let expiresAt: Date?
32
+ }
33
+
34
+ enum ScreenOverlayPayload {
35
+ case snapZones(ScreenOverlaySnapZonesPayload)
36
+ case toast(ScreenOverlayTextPayload)
37
+ case label(ScreenOverlayTextPayload)
38
+ case highlight(ScreenOverlayHighlightPayload)
39
+ case pet(ScreenOverlayPetPayload)
40
+ }
41
+
42
+ struct ScreenOverlaySnapZone {
43
+ let id: String
44
+ let label: String
45
+ let rect: CGRect
46
+ let isHovered: Bool
47
+ }
48
+
49
+ struct ScreenOverlaySnapZonesPayload {
50
+ let zones: [ScreenOverlaySnapZone]
51
+ let previewRect: CGRect?
52
+ let previewLabel: String?
53
+ let zoneOpacity: CGFloat
54
+ let highlightOpacity: CGFloat
55
+ let previewOpacity: CGFloat
56
+ let cornerRadius: CGFloat
57
+ }
58
+
59
+ struct ScreenOverlayTextPayload {
60
+ let text: String
61
+ let detail: String?
62
+ let point: CGPoint?
63
+ let placement: ScreenOverlayPlacement
64
+ let style: ScreenOverlayStyle
65
+ }
66
+
67
+ struct ScreenOverlayHighlightPayload {
68
+ let rect: CGRect
69
+ let label: String?
70
+ let style: ScreenOverlayStyle
71
+ let cornerRadius: CGFloat
72
+ }
73
+
74
+ struct ScreenOverlayPetPayload {
75
+ let glyph: String
76
+ let petID: String?
77
+ let state: String?
78
+ let name: String?
79
+ let message: String?
80
+ let point: CGPoint?
81
+ let placement: ScreenOverlayPlacement
82
+ let style: ScreenOverlayStyle
83
+ let isDragging: Bool
84
+ let dismissible: Bool
85
+
86
+ func moved(to point: CGPoint, state nextState: String?, isDragging nextIsDragging: Bool? = nil) -> ScreenOverlayPetPayload {
87
+ ScreenOverlayPetPayload(
88
+ glyph: glyph,
89
+ petID: petID,
90
+ state: nextState ?? state,
91
+ name: name,
92
+ message: message,
93
+ point: point,
94
+ placement: .point,
95
+ style: style,
96
+ isDragging: nextIsDragging ?? isDragging,
97
+ dismissible: dismissible
98
+ )
99
+ }
100
+ }
101
+
102
+ enum ScreenOverlayPlacement: String {
103
+ case top
104
+ case bottom
105
+ case center
106
+ case cursor
107
+ case point
108
+ }
109
+
110
+ enum ScreenOverlayStyle: String {
111
+ case info
112
+ case success
113
+ case warning
114
+ case danger
115
+ case playful
116
+ }
117
+
118
+ final class ScreenOverlayCanvasController {
119
+ static let shared = ScreenOverlayCanvasController()
120
+
121
+ private var windowsByScreenID: [String: ScreenOverlayWindow] = [:]
122
+ private var layersByID: [ScreenOverlayLayerID: ScreenOverlayLayerSnapshot] = [:]
123
+ private var motionsByLayerID: [ScreenOverlayLayerID: OverlayLayerMotion] = [:]
124
+ private var animationTimer: Timer?
125
+ private var globalDismissMonitor: Any?
126
+ private var localDismissMonitor: Any?
127
+ private var dragState: OverlayActorDragState?
128
+ private var actorDragTimeoutTimer: Timer?
129
+ private var agentActorsHidden = false
130
+ private let maxActorDragDuration: TimeInterval = 8.0
131
+
132
+ private init() {}
133
+
134
+ func warmUp() {
135
+ reconcileScreens()
136
+ for window in windowsByScreenID.values {
137
+ window.orderFrontRegardless()
138
+ window.alphaValue = 0
139
+ }
140
+ }
141
+
142
+ func reconcileScreens() {
143
+ let currentScreenIDs = Set(NSScreen.screens.map(Self.screenID(for:)))
144
+ for staleID in windowsByScreenID.keys where !currentScreenIDs.contains(staleID) {
145
+ windowsByScreenID[staleID]?.orderOut(nil)
146
+ windowsByScreenID.removeValue(forKey: staleID)
147
+ }
148
+
149
+ for screen in NSScreen.screens {
150
+ let screenID = Self.screenID(for: screen)
151
+ let window = windowsByScreenID[screenID] ?? makeWindow(for: screen)
152
+ window.setFrame(screen.frame, display: false)
153
+ window.overlayView.frame = NSRect(origin: .zero, size: screen.frame.size)
154
+ windowsByScreenID[screenID] = window
155
+ }
156
+ }
157
+
158
+ func publishLayer(_ layer: ScreenOverlayLayerSnapshot) {
159
+ layersByID[layer.id] = layer
160
+ scheduleExpiration(for: layer)
161
+ render()
162
+ updateLifecycleMonitors()
163
+ }
164
+
165
+ func replaceLayers(owner: ScreenOverlayOwner, with layers: [ScreenOverlayLayerSnapshot]) {
166
+ layersByID = layersByID.filter { _, layer in layer.owner != owner }
167
+ for layer in layers {
168
+ layersByID[layer.id] = layer
169
+ scheduleExpiration(for: layer)
170
+ }
171
+ render()
172
+ updateLifecycleMonitors()
173
+ }
174
+
175
+ func removeLayer(id: ScreenOverlayLayerID) {
176
+ layersByID.removeValue(forKey: id)
177
+ motionsByLayerID.removeValue(forKey: id)
178
+ if dragState?.id == id {
179
+ dragState = nil
180
+ cancelActorDragTimeout()
181
+ resetPointerCapture()
182
+ }
183
+ render()
184
+ updateLifecycleMonitors()
185
+ }
186
+
187
+ func removeLayers(owner: ScreenOverlayOwner) {
188
+ let removedIDs = Set(layersByID.values.filter { $0.owner == owner }.map(\.id))
189
+ layersByID = layersByID.filter { _, layer in layer.owner != owner }
190
+ motionsByLayerID = motionsByLayerID.filter { id, _ in layersByID[id] != nil }
191
+ if let dragState, removedIDs.contains(dragState.id) {
192
+ self.dragState = nil
193
+ cancelActorDragTimeout()
194
+ resetPointerCapture()
195
+ }
196
+ render()
197
+ updateLifecycleMonitors()
198
+ }
199
+
200
+ func toggleAgentActorsVisibility() {
201
+ agentActorsHidden.toggle()
202
+ if agentActorsHidden {
203
+ dragState = nil
204
+ cancelActorDragTimeout()
205
+ resetPointerCapture()
206
+ }
207
+ render()
208
+ updateLifecycleMonitors()
209
+ }
210
+
211
+ func resetInputCapture(reason: String) {
212
+ dragState = nil
213
+ cancelActorDragTimeout()
214
+ resetPointerCapture()
215
+ DiagnosticLog.shared.warn("ScreenOverlay: input capture reset for \(reason)")
216
+ }
217
+
218
+ @discardableResult
219
+ func moveLayer(id: ScreenOverlayLayerID, to target: CGPoint, durationMs: Int, easing: String?) -> Bool {
220
+ guard let layer = layersByID[id],
221
+ case .pet(let payload) = layer.payload else { return false }
222
+ let now = Date()
223
+ let currentPoint = motionsByLayerID[id]?.point(at: now) ?? payload.point ?? target
224
+ let duration = max(0.08, min(Double(durationMs) / 1000.0, 8.0))
225
+ let restingState = payload.state == "run_left" || payload.state == "run_right" ? "idle" : payload.state
226
+ let movingState = target.x < currentPoint.x - 2 ? "run_left" : "run_right"
227
+
228
+ motionsByLayerID[id] = OverlayLayerMotion(
229
+ from: currentPoint,
230
+ to: target,
231
+ startedAt: now,
232
+ duration: duration,
233
+ easing: OverlayLayerMotion.Easing.parse(easing),
234
+ restingState: restingState
235
+ )
236
+ layersByID[id] = layer.replacingPayload(.pet(payload.moved(to: currentPoint, state: movingState)))
237
+ render()
238
+ updateLifecycleMonitors()
239
+ return true
240
+ }
241
+
242
+ static func screenID(for screen: NSScreen) -> String {
243
+ let key = NSDeviceDescriptionKey("NSScreenNumber")
244
+ if let number = screen.deviceDescription[key] as? NSNumber {
245
+ return number.stringValue
246
+ }
247
+ return screen.localizedName
248
+ }
249
+
250
+ private func makeWindow(for screen: NSScreen) -> ScreenOverlayWindow {
251
+ let window = ScreenOverlayWindow(frame: screen.frame)
252
+ windowsByScreenID[Self.screenID(for: screen)] = window
253
+ return window
254
+ }
255
+
256
+ private func render() {
257
+ reconcileScreens()
258
+ dropExpiredLayers()
259
+
260
+ for screen in NSScreen.screens {
261
+ let screenID = Self.screenID(for: screen)
262
+ guard let window = windowsByScreenID[screenID] else { continue }
263
+ let visibleLayers = layersByID.values
264
+ .filter { layer in
265
+ if agentActorsHidden && layer.isParkableActor {
266
+ return false
267
+ }
268
+ switch layer.screen {
269
+ case .all:
270
+ return true
271
+ case .screen(let targetID):
272
+ return targetID == screenID
273
+ }
274
+ }
275
+ .sorted { left, right in
276
+ if left.zIndex != right.zIndex {
277
+ return left.zIndex < right.zIndex
278
+ }
279
+ return left.id.rawValue < right.id.rawValue
280
+ }
281
+
282
+ window.overlayView.layers = visibleLayers
283
+ if visibleLayers.isEmpty {
284
+ window.alphaValue = 0
285
+ } else {
286
+ window.alphaValue = 1
287
+ window.orderFrontRegardless()
288
+ }
289
+ }
290
+ }
291
+
292
+ private func dropExpiredLayers() {
293
+ let now = Date()
294
+ layersByID = layersByID.filter { _, layer in
295
+ guard let expiresAt = layer.expiresAt else { return true }
296
+ return expiresAt > now
297
+ }
298
+ motionsByLayerID = motionsByLayerID.filter { id, _ in layersByID[id] != nil }
299
+ }
300
+
301
+ private func scheduleExpiration(for layer: ScreenOverlayLayerSnapshot) {
302
+ guard let expiresAt = layer.expiresAt else { return }
303
+ let delay = max(0, expiresAt.timeIntervalSinceNow)
304
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
305
+ guard let self,
306
+ let current = self.layersByID[layer.id],
307
+ current.expiresAt == expiresAt else { return }
308
+ self.layersByID.removeValue(forKey: layer.id)
309
+ self.motionsByLayerID.removeValue(forKey: layer.id)
310
+ self.render()
311
+ self.updateLifecycleMonitors()
312
+ }
313
+ }
314
+
315
+ private func updateLifecycleMonitors() {
316
+ updateAnimationTimer()
317
+ updateDismissMonitors()
318
+ }
319
+
320
+ private func updateAnimationTimer() {
321
+ let needsAnimation = !motionsByLayerID.isEmpty || layersByID.values.contains { layer in
322
+ if case .pet = layer.payload { return true }
323
+ return false
324
+ }
325
+
326
+ if needsAnimation, animationTimer == nil {
327
+ animationTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
328
+ self?.tickAnimation()
329
+ }
330
+ } else if !needsAnimation {
331
+ animationTimer?.invalidate()
332
+ animationTimer = nil
333
+ }
334
+ }
335
+
336
+ private func tickAnimation() {
337
+ let now = Date()
338
+ var completedIDs: [ScreenOverlayLayerID] = []
339
+ for (id, motion) in motionsByLayerID {
340
+ guard let layer = layersByID[id],
341
+ case .pet(let payload) = layer.payload else {
342
+ completedIDs.append(id)
343
+ continue
344
+ }
345
+
346
+ let point = motion.point(at: now)
347
+ let isComplete = motion.isComplete(at: now)
348
+ let state: String?
349
+ if isComplete {
350
+ state = motion.restingState
351
+ completedIDs.append(id)
352
+ } else {
353
+ state = motion.to.x < motion.from.x ? "run_left" : "run_right"
354
+ }
355
+ layersByID[id] = layer.replacingPayload(.pet(payload.moved(to: point, state: state)))
356
+ }
357
+ for id in completedIDs {
358
+ motionsByLayerID.removeValue(forKey: id)
359
+ }
360
+
361
+ if !completedIDs.isEmpty {
362
+ updateLifecycleMonitors()
363
+ }
364
+ render()
365
+ for window in windowsByScreenID.values {
366
+ window.overlayView.needsDisplay = true
367
+ }
368
+ }
369
+
370
+ private func updateDismissMonitors() {
371
+ let hasAgentLayer = layersByID.values.contains { $0.owner == .agentApi }
372
+ if hasAgentLayer, globalDismissMonitor == nil {
373
+ let mask: NSEvent.EventTypeMask = [
374
+ .mouseMoved,
375
+ .leftMouseDown,
376
+ .leftMouseUp,
377
+ .rightMouseDown,
378
+ .otherMouseDown,
379
+ .leftMouseDragged,
380
+ .rightMouseDragged,
381
+ .otherMouseDragged,
382
+ ]
383
+ globalDismissMonitor = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in
384
+ DispatchQueue.main.async {
385
+ _ = self?.handlePointerEvent(event)
386
+ }
387
+ }
388
+ localDismissMonitor = NSEvent.addLocalMonitorForEvents(matching: mask.union(.keyDown)) { [weak self] event in
389
+ if event.type == .keyDown {
390
+ if event.keyCode == 53 {
391
+ self?.dismissAgentOverlays()
392
+ return nil
393
+ }
394
+ } else {
395
+ if self?.handlePointerEvent(event) == true {
396
+ return nil
397
+ }
398
+ }
399
+ return event
400
+ }
401
+ } else if !hasAgentLayer {
402
+ if let globalDismissMonitor {
403
+ NSEvent.removeMonitor(globalDismissMonitor)
404
+ self.globalDismissMonitor = nil
405
+ }
406
+ if let localDismissMonitor {
407
+ NSEvent.removeMonitor(localDismissMonitor)
408
+ self.localDismissMonitor = nil
409
+ }
410
+ dragState = nil
411
+ cancelActorDragTimeout()
412
+ resetPointerCapture()
413
+ }
414
+ }
415
+
416
+ @discardableResult
417
+ private func handlePointerEvent(_ event: NSEvent) -> Bool {
418
+ switch event.type {
419
+ case .mouseMoved:
420
+ updatePointerCapture(at: NSEvent.mouseLocation)
421
+ return false
422
+ case .leftMouseDown:
423
+ updatePointerCapture(at: NSEvent.mouseLocation)
424
+ if beginActorDrag(at: NSEvent.mouseLocation) {
425
+ return true
426
+ }
427
+ dismissAgentOverlays()
428
+ return false
429
+ case .leftMouseDragged:
430
+ if dragActor(to: NSEvent.mouseLocation) {
431
+ return true
432
+ }
433
+ dismissAgentOverlays()
434
+ return false
435
+ case .leftMouseUp:
436
+ let wasDragging = dragState != nil
437
+ endActorDrag()
438
+ updatePointerCapture(at: NSEvent.mouseLocation)
439
+ return wasDragging
440
+ case .rightMouseDown:
441
+ updatePointerCapture(at: NSEvent.mouseLocation)
442
+ if closeActor(at: NSEvent.mouseLocation) {
443
+ return true
444
+ }
445
+ dismissAgentOverlays()
446
+ return false
447
+ case .otherMouseDown, .rightMouseDragged, .otherMouseDragged:
448
+ dismissAgentOverlays()
449
+ return false
450
+ default:
451
+ return false
452
+ }
453
+ }
454
+
455
+ private func updatePointerCapture(at globalPoint: CGPoint) {
456
+ clearStaleActorDragIfNeeded()
457
+ let captureWindow: ScreenOverlayWindow?
458
+ if let dragState {
459
+ captureWindow = windowsByScreenID[dragState.screenID]
460
+ } else {
461
+ captureWindow = hitActor(at: globalPoint)?.window
462
+ }
463
+ for window in windowsByScreenID.values {
464
+ window.ignoresMouseEvents = window !== captureWindow
465
+ }
466
+ }
467
+
468
+ private func beginActorDrag(at globalPoint: CGPoint) -> Bool {
469
+ guard let hit = hitActor(at: globalPoint),
470
+ let layer = layersByID[hit.id],
471
+ case .pet(let payload) = layer.payload else { return false }
472
+ let currentPoint = motionsByLayerID[hit.id]?.point(at: Date()) ?? payload.point ?? hit.localPoint
473
+ motionsByLayerID.removeValue(forKey: hit.id)
474
+ dragState = OverlayActorDragState(
475
+ id: hit.id,
476
+ screenID: hit.screenID,
477
+ offset: CGPoint(x: hit.localPoint.x - currentPoint.x, y: hit.localPoint.y - currentPoint.y),
478
+ lastPoint: currentPoint,
479
+ startedAt: Date()
480
+ )
481
+ scheduleActorDragTimeout()
482
+ layersByID[hit.id] = layer.replacingPayload(.pet(payload.moved(to: currentPoint, state: "idle", isDragging: true)))
483
+ render()
484
+ updateLifecycleMonitors()
485
+ updatePointerCapture(at: globalPoint)
486
+ return true
487
+ }
488
+
489
+ private func dragActor(to globalPoint: CGPoint) -> Bool {
490
+ guard var dragState,
491
+ let hit = screenLocalPoint(for: globalPoint),
492
+ let layer = layersByID[dragState.id],
493
+ case .pet(let payload) = layer.payload else { return false }
494
+ clearStaleActorDragIfNeeded()
495
+ guard self.dragState != nil else { return false }
496
+ let nextPoint = CGPoint(
497
+ x: hit.localPoint.x - dragState.offset.x,
498
+ y: hit.localPoint.y - dragState.offset.y
499
+ )
500
+ let state = nextPoint.x < dragState.lastPoint.x - 1 ? "run_left" : "run_right"
501
+ layersByID[dragState.id] = layer.replacingPayload(.pet(payload.moved(to: nextPoint, state: state, isDragging: true)))
502
+ dragState.screenID = hit.screenID
503
+ dragState.lastPoint = nextPoint
504
+ self.dragState = dragState
505
+ render()
506
+ updatePointerCapture(at: globalPoint)
507
+ return true
508
+ }
509
+
510
+ private func endActorDrag() {
511
+ guard let dragState,
512
+ let layer = layersByID[dragState.id],
513
+ case .pet(let payload) = layer.payload else {
514
+ self.dragState = nil
515
+ resetPointerCapture()
516
+ return
517
+ }
518
+ layersByID[dragState.id] = layer.replacingPayload(.pet(payload.moved(to: dragState.lastPoint, state: "idle", isDragging: false)))
519
+ self.dragState = nil
520
+ cancelActorDragTimeout()
521
+ render()
522
+ updateLifecycleMonitors()
523
+ updatePointerCapture(at: NSEvent.mouseLocation)
524
+ }
525
+
526
+ private func clearStaleActorDragIfNeeded() {
527
+ guard let dragState,
528
+ Date().timeIntervalSince(dragState.startedAt) > maxActorDragDuration else { return }
529
+ DiagnosticLog.shared.warn("ScreenOverlay: stale actor drag cleared for \(dragState.id.rawValue)")
530
+ endActorDrag()
531
+ }
532
+
533
+ private func scheduleActorDragTimeout() {
534
+ cancelActorDragTimeout()
535
+ actorDragTimeoutTimer = Timer.scheduledTimer(withTimeInterval: maxActorDragDuration, repeats: false) { [weak self] _ in
536
+ guard let self, self.dragState != nil else { return }
537
+ DiagnosticLog.shared.warn("ScreenOverlay: actor drag timed out; releasing pointer capture")
538
+ self.endActorDrag()
539
+ }
540
+ }
541
+
542
+ private func cancelActorDragTimeout() {
543
+ actorDragTimeoutTimer?.invalidate()
544
+ actorDragTimeoutTimer = nil
545
+ }
546
+
547
+ private func resetPointerCapture() {
548
+ for window in windowsByScreenID.values {
549
+ window.ignoresMouseEvents = true
550
+ }
551
+ }
552
+
553
+ private func closeActor(at globalPoint: CGPoint) -> Bool {
554
+ guard let hit = hitActor(at: globalPoint),
555
+ let layer = layersByID[hit.id],
556
+ layer.isParkableActor else { return false }
557
+ layersByID.removeValue(forKey: hit.id)
558
+ motionsByLayerID.removeValue(forKey: hit.id)
559
+ if dragState?.id == hit.id {
560
+ dragState = nil
561
+ cancelActorDragTimeout()
562
+ }
563
+ render()
564
+ updateLifecycleMonitors()
565
+ updatePointerCapture(at: globalPoint)
566
+ return true
567
+ }
568
+
569
+ private func hitActor(at globalPoint: CGPoint) -> (id: ScreenOverlayLayerID, window: ScreenOverlayWindow, screenID: String, localPoint: CGPoint)? {
570
+ guard let hit = screenLocalPoint(for: globalPoint) else { return nil }
571
+ guard let id = hit.window.overlayView.layerID(at: hit.localPoint) else { return nil }
572
+ return (id, hit.window, hit.screenID, hit.localPoint)
573
+ }
574
+
575
+ private func screenLocalPoint(for globalPoint: CGPoint) -> (window: ScreenOverlayWindow, screenID: String, localPoint: CGPoint)? {
576
+ for (screenID, window) in windowsByScreenID where window.frame.contains(globalPoint) {
577
+ let localPoint = CGPoint(
578
+ x: globalPoint.x - window.frame.minX,
579
+ y: globalPoint.y - window.frame.minY
580
+ )
581
+ return (window, screenID, localPoint)
582
+ }
583
+ return nil
584
+ }
585
+
586
+ private func dismissAgentOverlays() {
587
+ let before = layersByID.count
588
+ layersByID = layersByID.filter { _, layer in
589
+ guard layer.owner == .agentApi else { return true }
590
+ return !layer.isDismissible
591
+ }
592
+ motionsByLayerID = motionsByLayerID.filter { id, _ in layersByID[id] != nil }
593
+ if let dragState, layersByID[dragState.id] == nil {
594
+ self.dragState = nil
595
+ cancelActorDragTimeout()
596
+ resetPointerCapture()
597
+ }
598
+ guard layersByID.count != before else { return }
599
+ render()
600
+ updateLifecycleMonitors()
601
+ }
602
+ }
603
+
604
+ private struct OverlayActorDragState {
605
+ let id: ScreenOverlayLayerID
606
+ var screenID: String
607
+ let offset: CGPoint
608
+ var lastPoint: CGPoint
609
+ let startedAt: Date
610
+ }
611
+
612
+ private extension ScreenOverlayLayerSnapshot {
613
+ var isDismissible: Bool {
614
+ switch payload {
615
+ case .pet(let payload):
616
+ return payload.dismissible
617
+ default:
618
+ return true
619
+ }
620
+ }
621
+
622
+ var isParkableActor: Bool {
623
+ owner == .agentApi && !isDismissible
624
+ }
625
+
626
+ func replacingPayload(_ payload: ScreenOverlayPayload) -> ScreenOverlayLayerSnapshot {
627
+ ScreenOverlayLayerSnapshot(
628
+ id: id,
629
+ owner: owner,
630
+ screen: screen,
631
+ zIndex: zIndex,
632
+ opacity: opacity,
633
+ payload: payload,
634
+ expiresAt: expiresAt
635
+ )
636
+ }
637
+ }
638
+
639
+ private struct OverlayLayerMotion {
640
+ enum Easing: String {
641
+ case linear
642
+ case easeInOut
643
+ case spring
644
+
645
+ static func parse(_ value: String?) -> Easing {
646
+ switch value?.lowercased() {
647
+ case "linear":
648
+ return .linear
649
+ case "easeinout", "ease-in-out", "ease_in_out":
650
+ return .easeInOut
651
+ case "spring", nil:
652
+ return .spring
653
+ default:
654
+ return .spring
655
+ }
656
+ }
657
+ }
658
+
659
+ let from: CGPoint
660
+ let to: CGPoint
661
+ let startedAt: Date
662
+ let duration: TimeInterval
663
+ let easing: Easing
664
+ let restingState: String?
665
+
666
+ func isComplete(at date: Date) -> Bool {
667
+ date.timeIntervalSince(startedAt) >= duration
668
+ }
669
+
670
+ func point(at date: Date) -> CGPoint {
671
+ let rawProgress = duration <= 0 ? 1 : min(max(date.timeIntervalSince(startedAt) / duration, 0), 1)
672
+ let progress = eased(rawProgress)
673
+ return CGPoint(
674
+ x: from.x + (to.x - from.x) * progress,
675
+ y: from.y + (to.y - from.y) * progress
676
+ )
677
+ }
678
+
679
+ private func eased(_ progress: Double) -> Double {
680
+ switch easing {
681
+ case .linear:
682
+ return progress
683
+ case .easeInOut:
684
+ return progress * progress * (3 - 2 * progress)
685
+ case .spring:
686
+ let damping = exp(-6.8 * progress)
687
+ let oscillation = cos(10.5 * progress)
688
+ return min(max(1 - damping * oscillation, 0), 1)
689
+ }
690
+ }
691
+ }
692
+
693
+ private final class ScreenOverlayWindow: NSPanel {
694
+ let overlayView = ScreenOverlayCanvasView(frame: .zero)
695
+
696
+ init(frame: CGRect) {
697
+ super.init(
698
+ contentRect: frame,
699
+ styleMask: [.borderless, .nonactivatingPanel],
700
+ backing: .buffered,
701
+ defer: false
702
+ )
703
+
704
+ isOpaque = false
705
+ backgroundColor = .clear
706
+ hasShadow = false
707
+ ignoresMouseEvents = true
708
+ level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
709
+ collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
710
+ isMovable = false
711
+ hidesOnDeactivate = false
712
+ animationBehavior = .none
713
+ alphaValue = 0
714
+ overlayView.frame = NSRect(origin: .zero, size: frame.size)
715
+ overlayView.autoresizingMask = [.width, .height]
716
+ contentView = overlayView
717
+ }
718
+
719
+ override var canBecomeKey: Bool { false }
720
+ override var canBecomeMain: Bool { false }
721
+ }
722
+
723
+ private final class ScreenOverlayCanvasView: NSView {
724
+ var layers: [ScreenOverlayLayerSnapshot] = [] {
725
+ didSet { needsDisplay = true }
726
+ }
727
+ private var interactiveRectsByLayerID: [ScreenOverlayLayerID: CGRect] = [:]
728
+
729
+ override init(frame frameRect: NSRect) {
730
+ super.init(frame: frameRect)
731
+ wantsLayer = true
732
+ layer?.backgroundColor = NSColor.clear.cgColor
733
+ }
734
+
735
+ required init?(coder: NSCoder) {
736
+ fatalError("init(coder:) has not been implemented")
737
+ }
738
+
739
+ override func draw(_ dirtyRect: NSRect) {
740
+ NSColor.clear.setFill()
741
+ bounds.fill()
742
+ interactiveRectsByLayerID.removeAll()
743
+
744
+ for layer in layers {
745
+ NSGraphicsContext.saveGraphicsState()
746
+ NSColor.black.withAlphaComponent(0).set()
747
+ switch layer.payload {
748
+ case .snapZones(let payload):
749
+ drawSnapZones(payload, opacity: layer.opacity)
750
+ case .toast(let payload):
751
+ drawTextPill(payload, opacity: layer.opacity, isToast: true)
752
+ case .label(let payload):
753
+ drawTextPill(payload, opacity: layer.opacity, isToast: false)
754
+ case .highlight(let payload):
755
+ drawHighlight(payload, opacity: layer.opacity)
756
+ case .pet(let payload):
757
+ drawPet(payload, id: layer.id, opacity: layer.opacity)
758
+ }
759
+ NSGraphicsContext.restoreGraphicsState()
760
+ }
761
+ }
762
+
763
+ func layerID(at point: CGPoint) -> ScreenOverlayLayerID? {
764
+ interactiveRectsByLayerID
765
+ .first { _, rect in rect.contains(point) }
766
+ .map(\.key)
767
+ }
768
+
769
+ private func drawTextPill(_ payload: ScreenOverlayTextPayload, opacity: CGFloat, isToast: Bool) {
770
+ let titleFont = NSFont.monospacedSystemFont(ofSize: isToast ? 13 : 11, weight: .semibold)
771
+ let detailFont = NSFont.systemFont(ofSize: 11, weight: .regular)
772
+ let maxWidth = min(bounds.width - 64, isToast ? 460 : 320)
773
+ let title = attributed(payload.text, font: titleFont, color: NSColor.white.withAlphaComponent(0.94 * opacity))
774
+ let detail = payload.detail.map {
775
+ attributed($0, font: detailFont, color: NSColor.white.withAlphaComponent(0.66 * opacity))
776
+ }
777
+ let detailSize = detail?.boundingRect(
778
+ with: CGSize(width: maxWidth - 28, height: 120),
779
+ options: [.usesLineFragmentOrigin, .usesFontLeading]
780
+ ).size ?? .zero
781
+ let titleSize = title.boundingRect(
782
+ with: CGSize(width: maxWidth - 28, height: 80),
783
+ options: [.usesLineFragmentOrigin, .usesFontLeading]
784
+ ).size
785
+ let width = min(maxWidth, max(110, max(titleSize.width, detailSize.width) + 28))
786
+ let height = max(30, titleSize.height + (detail == nil ? 12 : detailSize.height + 18))
787
+ let origin = overlayOrigin(
788
+ placement: payload.placement,
789
+ point: payload.point,
790
+ size: CGSize(width: width, height: height),
791
+ margin: isToast ? 42 : 18
792
+ )
793
+ let rect = CGRect(origin: origin, size: CGSize(width: width, height: height))
794
+
795
+ drawPanel(rect, style: payload.style, opacity: opacity, radius: min(16, height / 2))
796
+ title.draw(with: CGRect(x: rect.minX + 14, y: rect.maxY - titleSize.height - (detail == nil ? 8 : 10), width: width - 28, height: titleSize.height), options: [.usesLineFragmentOrigin])
797
+ if let detail {
798
+ detail.draw(with: CGRect(x: rect.minX + 14, y: rect.minY + 8, width: width - 28, height: detailSize.height + 2), options: [.usesLineFragmentOrigin])
799
+ }
800
+ }
801
+
802
+ private func drawHighlight(_ payload: ScreenOverlayHighlightPayload, opacity: CGFloat) {
803
+ let rect = payload.rect.insetBy(dx: -3, dy: -3)
804
+ let radius = min(payload.cornerRadius, min(rect.width, rect.height) * 0.2)
805
+ let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
806
+ let tint = color(for: payload.style)
807
+
808
+ let shadow = NSShadow()
809
+ shadow.shadowBlurRadius = 18
810
+ shadow.shadowOffset = .zero
811
+ shadow.shadowColor = tint.withAlphaComponent(0.25 * opacity)
812
+ NSGraphicsContext.saveGraphicsState()
813
+ shadow.set()
814
+ tint.withAlphaComponent(0.08 * opacity).setFill()
815
+ path.fill()
816
+ NSGraphicsContext.restoreGraphicsState()
817
+
818
+ path.lineWidth = 2
819
+ tint.withAlphaComponent(0.82 * opacity).setStroke()
820
+ path.stroke()
821
+
822
+ if let label = payload.label, !label.isEmpty {
823
+ let textPayload = ScreenOverlayTextPayload(
824
+ text: label,
825
+ detail: nil,
826
+ point: CGPoint(x: rect.minX + 14, y: rect.maxY + 18),
827
+ placement: .point,
828
+ style: payload.style
829
+ )
830
+ drawTextPill(textPayload, opacity: opacity, isToast: false)
831
+ }
832
+ }
833
+
834
+ private func drawPet(_ payload: ScreenOverlayPetPayload, id: ScreenOverlayLayerID, opacity: CGFloat) {
835
+ let glyphFont = NSFont.systemFont(ofSize: 44, weight: .regular)
836
+ let nameFont = NSFont.systemFont(ofSize: 12, weight: .semibold)
837
+ let messageFont = NSFont.systemFont(ofSize: 12, weight: .regular)
838
+ let glyph = attributed(payload.glyph, font: glyphFont, color: NSColor.white.withAlphaComponent(0.96 * opacity))
839
+ let name = payload.name.map { attributed($0, font: nameFont, color: NSColor.white.withAlphaComponent(0.96 * opacity)) }
840
+ let message = payload.message.map {
841
+ attributed($0, font: messageFont, color: NSColor.white.withAlphaComponent(0.86 * opacity))
842
+ }
843
+ let artSize = CGSize(width: 96, height: 104)
844
+ let textWidth: CGFloat = (name == nil && message == nil) ? 0 : 228
845
+ let textHeight = textPlateHeight(name: name, message: message, width: textWidth)
846
+ let bubbleWidth = artSize.width + (textWidth > 0 ? textWidth + 10 : 0)
847
+ let bubbleHeight = max(artSize.height, textHeight)
848
+ let origin = overlayOrigin(
849
+ placement: payload.placement,
850
+ point: payload.point,
851
+ size: CGSize(width: bubbleWidth, height: bubbleHeight),
852
+ margin: 30
853
+ )
854
+ let rect = CGRect(origin: origin, size: CGSize(width: bubbleWidth, height: bubbleHeight))
855
+ let artRect = CGRect(
856
+ x: rect.minX,
857
+ y: rect.midY - artSize.height / 2,
858
+ width: artSize.width,
859
+ height: artSize.height
860
+ )
861
+ let dragPhase = Date().timeIntervalSinceReferenceDate * 11
862
+ let dragLift: CGFloat = payload.isDragging ? 8 + CGFloat(sin(dragPhase)) * 2.5 : 0
863
+ let dragTilt: CGFloat = payload.isDragging
864
+ ? (payload.state == "run_left" ? 7 : -7) + CGFloat(sin(dragPhase * 0.72)) * 2.5
865
+ : 0
866
+ let dragScaleX: CGFloat = payload.isDragging ? 1.05 + CGFloat(sin(dragPhase * 0.9)) * 0.018 : 1
867
+ let dragScaleY: CGFloat = payload.isDragging ? 0.98 + CGFloat(cos(dragPhase * 0.9)) * 0.018 : 1
868
+ let bodyRect = artRect.offsetBy(dx: 0, dy: dragLift)
869
+
870
+ NSGraphicsContext.saveGraphicsState()
871
+ if payload.isDragging {
872
+ let transform = NSAffineTransform()
873
+ transform.translateX(by: bodyRect.midX, yBy: bodyRect.midY)
874
+ transform.rotate(byDegrees: dragTilt)
875
+ transform.scaleX(by: dragScaleX, yBy: dragScaleY)
876
+ transform.translateX(by: -bodyRect.midX, yBy: -bodyRect.midY)
877
+ transform.concat()
878
+ }
879
+ if let petID = payload.petID,
880
+ let frame = CodexPetAssetCache.shared.frame(for: petID, state: payload.state) {
881
+ frame.image.draw(
882
+ in: bodyRect,
883
+ from: frame.sourceRect,
884
+ operation: .sourceOver,
885
+ fraction: opacity,
886
+ respectFlipped: true,
887
+ hints: [.interpolation: NSImageInterpolation.high]
888
+ )
889
+ } else {
890
+ glyph.draw(
891
+ with: CGRect(x: bodyRect.midX - 26, y: bodyRect.midY - 26, width: 52, height: 52),
892
+ options: [.usesLineFragmentOrigin]
893
+ )
894
+ }
895
+ NSGraphicsContext.restoreGraphicsState()
896
+
897
+ guard textWidth > 0 else {
898
+ interactiveRectsByLayerID[id] = artRect.insetBy(dx: -8, dy: -8)
899
+ return
900
+ }
901
+ let textRect = CGRect(
902
+ x: artRect.maxX + 10,
903
+ y: rect.midY - textHeight / 2,
904
+ width: textWidth,
905
+ height: textHeight
906
+ )
907
+ interactiveRectsByLayerID[id] = artRect.union(textRect).insetBy(dx: -8, dy: -8)
908
+ drawTranslucentTextWash(textRect, opacity: opacity)
909
+
910
+ var cursorY = textRect.maxY - 10
911
+ if let name {
912
+ let nameRect = CGRect(x: textRect.minX + 12, y: cursorY - 16, width: textRect.width - 24, height: 16)
913
+ drawCrispOverlayText(name, in: nameRect, opacity: opacity)
914
+ cursorY = nameRect.minY - 4
915
+ }
916
+ if let message {
917
+ let messageRect = CGRect(x: textRect.minX + 12, y: textRect.minY + 10, width: textRect.width - 24, height: max(18, cursorY - textRect.minY - 10))
918
+ drawCrispOverlayText(message, in: messageRect, opacity: opacity)
919
+ }
920
+ }
921
+
922
+ private func textPlateHeight(name: NSAttributedString?, message: NSAttributedString?, width: CGFloat) -> CGFloat {
923
+ guard width > 0 else { return 0 }
924
+ let messageSize = message?.boundingRect(
925
+ with: CGSize(width: width - 24, height: 72),
926
+ options: [.usesLineFragmentOrigin, .usesFontLeading]
927
+ ).size ?? .zero
928
+ return max(38, (name == nil ? 0 : 20) + (message == nil ? 0 : ceil(messageSize.height) + 10) + 18)
929
+ }
930
+
931
+ private func drawTranslucentTextWash(_ rect: CGRect, opacity: CGFloat) {
932
+ let path = NSBezierPath(roundedRect: rect, xRadius: 8, yRadius: 8)
933
+ NSColor(calibratedWhite: 0.02, alpha: 0.34 * opacity).setFill()
934
+ path.fill()
935
+
936
+ path.lineWidth = 0.5
937
+ NSColor.white.withAlphaComponent(0.10 * opacity).setStroke()
938
+ path.stroke()
939
+ }
940
+
941
+ private func drawCrispOverlayText(_ text: NSAttributedString, in rect: CGRect, opacity: CGFloat) {
942
+ let shadow = NSShadow()
943
+ shadow.shadowBlurRadius = 2
944
+ shadow.shadowOffset = NSSize(width: 0, height: -1)
945
+ shadow.shadowColor = NSColor.black.withAlphaComponent(0.72 * opacity)
946
+
947
+ NSGraphicsContext.saveGraphicsState()
948
+ shadow.set()
949
+ text.draw(with: rect.offsetBy(dx: 0, dy: -0.5), options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
950
+ NSGraphicsContext.restoreGraphicsState()
951
+
952
+ let halo = NSMutableAttributedString(attributedString: text)
953
+ halo.addAttribute(.foregroundColor, value: NSColor.black.withAlphaComponent(0.36 * opacity), range: NSRange(location: 0, length: halo.length))
954
+ halo.draw(with: rect.offsetBy(dx: 0.5, dy: -0.5), options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
955
+ halo.draw(with: rect.offsetBy(dx: -0.5, dy: -0.5), options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
956
+
957
+ text.draw(with: rect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
958
+ }
959
+
960
+ private func drawSnapZones(_ payload: ScreenOverlaySnapZonesPayload, opacity: CGFloat) {
961
+ for zone in payload.zones {
962
+ drawSnapZone(zone, payload: payload, opacity: opacity)
963
+ }
964
+
965
+ if let previewRect = payload.previewRect {
966
+ drawSnapPreview(previewRect, label: payload.previewLabel, payload: payload, opacity: opacity)
967
+ }
968
+ }
969
+
970
+ private func drawSnapZone(
971
+ _ zone: ScreenOverlaySnapZone,
972
+ payload: ScreenOverlaySnapZonesPayload,
973
+ opacity: CGFloat
974
+ ) {
975
+ let rect = zone.rect.insetBy(dx: 1.5, dy: 1.5)
976
+ let radius = min(payload.cornerRadius, min(rect.width, rect.height) * 0.34)
977
+ let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
978
+ let idleStrength = max(0.35, min(payload.zoneOpacity / 0.10, 1.4))
979
+ let hoverStrength = max(0.35, min(payload.highlightOpacity / 0.22, 1.4))
980
+
981
+ let shadow = NSShadow()
982
+ shadow.shadowBlurRadius = zone.isHovered ? 18 : 10
983
+ shadow.shadowOffset = NSSize(width: 0, height: -2)
984
+ shadow.shadowColor = NSColor.black.withAlphaComponent((zone.isHovered ? 0.20 : 0.10) * opacity)
985
+
986
+ NSGraphicsContext.saveGraphicsState()
987
+ shadow.set()
988
+ let baseTop = NSColor(
989
+ calibratedWhite: 0.13,
990
+ alpha: (zone.isHovered ? 0.42 * hoverStrength : 0.22 * idleStrength) * opacity
991
+ )
992
+ let baseBottom = NSColor(
993
+ calibratedWhite: 0.07,
994
+ alpha: (zone.isHovered ? 0.34 * hoverStrength : 0.15 * idleStrength) * opacity
995
+ )
996
+ NSGradient(starting: baseTop, ending: baseBottom)?.draw(in: path, angle: -90)
997
+ NSGraphicsContext.restoreGraphicsState()
998
+
999
+ if zone.isHovered {
1000
+ let glowPath = path.copy() as! NSBezierPath
1001
+ glowPath.lineWidth = 6
1002
+ NSColor(
1003
+ calibratedRed: 0.25,
1004
+ green: 0.84,
1005
+ blue: 0.58,
1006
+ alpha: payload.highlightOpacity * 0.28 * opacity
1007
+ ).setStroke()
1008
+ glowPath.stroke()
1009
+ }
1010
+
1011
+ path.lineWidth = zone.isHovered ? 1.6 : 1.0
1012
+ NSColor(
1013
+ calibratedRed: 0.52,
1014
+ green: 0.94,
1015
+ blue: 0.72,
1016
+ alpha: (zone.isHovered ? 0.54 * hoverStrength : 0.10 * idleStrength) * opacity
1017
+ ).setStroke()
1018
+ path.stroke()
1019
+
1020
+ let lipRect = CGRect(x: rect.minX + 1.5, y: rect.maxY - 2.5, width: rect.width - 3, height: 2)
1021
+ if lipRect.width > 0 {
1022
+ let lipPath = NSBezierPath(roundedRect: lipRect, xRadius: 1, yRadius: 1)
1023
+ NSColor.white.withAlphaComponent((zone.isHovered ? 0.18 : 0.08) * opacity).setFill()
1024
+ lipPath.fill()
1025
+ }
1026
+
1027
+ drawLabel(zone.label, in: rect, emphasized: zone.isHovered, opacity: opacity)
1028
+ }
1029
+
1030
+ private func drawSnapPreview(
1031
+ _ rect: CGRect,
1032
+ label: String?,
1033
+ payload: ScreenOverlaySnapZonesPayload,
1034
+ opacity: CGFloat
1035
+ ) {
1036
+ let previewRect = rect.insetBy(dx: 10, dy: 10)
1037
+ let radius = min(payload.cornerRadius, min(previewRect.width, previewRect.height) * 0.14)
1038
+ let path = NSBezierPath(roundedRect: previewRect, xRadius: radius, yRadius: radius)
1039
+
1040
+ NSColor(calibratedWhite: 1.0, alpha: payload.previewOpacity * 0.22 * opacity).setFill()
1041
+ path.fill()
1042
+
1043
+ path.lineWidth = 1.6
1044
+ path.setLineDash([10, 8], count: 2, phase: 0)
1045
+ NSColor(
1046
+ calibratedRed: 0.44,
1047
+ green: 0.90,
1048
+ blue: 0.68,
1049
+ alpha: max(0.34, payload.previewOpacity * 3.2) * opacity
1050
+ ).setStroke()
1051
+ path.stroke()
1052
+ path.setLineDash([], count: 0, phase: 0)
1053
+
1054
+ let innerPath = NSBezierPath(
1055
+ roundedRect: previewRect.insetBy(dx: 7, dy: 7),
1056
+ xRadius: max(radius - 4, 8),
1057
+ yRadius: max(radius - 4, 8)
1058
+ )
1059
+ innerPath.lineWidth = 1
1060
+ NSColor.white.withAlphaComponent(max(0.08, payload.previewOpacity * 1.2) * opacity).setStroke()
1061
+ innerPath.stroke()
1062
+
1063
+ if let label {
1064
+ let tagRect = CGRect(x: previewRect.minX + 14, y: previewRect.maxY - 34, width: 110, height: 24)
1065
+ let tagPath = NSBezierPath(roundedRect: tagRect, xRadius: 12, yRadius: 12)
1066
+ NSColor(calibratedWhite: 0.08, alpha: 0.62 * opacity).setFill()
1067
+ tagPath.fill()
1068
+ NSColor.white.withAlphaComponent(0.10 * opacity).setStroke()
1069
+ tagPath.lineWidth = 1
1070
+ tagPath.stroke()
1071
+ drawLabel(label, in: tagRect, emphasized: true, opacity: opacity)
1072
+ }
1073
+ }
1074
+
1075
+ private func drawLabel(_ label: String, in rect: CGRect, emphasized: Bool, opacity: CGFloat) {
1076
+ let font = NSFont.monospacedSystemFont(ofSize: emphasized ? 11 : 10, weight: emphasized ? .semibold : .medium)
1077
+ let attributes: [NSAttributedString.Key: Any] = [
1078
+ .font: font,
1079
+ .foregroundColor: NSColor.white.withAlphaComponent((emphasized ? 0.92 : 0.72) * opacity),
1080
+ ]
1081
+ let attributed = NSAttributedString(string: label.uppercased(), attributes: attributes)
1082
+ let size = attributed.size()
1083
+ let drawPoint = CGPoint(
1084
+ x: rect.midX - size.width / 2,
1085
+ y: rect.midY - size.height / 2
1086
+ )
1087
+ attributed.draw(at: drawPoint)
1088
+ }
1089
+
1090
+ private func attributed(_ text: String, font: NSFont, color: NSColor) -> NSAttributedString {
1091
+ NSAttributedString(string: text, attributes: [
1092
+ .font: font,
1093
+ .foregroundColor: color,
1094
+ ])
1095
+ }
1096
+
1097
+ private func overlayOrigin(
1098
+ placement: ScreenOverlayPlacement,
1099
+ point: CGPoint?,
1100
+ size: CGSize,
1101
+ margin: CGFloat
1102
+ ) -> CGPoint {
1103
+ let cursor = convertGlobalPointToLocal(NSEvent.mouseLocation)
1104
+ let anchor: CGPoint
1105
+ switch placement {
1106
+ case .top:
1107
+ anchor = CGPoint(x: bounds.midX, y: bounds.maxY - margin - size.height / 2)
1108
+ case .bottom:
1109
+ anchor = CGPoint(x: bounds.midX, y: bounds.minY + margin + size.height / 2)
1110
+ case .center:
1111
+ anchor = CGPoint(x: bounds.midX, y: bounds.midY)
1112
+ case .cursor:
1113
+ anchor = cursor
1114
+ case .point:
1115
+ anchor = point ?? cursor
1116
+ }
1117
+
1118
+ return CGPoint(
1119
+ x: min(max(anchor.x - size.width / 2, 16), bounds.maxX - size.width - 16),
1120
+ y: min(max(anchor.y - size.height / 2, 16), bounds.maxY - size.height - 16)
1121
+ )
1122
+ }
1123
+
1124
+ private func convertGlobalPointToLocal(_ point: CGPoint) -> CGPoint {
1125
+ guard let window else { return point }
1126
+ return CGPoint(x: point.x - window.frame.minX, y: point.y - window.frame.minY)
1127
+ }
1128
+
1129
+ private func drawPanel(_ rect: CGRect, style: ScreenOverlayStyle, opacity: CGFloat, radius: CGFloat) {
1130
+ let tint = color(for: style)
1131
+ let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
1132
+ let shadow = NSShadow()
1133
+ shadow.shadowBlurRadius = 18
1134
+ shadow.shadowOffset = NSSize(width: 0, height: -4)
1135
+ shadow.shadowColor = NSColor.black.withAlphaComponent(0.26 * opacity)
1136
+
1137
+ NSGraphicsContext.saveGraphicsState()
1138
+ shadow.set()
1139
+ NSGradient(
1140
+ starting: NSColor(calibratedWhite: 0.12, alpha: 0.90 * opacity),
1141
+ ending: NSColor(calibratedWhite: 0.06, alpha: 0.90 * opacity)
1142
+ )?.draw(in: path, angle: -90)
1143
+ NSGraphicsContext.restoreGraphicsState()
1144
+
1145
+ path.lineWidth = 1
1146
+ tint.withAlphaComponent(0.34 * opacity).setStroke()
1147
+ path.stroke()
1148
+
1149
+ let lipRect = CGRect(x: rect.minX + 10, y: rect.maxY - 2, width: rect.width - 20, height: 1)
1150
+ NSColor.white.withAlphaComponent(0.10 * opacity).setFill()
1151
+ NSBezierPath(roundedRect: lipRect, xRadius: 0.5, yRadius: 0.5).fill()
1152
+ }
1153
+
1154
+ private func color(for style: ScreenOverlayStyle) -> NSColor {
1155
+ switch style {
1156
+ case .info:
1157
+ return NSColor(calibratedRed: 0.36, green: 0.72, blue: 1.0, alpha: 1)
1158
+ case .success:
1159
+ return NSColor(calibratedRed: 0.38, green: 0.92, blue: 0.62, alpha: 1)
1160
+ case .warning:
1161
+ return NSColor(calibratedRed: 1.0, green: 0.66, blue: 0.24, alpha: 1)
1162
+ case .danger:
1163
+ return NSColor(calibratedRed: 1.0, green: 0.36, blue: 0.38, alpha: 1)
1164
+ case .playful:
1165
+ return NSColor(calibratedRed: 0.95, green: 0.66, blue: 1.0, alpha: 1)
1166
+ }
1167
+ }
1168
+ }
1169
+
1170
+ private final class CodexPetAssetCache {
1171
+ static let shared = CodexPetAssetCache()
1172
+
1173
+ struct Frame {
1174
+ let image: NSImage
1175
+ let sourceRect: CGRect
1176
+ }
1177
+
1178
+ private struct Metadata: Decodable {
1179
+ struct State: Decodable {
1180
+ let row: Int
1181
+ let frames: Int
1182
+ let frameWidth: CGFloat
1183
+ let frameHeight: CGFloat
1184
+ }
1185
+
1186
+ let spritesheetPath: String?
1187
+ let states: [String: State]?
1188
+ }
1189
+
1190
+ private var cache: [String: (image: NSImage, metadata: Metadata?)] = [:]
1191
+
1192
+ private init() {}
1193
+
1194
+ func frame(for petID: String, state requestedState: String?) -> Frame? {
1195
+ guard let asset = load(petID: petID),
1196
+ let size = asset.image.representations.first.map({ CGSize(width: $0.pixelsWide, height: $0.pixelsHigh) }) else {
1197
+ return nil
1198
+ }
1199
+
1200
+ let state = requestedState.flatMap { asset.metadata?.states?[$0] }
1201
+ ?? asset.metadata?.states?["idle"]
1202
+ ?? Metadata.State(row: 0, frames: 1, frameWidth: 192, frameHeight: 208)
1203
+ let frameWidth = max(1, state.frameWidth)
1204
+ let frameHeight = max(1, state.frameHeight)
1205
+ let frameCount = max(1, state.frames)
1206
+ let frameIndex = Int(Date().timeIntervalSinceReferenceDate * 8) % frameCount
1207
+ let row = max(0, state.row)
1208
+ let maxX = max(0, size.width - frameWidth)
1209
+ let y = max(0, size.height - CGFloat(row + 1) * frameHeight)
1210
+ return Frame(
1211
+ image: asset.image,
1212
+ sourceRect: CGRect(x: min(CGFloat(frameIndex) * frameWidth, maxX), y: y, width: frameWidth, height: frameHeight)
1213
+ )
1214
+ }
1215
+
1216
+ private func load(petID: String) -> (image: NSImage, metadata: Metadata?)? {
1217
+ if let cached = cache[petID] {
1218
+ return cached
1219
+ }
1220
+
1221
+ guard petID.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil else {
1222
+ return nil
1223
+ }
1224
+
1225
+ let root = bundledPetRoot(petID: petID) ?? codexPetRoot(petID: petID)
1226
+ let metadataURL = root.appendingPathComponent("pet.json")
1227
+ let metadata = try? JSONDecoder().decode(Metadata.self, from: Data(contentsOf: metadataURL))
1228
+ let spritesheetURL = root.appendingPathComponent(metadata?.spritesheetPath ?? "spritesheet.webp")
1229
+ guard let image = NSImage(contentsOf: spritesheetURL) else {
1230
+ return nil
1231
+ }
1232
+
1233
+ let asset = (image, metadata)
1234
+ cache[petID] = asset
1235
+ return asset
1236
+ }
1237
+
1238
+ private func bundledPetRoot(petID: String) -> URL? {
1239
+ let candidateRoots = [
1240
+ Bundle.main.resourceURL?.appendingPathComponent("Pets"),
1241
+ Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Pets"),
1242
+ URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
1243
+ .appendingPathComponent("apps/mac/Resources/Pets"),
1244
+ URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
1245
+ .appendingPathComponent("Resources/Pets"),
1246
+ ].compactMap { $0 }
1247
+
1248
+ for petsRoot in candidateRoots {
1249
+ let root = petsRoot.appendingPathComponent(petID)
1250
+ if FileManager.default.fileExists(atPath: root.appendingPathComponent("spritesheet.webp").path) {
1251
+ return root
1252
+ }
1253
+ }
1254
+
1255
+ return nil
1256
+ }
1257
+
1258
+ private func codexPetRoot(petID: String) -> URL {
1259
+ URL(fileURLWithPath: NSHomeDirectory())
1260
+ .appendingPathComponent(".codex")
1261
+ .appendingPathComponent("pets")
1262
+ .appendingPathComponent(petID)
1263
+ }
1264
+ }