@arach/lattices 0.2.1 → 0.6.1

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -69
  3. package/apps/mac/Info.plist +43 -0
  4. package/apps/mac/Lattices.app/Contents/Info.plist +43 -0
  5. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  6. package/apps/mac/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  7. package/apps/mac/Lattices.app/Contents/Resources/docs/assistant-knowledge.md +130 -0
  8. package/apps/mac/Lattices.app/Contents/Resources/tap.wav +0 -0
  9. package/apps/mac/Lattices.app/Contents/_CodeSignature/CodeResources +150 -0
  10. package/apps/mac/Lattices.entitlements +21 -0
  11. package/apps/mac/Resources/Pets/assistant-spark/pet.json +62 -0
  12. package/apps/mac/Resources/Pets/assistant-spark/spritesheet.webp +0 -0
  13. package/apps/mac/Resources/Pets/scout-ranger/pet.json +6 -0
  14. package/apps/mac/Resources/Pets/scout-ranger/spritesheet.webp +0 -0
  15. package/apps/mac/Resources/tap.wav +0 -0
  16. package/assets/AppIcon.icns +0 -0
  17. package/bin/assistant-intelligence.ts +912 -0
  18. package/bin/cli/capture.ts +252 -0
  19. package/bin/cli/daemon.ts +22 -0
  20. package/bin/cli/helpers.ts +105 -0
  21. package/bin/cli/layer.ts +178 -0
  22. package/bin/cli/runs.ts +43 -0
  23. package/bin/cli/search.ts +141 -0
  24. package/bin/cli/session.ts +32 -0
  25. package/bin/client.ts +17 -0
  26. package/bin/cua.ts +26 -0
  27. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  28. package/bin/handsoff-infer.ts +96 -0
  29. package/bin/handsoff-worker.ts +531 -0
  30. package/bin/infer.ts +424 -0
  31. package/bin/keychain.ts +75 -0
  32. package/bin/lattices-app.ts +655 -0
  33. package/bin/lattices-build +125 -0
  34. package/bin/lattices-build-env.ts +77 -0
  35. package/bin/lattices-dev +362 -0
  36. package/bin/lattices.ts +3260 -0
  37. package/bin/project-twin.ts +645 -0
  38. package/docs/agent-execution-plan.md +562 -0
  39. package/docs/agent-layer-guide.md +207 -0
  40. package/docs/agents.md +233 -0
  41. package/docs/ai-chat-ux-review.md +416 -0
  42. package/docs/api.md +1041 -47
  43. package/docs/app.md +96 -13
  44. package/docs/assistant-knowledge.md +130 -0
  45. package/docs/companion-deck.md +209 -0
  46. package/docs/component-extraction-roadmap.md +392 -0
  47. package/docs/concepts.md +13 -12
  48. package/docs/config.md +83 -10
  49. package/docs/gesture-customization-proposal.md +520 -0
  50. package/docs/handsoff-test-scenarios.md +84 -0
  51. package/docs/hyperspace-grid-snappiness.md +210 -0
  52. package/docs/layers.md +176 -28
  53. package/docs/mouse-gestures.md +244 -0
  54. package/docs/ocr.md +21 -9
  55. package/docs/overview.md +42 -23
  56. package/docs/presentation-execution-review.md +491 -0
  57. package/docs/prompts/hands-off-system.md +382 -0
  58. package/docs/prompts/hands-off-turn.md +30 -0
  59. package/docs/prompts/voice-advisor.md +31 -0
  60. package/docs/prompts/voice-fallback.md +23 -0
  61. package/docs/proposals/LAT-001-gesture-visual-customization.md +522 -0
  62. package/docs/proposals/LAT-002-shared-overlay-canvas.md +353 -0
  63. package/docs/proposals/LAT-003-menu-bar-controller-architecture.md +291 -0
  64. package/docs/proposals/LAT-004-interactive-overlay-actors.md +534 -0
  65. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  66. package/docs/proposals/LAT-006-followup-gaps.md +103 -0
  67. package/docs/proposals/LAT-006-runs-and-capture-in-lattices.md +566 -0
  68. package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
  69. package/docs/quickstart.md +8 -12
  70. package/docs/reference/dewey.config.ts +74 -0
  71. package/docs/reference/install-agent.md +79 -0
  72. package/docs/release.md +172 -0
  73. package/docs/repo-structure.md +100 -0
  74. package/docs/terminal-kit.md +87 -0
  75. package/docs/tiling-reference.md +224 -0
  76. package/docs/twins.md +138 -0
  77. package/docs/voice-command-protocol.md +278 -0
  78. package/docs/voice-error-model.md +73 -0
  79. package/docs/voice.md +221 -0
  80. package/package.json +69 -16
  81. package/packages/npm/sdk/cua.d.mts +1 -0
  82. package/packages/npm/sdk/cua.d.ts +188 -0
  83. package/packages/npm/sdk/cua.mjs +376 -0
  84. package/app/Lattices.app/Contents/Info.plist +0 -24
  85. package/app/Package.swift +0 -13
  86. package/app/Sources/ActionRow.swift +0 -61
  87. package/app/Sources/App.swift +0 -10
  88. package/app/Sources/AppDelegate.swift +0 -234
  89. package/app/Sources/AppShellView.swift +0 -62
  90. package/app/Sources/AppTypeClassifier.swift +0 -70
  91. package/app/Sources/AppWindowShell.swift +0 -63
  92. package/app/Sources/CheatSheetHUD.swift +0 -332
  93. package/app/Sources/CommandModeState.swift +0 -1362
  94. package/app/Sources/CommandModeView.swift +0 -1405
  95. package/app/Sources/CommandModeWindow.swift +0 -192
  96. package/app/Sources/CommandPaletteView.swift +0 -307
  97. package/app/Sources/CommandPaletteWindow.swift +0 -134
  98. package/app/Sources/DaemonProtocol.swift +0 -101
  99. package/app/Sources/DaemonServer.swift +0 -414
  100. package/app/Sources/DesktopModel.swift +0 -121
  101. package/app/Sources/DesktopModelTypes.swift +0 -71
  102. package/app/Sources/DiagnosticLog.swift +0 -271
  103. package/app/Sources/EventBus.swift +0 -30
  104. package/app/Sources/HotkeyManager.swift +0 -250
  105. package/app/Sources/HotkeyStore.swift +0 -338
  106. package/app/Sources/InventoryManager.swift +0 -35
  107. package/app/Sources/InventoryPath.swift +0 -43
  108. package/app/Sources/KeyRecorderView.swift +0 -210
  109. package/app/Sources/LatticesApi.swift +0 -1125
  110. package/app/Sources/MainView.swift +0 -467
  111. package/app/Sources/MainWindow.swift +0 -83
  112. package/app/Sources/OcrModel.swift +0 -309
  113. package/app/Sources/OcrStore.swift +0 -295
  114. package/app/Sources/OmniSearchState.swift +0 -283
  115. package/app/Sources/OmniSearchView.swift +0 -288
  116. package/app/Sources/OmniSearchWindow.swift +0 -105
  117. package/app/Sources/OrphanRow.swift +0 -129
  118. package/app/Sources/PaletteCommand.swift +0 -419
  119. package/app/Sources/PermissionChecker.swift +0 -125
  120. package/app/Sources/Preferences.swift +0 -92
  121. package/app/Sources/ProcessModel.swift +0 -199
  122. package/app/Sources/ProcessQuery.swift +0 -151
  123. package/app/Sources/Project.swift +0 -28
  124. package/app/Sources/ProjectRow.swift +0 -368
  125. package/app/Sources/ProjectScanner.swift +0 -121
  126. package/app/Sources/ScreenMapState.swift +0 -2387
  127. package/app/Sources/ScreenMapView.swift +0 -2820
  128. package/app/Sources/ScreenMapWindowController.swift +0 -89
  129. package/app/Sources/SessionManager.swift +0 -72
  130. package/app/Sources/SettingsView.swift +0 -1053
  131. package/app/Sources/SettingsWindow.swift +0 -20
  132. package/app/Sources/TabGroupRow.swift +0 -178
  133. package/app/Sources/Terminal.swift +0 -259
  134. package/app/Sources/TerminalQuery.swift +0 -156
  135. package/app/Sources/TerminalSynthesizer.swift +0 -200
  136. package/app/Sources/Theme.swift +0 -163
  137. package/app/Sources/TilePickerView.swift +0 -209
  138. package/app/Sources/TmuxModel.swift +0 -53
  139. package/app/Sources/TmuxQuery.swift +0 -81
  140. package/app/Sources/WindowTiler.swift +0 -1755
  141. package/app/Sources/WorkspaceManager.swift +0 -434
  142. package/bin/lattices-app.js +0 -221
  143. package/bin/lattices.js +0 -1418
@@ -1,1755 +0,0 @@
1
- import AppKit
2
- import CoreGraphics
3
-
4
- // Private API: get CGWindowID from an AXUIElement
5
- @_silgen_name("_AXUIElementGetWindow")
6
- func _AXUIElementGetWindow(_ element: AXUIElement, _ windowID: UnsafeMutablePointer<CGWindowID>) -> AXError
7
-
8
- // MARK: - SkyLight Private APIs (instant window moves, no animation)
9
- // Loaded at runtime via dlsym — graceful fallback if unavailable.
10
-
11
- private let skyLight: UnsafeMutableRawPointer? = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_NOW)
12
-
13
- private typealias SLSMainConnectionIDFunc = @convention(c) () -> Int32
14
- private typealias SLSMoveWindowFunc = @convention(c) (Int32, UInt32, UnsafeMutablePointer<CGPoint>) -> CGError
15
-
16
- private let _SLSMainConnectionID: SLSMainConnectionIDFunc? = {
17
- guard let sl = skyLight, let sym = dlsym(sl, "SLSMainConnectionID") else { return nil }
18
- return unsafeBitCast(sym, to: SLSMainConnectionIDFunc.self)
19
- }()
20
-
21
- private let _SLSMoveWindow: SLSMoveWindowFunc? = {
22
- guard let sl = skyLight, let sym = dlsym(sl, "SLSMoveWindow") else { return nil }
23
- return unsafeBitCast(sym, to: SLSMoveWindowFunc.self)
24
- }()
25
-
26
- // MARK: - Window Highlight Overlay
27
-
28
- final class WindowHighlight {
29
- static let shared = WindowHighlight()
30
-
31
- private var overlayWindow: NSWindow?
32
- private var fadeTimer: Timer?
33
-
34
- /// Flash a green border overlay at the given screen frame
35
- func flash(frame: NSRect, duration: TimeInterval = 1.2) {
36
- dismiss()
37
-
38
- let inset: CGFloat = -8 // slightly larger than the window
39
- let expandedFrame = frame.insetBy(dx: inset, dy: inset)
40
-
41
- let window = NSWindow(
42
- contentRect: expandedFrame,
43
- styleMask: .borderless,
44
- backing: .buffered,
45
- defer: false
46
- )
47
- window.isOpaque = false
48
- window.backgroundColor = .clear
49
- window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
50
- window.hasShadow = false
51
- window.ignoresMouseEvents = true
52
- window.collectionBehavior = [.canJoinAllSpaces, .stationary]
53
-
54
- let borderView = HighlightBorderView(frame: NSRect(origin: .zero, size: expandedFrame.size))
55
- window.contentView = borderView
56
-
57
- window.alphaValue = 0
58
- window.orderFrontRegardless()
59
-
60
- overlayWindow = window
61
-
62
- // Fade in
63
- NSAnimationContext.runAnimationGroup { ctx in
64
- ctx.duration = 0.15
65
- window.animator().alphaValue = 1.0
66
- }
67
-
68
- // Schedule fade out
69
- fadeTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
70
- self?.fadeOut()
71
- }
72
- }
73
-
74
- func dismiss() {
75
- fadeTimer?.invalidate()
76
- fadeTimer = nil
77
- overlayWindow?.orderOut(nil)
78
- overlayWindow = nil
79
- }
80
-
81
- private func fadeOut() {
82
- guard let window = overlayWindow else { return }
83
- NSAnimationContext.runAnimationGroup({ ctx in
84
- ctx.duration = 0.3
85
- window.animator().alphaValue = 0
86
- }, completionHandler: { [weak self] in
87
- self?.dismiss()
88
- })
89
- }
90
- }
91
-
92
- private class HighlightBorderView: NSView {
93
- override func draw(_ dirtyRect: NSRect) {
94
- let borderWidth: CGFloat = 4
95
- let cornerRadius: CGFloat = 12
96
-
97
- // Outer glow
98
- let glowRect = bounds.insetBy(dx: 1, dy: 1)
99
- let glowPath = NSBezierPath(roundedRect: glowRect, xRadius: cornerRadius + 2, yRadius: cornerRadius + 2)
100
- glowPath.lineWidth = borderWidth + 4
101
- NSColor(calibratedRed: 0.2, green: 0.9, blue: 0.4, alpha: 0.15).setStroke()
102
- glowPath.stroke()
103
-
104
- // Main border
105
- let rect = bounds.insetBy(dx: borderWidth / 2 + 2, dy: borderWidth / 2 + 2)
106
- let path = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius)
107
- path.lineWidth = borderWidth
108
- NSColor(calibratedRed: 0.2, green: 0.9, blue: 0.4, alpha: 0.9).setStroke()
109
- path.stroke()
110
- }
111
- }
112
-
113
- enum TilePosition: String, CaseIterable, Identifiable {
114
- case left = "left"
115
- case right = "right"
116
- case top = "top"
117
- case bottom = "bottom"
118
- case topLeft = "top-left"
119
- case topRight = "top-right"
120
- case bottomLeft = "bottom-left"
121
- case bottomRight = "bottom-right"
122
- case maximize = "maximize"
123
- case center = "center"
124
- case leftThird = "left-third"
125
- case centerThird = "center-third"
126
- case rightThird = "right-third"
127
-
128
- var id: String { rawValue }
129
-
130
- var label: String {
131
- switch self {
132
- case .left: return "Left"
133
- case .right: return "Right"
134
- case .top: return "Top"
135
- case .bottom: return "Bottom"
136
- case .topLeft: return "Top Left"
137
- case .topRight: return "Top Right"
138
- case .bottomLeft: return "Bottom Left"
139
- case .bottomRight: return "Bottom Right"
140
- case .maximize: return "Max"
141
- case .center: return "Center"
142
- case .leftThird: return "Left Third"
143
- case .centerThird: return "Center Third"
144
- case .rightThird: return "Right Third"
145
- }
146
- }
147
-
148
- var icon: String {
149
- switch self {
150
- case .left: return "rectangle.lefthalf.filled"
151
- case .right: return "rectangle.righthalf.filled"
152
- case .top: return "rectangle.tophalf.filled"
153
- case .bottom: return "rectangle.bottomhalf.filled"
154
- case .topLeft: return "rectangle.inset.topleft.filled"
155
- case .topRight: return "rectangle.inset.topright.filled"
156
- case .bottomLeft: return "rectangle.inset.bottomleft.filled"
157
- case .bottomRight: return "rectangle.inset.bottomright.filled"
158
- case .maximize: return "rectangle.fill"
159
- case .center: return "rectangle.center.inset.filled"
160
- case .leftThird: return "rectangle.leadingthird.inset.filled"
161
- case .centerThird: return "rectangle.center.inset.filled"
162
- case .rightThird: return "rectangle.trailingthird.inset.filled"
163
- }
164
- }
165
-
166
- /// Returns (x, y, w, h) as fractions of screen
167
- var rect: (CGFloat, CGFloat, CGFloat, CGFloat) {
168
- switch self {
169
- case .left: return (0, 0, 0.5, 1.0)
170
- case .right: return (0.5, 0, 0.5, 1.0)
171
- case .top: return (0, 0, 1.0, 0.5)
172
- case .bottom: return (0, 0.5, 1.0, 0.5)
173
- case .topLeft: return (0, 0, 0.5, 0.5)
174
- case .topRight: return (0.5, 0, 0.5, 0.5)
175
- case .bottomLeft: return (0, 0.5, 0.5, 0.5)
176
- case .bottomRight: return (0.5, 0.5, 0.5, 0.5)
177
- case .maximize: return (0, 0, 1.0, 1.0)
178
- case .center: return (0.15, 0.1, 0.7, 0.8)
179
- case .leftThird: return (0, 0, 0.333, 1.0)
180
- case .centerThird: return (0.333, 0, 0.334, 1.0)
181
- case .rightThird: return (0.667, 0, 0.333, 1.0)
182
- }
183
- }
184
- }
185
-
186
- // MARK: - Private CGS API for Spaces (loaded dynamically from SkyLight)
187
-
188
- struct SpaceInfo: Identifiable {
189
- let id: Int // CGS space ID
190
- let index: Int // 1-based index within its display
191
- let display: Int // 0-based display index
192
- let isCurrent: Bool
193
- }
194
-
195
- struct DisplaySpaces {
196
- let displayIndex: Int
197
- let displayId: String
198
- let spaces: [SpaceInfo]
199
- let currentSpaceId: Int
200
- }
201
-
202
- private enum CGS {
203
- // Use Int32 for CGS connection IDs (C `int`), UInt64 for space IDs
204
- typealias MainConnectionIDFunc = @convention(c) () -> Int32
205
- typealias GetActiveSpaceFunc = @convention(c) (Int32) -> UInt64
206
- typealias CopyManagedDisplaySpacesFunc = @convention(c) (Int32) -> CFArray
207
- typealias CopySpacesForWindowsFunc = @convention(c) (Int32, Int32, CFArray) -> CFArray
208
- typealias SetCurrentSpaceFunc = @convention(c) (Int32, CFString, UInt64) -> Void
209
-
210
- private static let handle = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY)
211
-
212
- static let mainConnectionID: MainConnectionIDFunc? = {
213
- guard let h = handle, let sym = dlsym(h, "CGSMainConnectionID") else { return nil }
214
- return unsafeBitCast(sym, to: MainConnectionIDFunc.self)
215
- }()
216
-
217
- static let getActiveSpace: GetActiveSpaceFunc? = {
218
- guard let h = handle, let sym = dlsym(h, "CGSGetActiveSpace") else { return nil }
219
- return unsafeBitCast(sym, to: GetActiveSpaceFunc.self)
220
- }()
221
-
222
- static let copyManagedDisplaySpaces: CopyManagedDisplaySpacesFunc? = {
223
- guard let h = handle, let sym = dlsym(h, "CGSCopyManagedDisplaySpaces") else { return nil }
224
- return unsafeBitCast(sym, to: CopyManagedDisplaySpacesFunc.self)
225
- }()
226
-
227
- static let copySpacesForWindows: CopySpacesForWindowsFunc? = {
228
- guard let h = handle, let sym = dlsym(h, "SLSCopySpacesForWindows") else { return nil }
229
- return unsafeBitCast(sym, to: CopySpacesForWindowsFunc.self)
230
- }()
231
-
232
- static let setCurrentSpace: SetCurrentSpaceFunc? = {
233
- guard let h = handle, let sym = dlsym(h, "SLSManagedDisplaySetCurrentSpace") else { return nil }
234
- return unsafeBitCast(sym, to: SetCurrentSpaceFunc.self)
235
- }()
236
-
237
- // Move windows between spaces
238
- typealias AddWindowsToSpacesFunc = @convention(c) (Int32, CFArray, CFArray) -> Void
239
- typealias RemoveWindowsFromSpacesFunc = @convention(c) (Int32, CFArray, CFArray) -> Void
240
-
241
- static let addWindowsToSpaces: AddWindowsToSpacesFunc? = {
242
- guard let h = handle else { return nil }
243
- guard let sym = dlsym(h, "CGSAddWindowsToSpaces") ?? dlsym(h, "SLSAddWindowsToSpaces") else { return nil }
244
- return unsafeBitCast(sym, to: AddWindowsToSpacesFunc.self)
245
- }()
246
-
247
- static let removeWindowsFromSpaces: RemoveWindowsFromSpacesFunc? = {
248
- guard let h = handle else { return nil }
249
- guard let sym = dlsym(h, "CGSRemoveWindowsFromSpaces") ?? dlsym(h, "SLSRemoveWindowsFromSpaces") else { return nil }
250
- return unsafeBitCast(sym, to: RemoveWindowsFromSpacesFunc.self)
251
- }()
252
- }
253
-
254
- enum WindowTiler {
255
- /// Whether CGS move-between-spaces APIs are available
256
- static var canMoveWindowsBetweenSpaces: Bool {
257
- CGS.addWindowsToSpaces != nil && CGS.removeWindowsFromSpaces != nil
258
- }
259
-
260
- /// Convert fractional rect to AppleScript bounds {left, top, right, bottom}
261
- /// AppleScript uses top-left origin; NSScreen uses bottom-left origin
262
- private static func appleScriptBounds(for position: TilePosition, screen: NSScreen? = nil) -> (Int, Int, Int, Int) {
263
- let targetScreen = screen ?? NSScreen.main
264
- guard let targetScreen else { return (0, 0, 960, 540) }
265
- let full = targetScreen.frame
266
- let visible = targetScreen.visibleFrame
267
-
268
- let visTop = Int(full.height - visible.maxY)
269
- let visLeft = Int(visible.minX)
270
- let visW = Int(visible.width)
271
- let visH = Int(visible.height)
272
-
273
- let (fx, fy, fw, fh) = position.rect
274
- let x1 = visLeft + Int(CGFloat(visW) * fx)
275
- let y1 = visTop + Int(CGFloat(visH) * fy)
276
- let x2 = x1 + Int(CGFloat(visW) * fw)
277
- let y2 = y1 + Int(CGFloat(visH) * fh)
278
- return (x1, y1, x2, y2)
279
- }
280
-
281
- /// Compute AX-coordinate frame for a tile position on a given screen
282
- static func tileFrame(for position: TilePosition, on screen: NSScreen) -> CGRect {
283
- let visible = screen.visibleFrame
284
- guard let primary = NSScreen.screens.first else { return .zero }
285
- let primaryH = primary.frame.height
286
- let axTop = primaryH - visible.maxY
287
- let (fx, fy, fw, fh) = position.rect
288
- return CGRect(
289
- x: visible.origin.x + visible.width * fx,
290
- y: axTop + visible.height * fy,
291
- width: visible.width * fw,
292
- height: visible.height * fh
293
- )
294
- }
295
-
296
- /// Compute AX-coordinate frame for a tile position within a raw display CGRect (CG/AX coords)
297
- static func tileFrame(for position: TilePosition, inDisplay displayRect: CGRect) -> CGRect {
298
- let (fx, fy, fw, fh) = position.rect
299
- return CGRect(
300
- x: displayRect.origin.x + displayRect.width * fx,
301
- y: displayRect.origin.y + displayRect.height * fy,
302
- width: displayRect.width * fw,
303
- height: displayRect.height * fh
304
- )
305
- }
306
-
307
- /// Compute AX-coordinate frame from fractional (x, y, w, h) within a raw display CGRect
308
- static func tileFrame(fractions: (CGFloat, CGFloat, CGFloat, CGFloat), inDisplay displayRect: CGRect) -> CGRect {
309
- let (fx, fy, fw, fh) = fractions
310
- return CGRect(
311
- x: displayRect.origin.x + displayRect.width * fx,
312
- y: displayRect.origin.y + displayRect.height * fy,
313
- width: displayRect.width * fw,
314
- height: displayRect.height * fh
315
- )
316
- }
317
-
318
- /// Tile a specific terminal window on a given screen.
319
- /// Fast path: DesktopModel → AX. Fallback: AX search → AppleScript last resort.
320
- static func tile(session: String, terminal: Terminal, to position: TilePosition, on screen: NSScreen) {
321
- let diag = DiagnosticLog.shared
322
- let t = diag.startTimed("tile: \(session) → \(position.rawValue)")
323
-
324
- // Fast path: use DesktopModel cache → single AX move
325
- if let entry = DesktopModel.shared.windowForSession(session) {
326
- let frame = tileFrame(for: position, on: screen)
327
- batchMoveAndRaiseWindows([(wid: entry.wid, pid: entry.pid, frame: frame)])
328
- diag.success("tile fast path (DesktopModel): \(session)")
329
- diag.finish(t)
330
- return
331
- }
332
-
333
- // AX fallback: search terminal windows by title tag
334
- let tag = Terminal.windowTag(for: session)
335
- if let (pid, axWindow) = findWindowViaAX(terminal: terminal, tag: tag) {
336
- let targetFrame = tileFrame(for: position, on: screen)
337
- var newPos = CGPoint(x: targetFrame.origin.x, y: targetFrame.origin.y)
338
- var newSize = CGSize(width: targetFrame.width, height: targetFrame.height)
339
- let win = axWindow
340
- if let sv = AXValueCreate(.cgSize, &newSize) {
341
- AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
342
- }
343
- if let pv = AXValueCreate(.cgPoint, &newPos) {
344
- AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
345
- }
346
- if let sv = AXValueCreate(.cgSize, &newSize) {
347
- AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
348
- }
349
- if let pv = AXValueCreate(.cgPoint, &newPos) {
350
- AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
351
- }
352
- AXUIElementPerformAction(win, kAXRaiseAction as CFString)
353
- if let app = NSRunningApplication(processIdentifier: pid) { app.activate() }
354
- diag.success("tile AX fallback: \(session)")
355
- diag.finish(t)
356
- return
357
- }
358
-
359
- // AppleScript last resort (slow, single-monitor)
360
- diag.warn("tile AppleScript last resort: \(session)")
361
- let bounds = appleScriptBounds(for: position, screen: screen)
362
- switch terminal {
363
- case .terminal:
364
- tileAppleScript(app: "Terminal", tag: tag, bounds: bounds)
365
- case .iterm2:
366
- tileAppleScript(app: "iTerm2", tag: tag, bounds: bounds)
367
- default:
368
- tileFrontmost(bounds: bounds)
369
- }
370
- diag.finish(t)
371
- }
372
-
373
- /// Tile a specific terminal window (found by lattices session tag) to a position.
374
- /// Uses the same fast path strategy as tile(session:terminal:to:on:) with main screen.
375
- static func tile(session: String, terminal: Terminal, to position: TilePosition) {
376
- let screen = NSScreen.main ?? NSScreen.screens[0]
377
- tile(session: session, terminal: terminal, to: position, on: screen)
378
- }
379
-
380
- /// Tile the frontmost window (works for any terminal)
381
- static func tileFrontmost(to position: TilePosition) {
382
- tileFrontmost(bounds: appleScriptBounds(for: position))
383
- }
384
-
385
- // MARK: - Spaces
386
-
387
- /// Get spaces organized by display
388
- static func getDisplaySpaces() -> [DisplaySpaces] {
389
- guard let mainConn = CGS.mainConnectionID,
390
- let copyManaged = CGS.copyManagedDisplaySpaces else { return [] }
391
-
392
- let cid = mainConn()
393
- guard let managed = copyManaged(cid) as? [[String: Any]] else { return [] }
394
-
395
- var result: [DisplaySpaces] = []
396
- for (displayIdx, display) in managed.enumerated() {
397
- let displayId = display["Display Identifier"] as? String ?? ""
398
- let rawSpaces = display["Spaces"] as? [[String: Any]] ?? []
399
- let currentDict = display["Current Space"] as? [String: Any]
400
- let currentId = currentDict?["id64"] as? Int ?? currentDict?["ManagedSpaceID"] as? Int ?? 0
401
-
402
- var spaces: [SpaceInfo] = []
403
- for (spaceIdx, space) in rawSpaces.enumerated() {
404
- let sid = space["id64"] as? Int ?? space["ManagedSpaceID"] as? Int ?? 0
405
- let type = space["type"] as? Int ?? 0
406
- if type == 0 {
407
- spaces.append(SpaceInfo(
408
- id: sid,
409
- index: spaceIdx + 1,
410
- display: displayIdx,
411
- isCurrent: sid == currentId
412
- ))
413
- }
414
- }
415
-
416
- result.append(DisplaySpaces(
417
- displayIndex: displayIdx,
418
- displayId: displayId,
419
- spaces: spaces,
420
- currentSpaceId: currentId
421
- ))
422
- }
423
- return result
424
- }
425
-
426
- /// Get the current active Space ID
427
- static func getCurrentSpace() -> Int {
428
- guard let mainConn = CGS.mainConnectionID, let getActive = CGS.getActiveSpace else { return 0 }
429
- return Int(getActive(mainConn()))
430
- }
431
-
432
- /// Find a window by its title tag and return its CGWindowID and owner PID
433
- static func findWindow(tag: String) -> (wid: UInt32, pid: pid_t)? {
434
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
435
- return nil
436
- }
437
- for info in windowList {
438
- if let name = info[kCGWindowName as String] as? String,
439
- name.contains(tag),
440
- let wid = info[kCGWindowNumber as String] as? UInt32,
441
- let pid = info[kCGWindowOwnerPID as String] as? pid_t {
442
- return (wid, pid)
443
- }
444
- }
445
- return nil
446
- }
447
-
448
- /// Get the space ID(s) a window is on
449
- static func getSpacesForWindow(_ wid: UInt32) -> [Int] {
450
- guard let mainConn = CGS.mainConnectionID,
451
- let copySpaces = CGS.copySpacesForWindows else { return [] }
452
- let cid = mainConn()
453
- let arr = [NSNumber(value: wid)] as CFArray
454
- guard let result = copySpaces(cid, 0x7, arr) as? [NSNumber] else { return [] }
455
- return result.map { $0.intValue }
456
- }
457
-
458
- /// Switch a display to a specific Space
459
- static func switchToSpace(spaceId: Int) {
460
- guard let mainConn = CGS.mainConnectionID,
461
- let setSpace = CGS.setCurrentSpace else { return }
462
-
463
- let cid = mainConn()
464
-
465
- // Find which display this space belongs to
466
- let allDisplays = getDisplaySpaces()
467
- for display in allDisplays {
468
- if display.spaces.contains(where: { $0.id == spaceId }) {
469
- setSpace(cid, display.displayId as CFString, UInt64(spaceId))
470
- return
471
- }
472
- }
473
- }
474
-
475
- // MARK: - Move Window Between Spaces
476
-
477
- enum MoveResult {
478
- case success(method: String)
479
- case alreadyOnSpace
480
- case windowNotFound
481
- case failed(reason: String)
482
- }
483
-
484
- /// Move a session's terminal window to a different Space.
485
- /// Note: On macOS 14.5+ the CGS move APIs are silently denied.
486
- /// When that happens we fall back to just switching the user's view.
487
- static func moveWindowToSpace(session: String, terminal: Terminal, spaceId: Int) -> MoveResult {
488
- let diag = DiagnosticLog.shared
489
- let tag = Terminal.windowTag(for: session)
490
- diag.info("moveWindowToSpace: session=\(session) tag=\(tag) targetSpace=\(spaceId)")
491
-
492
- // Find the window — CG first, then AX→CG fallback
493
- let wid: UInt32
494
- if let (w, _) = findWindow(tag: tag) {
495
- wid = w
496
- diag.info("moveWindowToSpace: found via CG wid=\(w)")
497
- } else if let (pid, axWindow) = findWindowViaAX(terminal: terminal, tag: tag),
498
- let w = matchCGWindow(pid: pid, axWindow: axWindow) {
499
- wid = w
500
- diag.info("moveWindowToSpace: found via AX→CG wid=\(w)")
501
- } else {
502
- diag.warn("moveWindowToSpace: window not found for tag \(tag) — switching view only")
503
- switchToSpace(spaceId: spaceId)
504
- return .windowNotFound
505
- }
506
-
507
- // Check current spaces
508
- let currentSpaces = getSpacesForWindow(wid)
509
- diag.info("moveWindowToSpace: wid=\(wid) currentSpaces=\(currentSpaces)")
510
- if currentSpaces.contains(spaceId) {
511
- diag.info("moveWindowToSpace: already on target space — switching view")
512
- switchToSpace(spaceId: spaceId)
513
- return .alreadyOnSpace
514
- }
515
-
516
- // Try CGS direct move (works on older macOS, silently denied on 14.5+)
517
- if let result = moveViaCGS(wid: wid, fromSpaces: currentSpaces, toSpace: spaceId) {
518
- return result
519
- }
520
-
521
- // CGS unavailable — just switch the user's view
522
- diag.info("moveWindowToSpace: CGS unavailable, switching view to space")
523
- switchToSpace(spaceId: spaceId)
524
- return .success(method: "switch-view")
525
- }
526
-
527
- /// Attempt CGS-based window move. Returns nil if APIs are unavailable.
528
- private static func moveViaCGS(wid: UInt32, fromSpaces: [Int], toSpace: Int) -> MoveResult? {
529
- let diag = DiagnosticLog.shared
530
- guard let mainConn = CGS.mainConnectionID,
531
- let addToSpaces = CGS.addWindowsToSpaces,
532
- let removeFromSpaces = CGS.removeWindowsFromSpaces else {
533
- return nil
534
- }
535
-
536
- let cid = mainConn()
537
- let windowArray = [NSNumber(value: wid)] as CFArray
538
- let targetArray = [NSNumber(value: toSpace)] as CFArray
539
-
540
- addToSpaces(cid, windowArray, targetArray)
541
- if !fromSpaces.isEmpty {
542
- let sourceArray = fromSpaces.map { NSNumber(value: $0) } as CFArray
543
- removeFromSpaces(cid, windowArray, sourceArray)
544
- }
545
-
546
- // Verify the move took effect (macOS 14.5+ silently denies)
547
- let newSpaces = getSpacesForWindow(wid)
548
- if newSpaces.contains(toSpace) && !fromSpaces.allSatisfy({ newSpaces.contains($0) }) {
549
- diag.success("moveViaCGS: successfully moved wid=\(wid) to space \(toSpace)")
550
- return .success(method: "CGS")
551
- }
552
-
553
- // CGS was silently denied — switch the view instead
554
- diag.warn("moveViaCGS: silently denied (macOS 14.5+ restriction) — switching view")
555
- switchToSpace(spaceId: toSpace)
556
- return .success(method: "switch-view")
557
- }
558
-
559
- /// Navigate to a session's window: switch to its Space, raise it, highlight it
560
- /// Falls back through CG → AX → AppleScript depending on available permissions
561
- static func navigateToWindow(session: String, terminal: Terminal) {
562
- let diag = DiagnosticLog.shared
563
- let t = diag.startTimed("navigateToWindow: \(session)")
564
- let tag = Terminal.windowTag(for: session)
565
-
566
- // Path 1: CG window lookup (needs Screen Recording permission for window names)
567
- if let (wid, pid) = findWindow(tag: tag) {
568
- diag.success("Path 1 (CG): found wid=\(wid) pid=\(pid)")
569
- navigateToKnownWindow(wid: wid, pid: pid, tag: tag, session: session, terminal: terminal)
570
- diag.finish(t)
571
- return
572
- }
573
- diag.warn("Path 1 (CG): findWindow failed — no Screen Recording?")
574
-
575
- // Path 2: AX API fallback (needs Accessibility permission)
576
- if let (pid, axWindow) = findWindowViaAX(terminal: terminal, tag: tag) {
577
- diag.success("Path 2 (AX): found window for \(terminal.rawValue) pid=\(pid)")
578
- // Try to match AX window → CG window for space switching
579
- if let wid = matchCGWindow(pid: pid, axWindow: axWindow) {
580
- diag.success("Path 2 (AX→CG): matched CG wid=\(wid)")
581
- navigateToKnownWindow(wid: wid, pid: pid, tag: tag, session: session, terminal: terminal)
582
- } else {
583
- diag.warn("Path 2 (AX): no CG match — raising without space switch")
584
- AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
585
- AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
586
- if let app = NSRunningApplication(processIdentifier: pid) {
587
- app.activate()
588
- }
589
- if let frame = axWindowFrame(axWindow) {
590
- diag.info("Highlighting via AX frame: \(frame)")
591
- DispatchQueue.main.async { WindowHighlight.shared.flash(frame: frame) }
592
- } else {
593
- diag.error("axWindowFrame returned nil — no highlight")
594
- }
595
- }
596
- diag.finish(t)
597
- return
598
- }
599
- diag.warn("Path 2 (AX): findWindowViaAX failed — no Accessibility?")
600
-
601
- // Path 3: AppleScript / bare activate fallback
602
- diag.warn("Path 3: falling back to AppleScript/activate")
603
- activateViaAppleScript(session: session, tag: tag, terminal: terminal)
604
- diag.finish(t)
605
- }
606
-
607
- private static func navigateToKnownWindow(wid: UInt32, pid: pid_t, tag: String, session: String, terminal: Terminal) {
608
- let diag = DiagnosticLog.shared
609
- let windowSpaces = getSpacesForWindow(wid)
610
- let currentSpace = getCurrentSpace()
611
- diag.info("navigateToKnown: wid=\(wid) spaces=\(windowSpaces) current=\(currentSpace)")
612
-
613
- if let windowSpace = windowSpaces.first, windowSpace != currentSpace {
614
- diag.info("Switching from space \(currentSpace) → \(windowSpace)")
615
- switchToSpace(spaceId: windowSpace)
616
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
617
- raiseWindow(pid: pid, tag: tag, terminal: terminal)
618
- highlightWindow(session: session)
619
- }
620
- } else {
621
- diag.info("Window on current space — raising + highlighting")
622
- raiseWindow(pid: pid, tag: tag, terminal: terminal)
623
- highlightWindow(session: session)
624
- }
625
- }
626
-
627
- /// Find a terminal window by title tag using AX API (requires Accessibility permission)
628
- private static func findWindowViaAX(terminal: Terminal, tag: String) -> (pid: pid_t, window: AXUIElement)? {
629
- let diag = DiagnosticLog.shared
630
- guard let app = NSWorkspace.shared.runningApplications.first(where: {
631
- $0.bundleIdentifier == terminal.bundleId
632
- }) else {
633
- diag.error("findWindowViaAX: \(terminal.rawValue) (\(terminal.bundleId)) not running")
634
- return nil
635
- }
636
-
637
- let pid = app.processIdentifier
638
- let appRef = AXUIElementCreateApplication(pid)
639
- var windowsRef: CFTypeRef?
640
- let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
641
- guard err == .success, let windows = windowsRef as? [AXUIElement] else {
642
- diag.error("findWindowViaAX: AX error \(err.rawValue) — Accessibility not granted?")
643
- return nil
644
- }
645
-
646
- diag.info("findWindowViaAX: \(windows.count) windows for \(terminal.rawValue), searching for \(tag)")
647
- for win in windows {
648
- var titleRef: CFTypeRef?
649
- AXUIElementCopyAttributeValue(win, kAXTitleAttribute as CFString, &titleRef)
650
- let title = titleRef as? String ?? "<no title>"
651
- if title.contains(tag) {
652
- diag.success("findWindowViaAX: matched \"\(title)\"")
653
- return (pid, win)
654
- } else {
655
- diag.info(" skip: \"\(title)\"")
656
- }
657
- }
658
- diag.warn("findWindowViaAX: no window matched tag \(tag)")
659
- return nil
660
- }
661
-
662
- /// Match an AX window to its CG window ID using PID + bounds comparison
663
- private static func matchCGWindow(pid: pid_t, axWindow: AXUIElement) -> UInt32? {
664
- var posRef: CFTypeRef?
665
- var sizeRef: CFTypeRef?
666
- AXUIElementCopyAttributeValue(axWindow, kAXPositionAttribute as CFString, &posRef)
667
- AXUIElementCopyAttributeValue(axWindow, kAXSizeAttribute as CFString, &sizeRef)
668
- guard let pv = posRef, let sv = sizeRef else { return nil }
669
-
670
- var pos = CGPoint.zero
671
- var size = CGSize.zero
672
- AXValueGetValue(pv as! AXValue, .cgPoint, &pos)
673
- AXValueGetValue(sv as! AXValue, .cgSize, &size)
674
-
675
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return nil }
676
-
677
- for info in windowList {
678
- guard let wPid = info[kCGWindowOwnerPID as String] as? pid_t,
679
- wPid == pid,
680
- let wid = info[kCGWindowNumber as String] as? UInt32,
681
- let boundsDict = info[kCGWindowBounds as String] as? NSDictionary else { continue }
682
- var rect = CGRect.zero
683
- if CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) {
684
- if abs(rect.origin.x - pos.x) < 2 && abs(rect.origin.y - pos.y) < 2 &&
685
- abs(rect.width - size.width) < 2 && abs(rect.height - size.height) < 2 {
686
- return wid
687
- }
688
- }
689
- }
690
- return nil
691
- }
692
-
693
- /// Get NSRect from an AX window element (AX uses top-left origin, convert to NS bottom-left)
694
- private static func axWindowFrame(_ window: AXUIElement) -> NSRect? {
695
- var posRef: CFTypeRef?
696
- var sizeRef: CFTypeRef?
697
- AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &posRef)
698
- AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeRef)
699
- guard let pv = posRef, let sv = sizeRef else { return nil }
700
-
701
- var pos = CGPoint.zero
702
- var size = CGSize.zero
703
- AXValueGetValue(pv as! AXValue, .cgPoint, &pos)
704
- AXValueGetValue(sv as! AXValue, .cgSize, &size)
705
-
706
- guard let primaryScreen = NSScreen.screens.first else { return nil }
707
- let primaryHeight = primaryScreen.frame.height
708
- return NSRect(x: pos.x, y: primaryHeight - pos.y - size.height, width: size.width, height: size.height)
709
- }
710
-
711
- /// Last-resort: use AppleScript for Terminal/iTerm2, or bare activate for others
712
- private static func activateViaAppleScript(session: String, tag: String, terminal: Terminal) {
713
- switch terminal {
714
- case .terminal:
715
- runScript("""
716
- tell application "Terminal"
717
- activate
718
- repeat with w in windows
719
- if name of w contains "\(tag)" then
720
- set index of w to 1
721
- exit repeat
722
- end if
723
- end repeat
724
- end tell
725
- """)
726
- case .iterm2:
727
- runScript("""
728
- tell application "iTerm2"
729
- activate
730
- repeat with w in windows
731
- if name of w contains "\(tag)" then
732
- select w
733
- exit repeat
734
- end if
735
- end repeat
736
- end tell
737
- """)
738
- default:
739
- if let app = NSWorkspace.shared.runningApplications.first(where: {
740
- $0.bundleIdentifier == terminal.bundleId
741
- }) {
742
- app.activate()
743
- }
744
- }
745
- }
746
-
747
- /// Raise a specific window using AX API + AppleScript
748
- private static func raiseWindow(pid: pid_t, tag: String, terminal: Terminal) {
749
- let diag = DiagnosticLog.shared
750
- let appRef = AXUIElementCreateApplication(pid)
751
- var windowsRef: CFTypeRef?
752
- let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
753
- var raised = false
754
- if err == .success, let windows = windowsRef as? [AXUIElement] {
755
- for win in windows {
756
- var titleRef: CFTypeRef?
757
- AXUIElementCopyAttributeValue(win, kAXTitleAttribute as CFString, &titleRef)
758
- if let title = titleRef as? String, title.contains(tag) {
759
- AXUIElementPerformAction(win, kAXRaiseAction as CFString)
760
- AXUIElementSetAttributeValue(win, kAXMainAttribute as CFString, kCFBooleanTrue)
761
- diag.success("raiseWindow: raised \"\(title)\"")
762
- raised = true
763
- break
764
- }
765
- }
766
- }
767
- if !raised {
768
- diag.warn("raiseWindow: could not find window with tag \(tag) via AX (err=\(err.rawValue))")
769
- }
770
-
771
- if let app = NSRunningApplication(processIdentifier: pid) {
772
- app.activate()
773
- diag.info("raiseWindow: activated \(app.localizedName ?? "pid:\(pid)")")
774
- }
775
- }
776
-
777
- // MARK: - Highlight
778
-
779
- /// Flash a highlight border around a session's terminal window
780
- static func highlightWindow(session: String) {
781
- let diag = DiagnosticLog.shared
782
- let tag = Terminal.windowTag(for: session)
783
- diag.info("highlightWindow: tag=\(tag)")
784
-
785
- // Path 1: CG approach (needs Screen Recording)
786
- if let (wid, _) = findWindow(tag: tag) {
787
- diag.info("highlight via CG: wid=\(wid)")
788
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return }
789
- for info in windowList {
790
- if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
791
- let dict = info[kCGWindowBounds as String] as? NSDictionary {
792
- var rect = CGRect.zero
793
- if CGRectMakeWithDictionaryRepresentation(dict, &rect) {
794
- guard let primaryScreen = NSScreen.screens.first else { return }
795
- let primaryHeight = primaryScreen.frame.height
796
- let nsRect = NSRect(
797
- x: rect.origin.x,
798
- y: primaryHeight - rect.origin.y - rect.height,
799
- width: rect.width,
800
- height: rect.height
801
- )
802
- diag.success("highlight CG flash at \(Int(nsRect.origin.x)),\(Int(nsRect.origin.y)) \(Int(nsRect.width))×\(Int(nsRect.height))")
803
- DispatchQueue.main.async { WindowHighlight.shared.flash(frame: nsRect) }
804
- }
805
- return
806
- }
807
- }
808
- diag.warn("highlight CG: wid \(wid) not in window list")
809
- return
810
- }
811
-
812
- // Path 2: AX fallback — search installed terminals for the tagged window
813
- diag.info("highlight: CG failed, trying AX fallback across \(Terminal.installed.count) terminals")
814
- for terminal in Terminal.installed {
815
- if let (_, axWindow) = findWindowViaAX(terminal: terminal, tag: tag),
816
- let frame = axWindowFrame(axWindow) {
817
- diag.success("highlight AX flash at \(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))×\(Int(frame.height))")
818
- DispatchQueue.main.async { WindowHighlight.shared.flash(frame: frame) }
819
- return
820
- }
821
- }
822
- diag.error("highlight: no method found window — no highlight shown")
823
- }
824
-
825
- // MARK: - Window Info
826
-
827
- struct WindowInfo {
828
- let spaceIndex: Int // 1-based space number
829
- let displayIndex: Int // 0-based display index
830
- let tilePosition: TilePosition? // inferred from bounds, nil if free-form
831
- let wid: UInt32
832
- }
833
-
834
- /// Get spatial info for a session's terminal window (space, display, tile position)
835
- static func getWindowInfo(session: String, terminal: Terminal) -> WindowInfo? {
836
- let tag = Terminal.windowTag(for: session)
837
-
838
- // Find the window
839
- let wid: UInt32
840
- if let (w, _) = findWindow(tag: tag) {
841
- wid = w
842
- } else if let (pid, axWindow) = findWindowViaAX(terminal: terminal, tag: tag),
843
- let w = matchCGWindow(pid: pid, axWindow: axWindow) {
844
- wid = w
845
- } else {
846
- return nil
847
- }
848
-
849
- // Determine which space/display the window is on
850
- let windowSpaces = getSpacesForWindow(wid)
851
- let allDisplays = getDisplaySpaces()
852
-
853
- var spaceIndex = 1
854
- var displayIndex = 0
855
-
856
- if let windowSpaceId = windowSpaces.first {
857
- for display in allDisplays {
858
- if let space = display.spaces.first(where: { $0.id == windowSpaceId }) {
859
- spaceIndex = space.index
860
- displayIndex = display.displayIndex
861
- break
862
- }
863
- }
864
- }
865
-
866
- let tile = inferTilePosition(wid: wid)
867
-
868
- return WindowInfo(
869
- spaceIndex: spaceIndex,
870
- displayIndex: displayIndex,
871
- tilePosition: tile,
872
- wid: wid
873
- )
874
- }
875
-
876
- /// Infer tile position from a window frame + screen without re-querying CGWindowList
877
- static func inferTilePosition(frame: WindowFrame, screen: NSScreen) -> TilePosition? {
878
- let visible = screen.visibleFrame
879
- let full = screen.frame
880
-
881
- // CG top-left origin → visible frame top-left origin
882
- let primaryHeight = NSScreen.screens.first?.frame.height ?? full.height
883
- let visTop = primaryHeight - visible.maxY
884
- let fx = (frame.x - visible.origin.x) / visible.width
885
- let fy = (frame.y - visTop) / visible.height
886
- let fw = frame.w / visible.width
887
- let fh = frame.h / visible.height
888
-
889
- let tolerance: CGFloat = 0.05
890
-
891
- for position in TilePosition.allCases {
892
- let (px, py, pw, ph) = position.rect
893
- if abs(fx - CGFloat(px)) < tolerance && abs(fy - CGFloat(py)) < tolerance &&
894
- abs(fw - CGFloat(pw)) < tolerance && abs(fh - CGFloat(ph)) < tolerance {
895
- return position
896
- }
897
- }
898
- return nil
899
- }
900
-
901
- /// Infer tile position from a window's current bounds relative to its screen
902
- private static func inferTilePosition(wid: UInt32) -> TilePosition? {
903
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
904
- return nil
905
- }
906
-
907
- // Find the window's bounds
908
- var windowRect = CGRect.zero
909
- for info in windowList {
910
- if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
911
- let dict = info[kCGWindowBounds as String] as? NSDictionary {
912
- CGRectMakeWithDictionaryRepresentation(dict, &windowRect)
913
- break
914
- }
915
- }
916
- guard windowRect.width > 0 else { return nil }
917
-
918
- // Find which screen contains the window center
919
- let centerX = windowRect.midX
920
- let centerY = windowRect.midY
921
- guard let primaryScreen = NSScreen.screens.first else { return nil }
922
- let primaryHeight = primaryScreen.frame.height
923
-
924
- // CG uses top-left origin; convert to NS bottom-left for screen matching
925
- let nsCenterY = primaryHeight - centerY
926
-
927
- let screen = NSScreen.screens.first(where: {
928
- $0.frame.contains(NSPoint(x: centerX, y: nsCenterY))
929
- }) ?? NSScreen.main ?? primaryScreen
930
-
931
- let visible = screen.visibleFrame
932
- let full = screen.frame
933
-
934
- // Convert CG rect to fractional coordinates relative to visible frame
935
- // CG top-left origin → visible frame top-left origin
936
- let visTop = full.height - visible.maxY + full.origin.y
937
- let fx = (windowRect.origin.x - visible.origin.x) / visible.width
938
- let fy = (windowRect.origin.y - visTop) / visible.height
939
- let fw = windowRect.width / visible.width
940
- let fh = windowRect.height / visible.height
941
-
942
- let tolerance: CGFloat = 0.05
943
-
944
- for position in TilePosition.allCases {
945
- let (px, py, pw, ph) = position.rect
946
- if abs(fx - px) < tolerance && abs(fy - py) < tolerance &&
947
- abs(fw - pw) < tolerance && abs(fh - ph) < tolerance {
948
- return position
949
- }
950
- }
951
-
952
- return nil
953
- }
954
-
955
- // MARK: - By-ID Window Operations (Desktop Inventory)
956
-
957
- /// Navigate to an arbitrary window by its CG window ID: switch space, raise, highlight
958
- static func navigateToWindowById(wid: UInt32, pid: Int32) {
959
- let diag = DiagnosticLog.shared
960
- diag.info("navigateToWindowById: wid=\(wid) pid=\(pid)")
961
-
962
- // Switch to window's space if needed
963
- let windowSpaces = getSpacesForWindow(wid)
964
- let currentSpace = getCurrentSpace()
965
-
966
- if let windowSpace = windowSpaces.first, windowSpace != currentSpace {
967
- diag.info("Switching from space \(currentSpace) → \(windowSpace)")
968
- switchToSpace(spaceId: windowSpace)
969
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
970
- raiseWindowById(wid: wid, pid: pid)
971
- highlightWindowById(wid: wid)
972
- }
973
- } else {
974
- raiseWindowById(wid: wid, pid: pid)
975
- highlightWindowById(wid: wid)
976
- }
977
- }
978
-
979
- /// Flash a highlight border on any window by its CG window ID
980
- static func highlightWindowById(wid: UInt32) {
981
- guard let frame = cgWindowFrame(wid: wid) else {
982
- DiagnosticLog.shared.warn("highlightWindowById: no frame for wid=\(wid)")
983
- return
984
- }
985
- DispatchQueue.main.async { WindowHighlight.shared.flash(frame: frame) }
986
- }
987
-
988
- /// Tile any window by its CG window ID to a position using AX API
989
- static func tileWindowById(wid: UInt32, pid: Int32, to position: TilePosition) {
990
- let diag = DiagnosticLog.shared
991
- diag.info("tileWindowById: wid=\(wid) pid=\(pid) pos=\(position.rawValue)")
992
-
993
- // Find the screen the window is on
994
- guard let windowFrame = cgWindowFrame(wid: wid) else {
995
- diag.warn("tileWindowById: no frame for wid=\(wid)")
996
- return
997
- }
998
- let screen = NSScreen.screens.first(where: {
999
- $0.frame.contains(NSPoint(x: windowFrame.midX, y: windowFrame.midY))
1000
- }) ?? NSScreen.main ?? NSScreen.screens[0]
1001
-
1002
- let visible = screen.visibleFrame
1003
- let (fx, fy, fw, fh) = position.rect
1004
-
1005
- // Calculate target in NS coordinates (bottom-left origin)
1006
- let targetX = visible.origin.x + visible.width * fx
1007
- let targetY = visible.origin.y + visible.height * (1.0 - fy - fh)
1008
- let targetW = visible.width * fw
1009
- let targetH = visible.height * fh
1010
-
1011
- // Convert NS bottom-left → AX top-left origin
1012
- guard let primaryScreen = NSScreen.screens.first else { return }
1013
- let primaryHeight = primaryScreen.frame.height
1014
- let axX = targetX
1015
- let axY = primaryHeight - targetY - targetH
1016
-
1017
- // Find the AX window matching this CG wid by frame comparison
1018
- guard let axWindow = findAXWindowByFrame(wid: wid, pid: pid) else {
1019
- diag.warn("tileWindowById: couldn't match AX window for wid=\(wid)")
1020
- return
1021
- }
1022
-
1023
- // Set position and size via AX
1024
- var newPos = CGPoint(x: axX, y: axY)
1025
- var newSize = CGSize(width: targetW, height: targetH)
1026
-
1027
- if let posValue = AXValueCreate(.cgPoint, &newPos) {
1028
- AXUIElementSetAttributeValue(axWindow, kAXPositionAttribute as CFString, posValue)
1029
- }
1030
- if let sizeValue = AXValueCreate(.cgSize, &newSize) {
1031
- AXUIElementSetAttributeValue(axWindow, kAXSizeAttribute as CFString, sizeValue)
1032
- }
1033
-
1034
- diag.success("tileWindowById: tiled wid=\(wid) to \(position.rawValue)")
1035
- }
1036
-
1037
- /// Distribute windows in a smart grid layout (delegates to batch operation)
1038
- static func tileDistributeHorizontally(windows: [(wid: UInt32, pid: Int32)]) {
1039
- batchRaiseAndDistribute(windows: windows)
1040
- }
1041
-
1042
- /// Distribute ALL visible non-Lattices windows into a smart grid on the screen with the most windows.
1043
- static func distributeVisible() {
1044
- let diag = DiagnosticLog.shared
1045
- let t = diag.startTimed("distributeVisible")
1046
-
1047
- let allEntries = DesktopModel.shared.allWindows()
1048
- let visible = allEntries.filter { entry in
1049
- entry.isOnScreen && entry.app != "Lattices" && entry.frame.w > 50 && entry.frame.h > 50
1050
- }
1051
-
1052
- guard !visible.isEmpty else {
1053
- diag.info("distributeVisible: no visible windows to distribute")
1054
- diag.finish(t)
1055
- return
1056
- }
1057
-
1058
- let windows = visible.map { (wid: $0.wid, pid: $0.pid) }
1059
- diag.info("distributeVisible: \(windows.count) windows")
1060
- batchRaiseAndDistribute(windows: windows)
1061
- diag.finish(t)
1062
- }
1063
-
1064
- /// Get NSRect (bottom-left origin) for a known CG window ID
1065
- private static func cgWindowFrame(wid: UInt32) -> NSRect? {
1066
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return nil }
1067
- for info in windowList {
1068
- if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
1069
- let dict = info[kCGWindowBounds as String] as? NSDictionary {
1070
- var rect = CGRect.zero
1071
- if CGRectMakeWithDictionaryRepresentation(dict, &rect) {
1072
- guard let primaryScreen = NSScreen.screens.first else { return nil }
1073
- let primaryHeight = primaryScreen.frame.height
1074
- return NSRect(
1075
- x: rect.origin.x,
1076
- y: primaryHeight - rect.origin.y - rect.height,
1077
- width: rect.width,
1078
- height: rect.height
1079
- )
1080
- }
1081
- }
1082
- }
1083
- return nil
1084
- }
1085
-
1086
- /// Raise a window by matching its CG window ID to an AX element via frame comparison
1087
- private static func raiseWindowById(wid: UInt32, pid: Int32) {
1088
- let diag = DiagnosticLog.shared
1089
-
1090
- if let axWindow = findAXWindowByFrame(wid: wid, pid: pid) {
1091
- AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
1092
- AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
1093
- diag.success("raiseWindowById: raised wid=\(wid)")
1094
- } else {
1095
- diag.warn("raiseWindowById: couldn't match AX window for wid=\(wid)")
1096
- }
1097
-
1098
- if let app = NSRunningApplication(processIdentifier: pid) {
1099
- app.activate()
1100
- }
1101
- }
1102
-
1103
- /// Raise multiple windows at once, re-activating our app once at the end
1104
- static func raiseWindowsAndReactivate(windows: [(wid: UInt32, pid: Int32)]) {
1105
- let diag = DiagnosticLog.shared
1106
- var activatedPids = Set<Int32>()
1107
- for win in windows {
1108
- if let axWindow = findAXWindowByFrame(wid: win.wid, pid: win.pid) {
1109
- AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
1110
- AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
1111
- }
1112
- if !activatedPids.contains(win.pid) {
1113
- if let app = NSRunningApplication(processIdentifier: win.pid) {
1114
- app.activate()
1115
- activatedPids.insert(win.pid)
1116
- }
1117
- }
1118
- }
1119
- diag.success("raiseWindowsAndReactivate: raised \(windows.count) windows")
1120
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
1121
- NSApp.activate(ignoringOtherApps: true)
1122
- }
1123
- }
1124
-
1125
- /// Raise a window to front and then re-activate our own app so the panel stays visible
1126
- static func raiseWindowAndReactivate(wid: UInt32, pid: Int32) {
1127
- let diag = DiagnosticLog.shared
1128
- diag.info("raiseWindowAndReactivate: wid=\(wid) pid=\(pid)")
1129
-
1130
- // Switch to window's space if needed
1131
- let windowSpaces = getSpacesForWindow(wid)
1132
- let currentSpace = getCurrentSpace()
1133
-
1134
- let doRaise = {
1135
- if let axWindow = findAXWindowByFrame(wid: wid, pid: pid) {
1136
- AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
1137
- AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
1138
- diag.success("raiseWindowAndReactivate: raised wid=\(wid)")
1139
- }
1140
- // Activate target app briefly so window comes to front
1141
- if let app = NSRunningApplication(processIdentifier: pid) {
1142
- app.activate()
1143
- }
1144
- // Re-activate our app so the panel stays visible
1145
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
1146
- NSApp.activate(ignoringOtherApps: true)
1147
- }
1148
- }
1149
-
1150
- if let windowSpace = windowSpaces.first, windowSpace != currentSpace {
1151
- diag.info("Switching from space \(currentSpace) → \(windowSpace)")
1152
- switchToSpace(spaceId: windowSpace)
1153
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { doRaise() }
1154
- } else {
1155
- doRaise()
1156
- }
1157
- }
1158
-
1159
- // MARK: - Batch Window Operations
1160
-
1161
- /// Move multiple windows to target frames in one shot.
1162
- /// Single CGWindowList query, single AX query per process, all moves synchronous.
1163
- static func batchMoveWindows(_ moves: [(wid: UInt32, pid: Int32, frame: CGRect)]) {
1164
- guard !moves.isEmpty else { return }
1165
- let diag = DiagnosticLog.shared
1166
-
1167
- // Group by pid so we query each app's AX windows once
1168
- var byPid: [Int32: [(wid: UInt32, target: CGRect)]] = [:]
1169
- for move in moves {
1170
- byPid[move.pid, default: []].append((wid: move.wid, target: move.frame))
1171
- }
1172
-
1173
- // For each process: get AX windows, match by CGWindowID, move+resize
1174
- var moved = 0
1175
- var failed = 0
1176
- for (pid, windowMoves) in byPid {
1177
- let appRef = AXUIElementCreateApplication(pid)
1178
- var windowsRef: CFTypeRef?
1179
- let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1180
- guard err == .success, let axWindows = windowsRef as? [AXUIElement] else {
1181
- diag.info("[batchMove] AX query failed for pid \(pid)")
1182
- failed += windowMoves.count
1183
- continue
1184
- }
1185
-
1186
- // Build wid → AXUIElement map using _AXUIElementGetWindow
1187
- var axByWid: [UInt32: AXUIElement] = [:]
1188
- for axWin in axWindows {
1189
- var windowId: CGWindowID = 0
1190
- if _AXUIElementGetWindow(axWin, &windowId) == .success {
1191
- axByWid[windowId] = axWin
1192
- }
1193
- }
1194
-
1195
- for wm in windowMoves {
1196
- guard let axWin = axByWid[wm.wid] else {
1197
- diag.info("[batchMove] no AX match for wid \(wm.wid)")
1198
- failed += 1
1199
- continue
1200
- }
1201
-
1202
- applyFrameToAXWindow(axWin, wid: wm.wid, target: wm.target)
1203
- moved += 1
1204
- }
1205
- }
1206
- if failed > 0 {
1207
- diag.info("[batchMove] \(failed) windows failed to match")
1208
- }
1209
- diag.success("batchMoveWindows: moved \(moved)/\(moves.count) windows")
1210
- }
1211
-
1212
- /// Apply position+size to a single AX window. No delays, no retries — just set and go.
1213
- private static func applyFrameToAXWindow(_ axWin: AXUIElement, wid: UInt32, target: CGRect) {
1214
- var newPos = CGPoint(x: target.origin.x, y: target.origin.y)
1215
- var newSize = CGSize(width: target.width, height: target.height)
1216
-
1217
- // Size first (avoids clipping at screen edges), then position
1218
- if let sv = AXValueCreate(.cgSize, &newSize) {
1219
- AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
1220
- }
1221
- if let pv = AXValueCreate(.cgPoint, &newPos) {
1222
- AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pv)
1223
- }
1224
- }
1225
-
1226
- /// Read back current AX position+size for a window element.
1227
- static func readAXFrame(_ axWin: AXUIElement) -> CGRect? {
1228
- var posRef: CFTypeRef?
1229
- var sizeRef: CFTypeRef?
1230
- guard AXUIElementCopyAttributeValue(axWin, kAXPositionAttribute as CFString, &posRef) == .success,
1231
- AXUIElementCopyAttributeValue(axWin, kAXSizeAttribute as CFString, &sizeRef) == .success else {
1232
- return nil
1233
- }
1234
- var pos = CGPoint.zero
1235
- var size = CGSize.zero
1236
- guard AXValueGetValue(posRef as! AXValue, .cgPoint, &pos),
1237
- AXValueGetValue(sizeRef as! AXValue, .cgSize, &size) else {
1238
- return nil
1239
- }
1240
- return CGRect(origin: pos, size: size)
1241
- }
1242
-
1243
- /// Verify which windows drifted from their targets using CGWindowList.
1244
- /// Returns array of moves that still need correction.
1245
- static func verifyMoves(_ moves: [(wid: UInt32, pid: Int32, frame: CGRect)], tolerance: CGFloat = 4) -> [(wid: UInt32, pid: Int32, frame: CGRect)] {
1246
- guard let rawList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
1247
- return moves // can't verify, return all
1248
- }
1249
-
1250
- var actualByWid: [UInt32: CGRect] = [:]
1251
- for info in rawList {
1252
- guard let wid = info[kCGWindowNumber as String] as? UInt32,
1253
- let bounds = info[kCGWindowBounds as String] as? [String: Any],
1254
- let x = bounds["X"] as? CGFloat, let y = bounds["Y"] as? CGFloat,
1255
- let w = bounds["Width"] as? CGFloat, let h = bounds["Height"] as? CGFloat else { continue }
1256
- actualByWid[wid] = CGRect(x: x, y: y, width: w, height: h)
1257
- }
1258
-
1259
- let diag = DiagnosticLog.shared
1260
- var drifted: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
1261
- for move in moves {
1262
- guard let actual = actualByWid[move.wid] else {
1263
- drifted.append(move)
1264
- continue
1265
- }
1266
- let dx = abs(actual.origin.x - move.frame.origin.x)
1267
- let dy = abs(actual.origin.y - move.frame.origin.y)
1268
- let dw = abs(actual.width - move.frame.width)
1269
- let dh = abs(actual.height - move.frame.height)
1270
- if dx > tolerance || dy > tolerance || dw > tolerance || dh > tolerance {
1271
- diag.info("[verify] wid \(move.wid) drifted: target \(move.frame) actual \(actual) (dx=\(Int(dx)) dy=\(Int(dy)) dw=\(Int(dw)) dh=\(Int(dh)))")
1272
- drifted.append(move)
1273
- }
1274
- }
1275
- return drifted
1276
- }
1277
-
1278
- /// Raise and focus a single window by its CGWindowID.
1279
- static func focusWindow(wid: UInt32, pid: Int32) {
1280
- let appRef = AXUIElementCreateApplication(pid)
1281
- var windowsRef: CFTypeRef?
1282
- guard AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef) == .success,
1283
- let axWindows = windowsRef as? [AXUIElement] else { return }
1284
-
1285
- for axWin in axWindows {
1286
- var windowId: CGWindowID = 0
1287
- if _AXUIElementGetWindow(axWin, &windowId) == .success, windowId == wid {
1288
- AXUIElementPerformAction(axWin, kAXRaiseAction as CFString)
1289
- AXUIElementSetAttributeValue(axWin, kAXMainAttribute as CFString, kCFBooleanTrue)
1290
- break
1291
- }
1292
- }
1293
-
1294
- if let app = NSRunningApplication(processIdentifier: pid) {
1295
- app.activate()
1296
- }
1297
- }
1298
-
1299
- /// Move AND raise windows in a single CG+AX pass (avoids duplicate lookups).
1300
- /// Does not reactivate lattices at the end — caller controls that.
1301
- static func batchMoveAndRaiseWindows(_ moves: [(wid: UInt32, pid: Int32, frame: CGRect)]) {
1302
- guard !moves.isEmpty else { return }
1303
- let diag = DiagnosticLog.shared
1304
-
1305
- var byPid: [Int32: [(wid: UInt32, target: CGRect)]] = [:]
1306
- for move in moves {
1307
- byPid[move.pid, default: []].append((wid: move.wid, target: move.frame))
1308
- }
1309
-
1310
- var processed = 0
1311
- var activatedPids = Set<Int32>()
1312
-
1313
- for (pid, windowMoves) in byPid {
1314
- let appRef = AXUIElementCreateApplication(pid)
1315
- var windowsRef: CFTypeRef?
1316
- let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1317
- guard err == .success, let axWindows = windowsRef as? [AXUIElement] else { continue }
1318
-
1319
- // Build wid → AXUIElement map using _AXUIElementGetWindow
1320
- var axByWid: [UInt32: AXUIElement] = [:]
1321
- for axWin in axWindows {
1322
- var windowId: CGWindowID = 0
1323
- if _AXUIElementGetWindow(axWin, &windowId) == .success {
1324
- axByWid[windowId] = axWin
1325
- }
1326
- }
1327
-
1328
- for wm in windowMoves {
1329
- guard let axWin = axByWid[wm.wid] else { continue }
1330
-
1331
- var newPos = CGPoint(x: wm.target.origin.x, y: wm.target.origin.y)
1332
- var newSize = CGSize(width: wm.target.width, height: wm.target.height)
1333
-
1334
- // Position → Size → Position (double-set avoids clipping/snapping)
1335
- if let pv = AXValueCreate(.cgPoint, &newPos) {
1336
- AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pv)
1337
- }
1338
- if let sv = AXValueCreate(.cgSize, &newSize) {
1339
- AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
1340
- }
1341
- if let pv = AXValueCreate(.cgPoint, &newPos) {
1342
- AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pv)
1343
- }
1344
-
1345
- // Raise
1346
- AXUIElementPerformAction(axWin, kAXRaiseAction as CFString)
1347
- AXUIElementSetAttributeValue(axWin, kAXMainAttribute as CFString, kCFBooleanTrue)
1348
-
1349
- processed += 1
1350
- }
1351
-
1352
- // Activate each app once so its windows come to front
1353
- if !activatedPids.contains(pid) {
1354
- if let app = NSRunningApplication(processIdentifier: pid) {
1355
- app.activate()
1356
- activatedPids.insert(pid)
1357
- }
1358
- }
1359
- }
1360
- diag.success("batchMoveAndRaiseWindows: processed \(processed)/\(moves.count) windows")
1361
- }
1362
-
1363
- // MARK: - Grid Layout Strategy
1364
-
1365
- /// Optimal grid shapes for common window counts.
1366
- /// Returns array of column counts per row (top row first).
1367
- /// e.g. 5 → [3, 2] means 3 on top row, 2 on bottom row.
1368
- static func gridShape(for count: Int) -> [Int] {
1369
- switch count {
1370
- case 1: return [1]
1371
- case 2: return [2]
1372
- case 3: return [3]
1373
- case 4: return [2, 2]
1374
- case 5: return [3, 2]
1375
- case 6: return [3, 3]
1376
- case 7: return [4, 3]
1377
- case 8: return [4, 4]
1378
- case 9: return [3, 3, 3]
1379
- case 10: return [5, 5]
1380
- case 11: return [4, 4, 3]
1381
- case 12: return [4, 4, 4]
1382
- default:
1383
- // General: bias toward more columns (landscape screens)
1384
- let cols = Int(ceil(sqrt(Double(count) * 1.5)))
1385
- var rows: [Int] = []
1386
- var remaining = count
1387
- while remaining > 0 {
1388
- rows.append(min(cols, remaining))
1389
- remaining -= cols
1390
- }
1391
- return rows
1392
- }
1393
- }
1394
-
1395
- /// Compute grid slot rects in AX coordinates (top-left origin) for N windows
1396
- static func computeGridSlots(count: Int, screen: NSScreen) -> [CGRect] {
1397
- guard count > 0 else { return [] }
1398
- let visible = screen.visibleFrame
1399
- guard let primaryScreen = NSScreen.screens.first else { return [] }
1400
- let primaryHeight = primaryScreen.frame.height
1401
-
1402
- // AX Y of visible top edge
1403
- let axTop = primaryHeight - visible.maxY
1404
- let shape = gridShape(for: count)
1405
- let rowCount = shape.count
1406
- let rowH = visible.height / CGFloat(rowCount)
1407
-
1408
- var slots: [CGRect] = []
1409
- for (row, cols) in shape.enumerated() {
1410
- let colW = visible.width / CGFloat(cols)
1411
- let axY = axTop + CGFloat(row) * rowH
1412
- for col in 0..<cols {
1413
- let x = visible.origin.x + CGFloat(col) * colW
1414
- slots.append(CGRect(x: x, y: axY, width: colW, height: rowH))
1415
- }
1416
- }
1417
- return slots
1418
- }
1419
-
1420
- /// Raise multiple windows and arrange in smart grid — single CG query, single AX query per process
1421
- static func batchRaiseAndDistribute(windows: [(wid: UInt32, pid: Int32)]) {
1422
- guard !windows.isEmpty else { return }
1423
- let diag = DiagnosticLog.shared
1424
-
1425
- // Find screen from first window
1426
- guard let firstFrame = cgWindowFrame(wid: windows[0].wid) else {
1427
- diag.warn("batchRaiseAndDistribute: no frame for first window wid=\(windows[0].wid)")
1428
- return
1429
- }
1430
- let screen = NSScreen.screens.first(where: {
1431
- $0.frame.contains(NSPoint(x: firstFrame.midX, y: firstFrame.midY))
1432
- }) ?? NSScreen.main ?? NSScreen.screens[0]
1433
-
1434
- let visible = screen.visibleFrame
1435
- let screenFrame = screen.frame
1436
- let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
1437
- let shape = gridShape(for: windows.count)
1438
- let desc = shape.map(String.init).joined(separator: "+")
1439
-
1440
- diag.info("Grid layout: \(windows.count) windows → [\(desc)]")
1441
- diag.info(" Screen: \(screen.localizedName) \(Int(screenFrame.width))x\(Int(screenFrame.height))")
1442
- diag.info(" Visible: origin=(\(Int(visible.origin.x)),\(Int(visible.origin.y))) size=\(Int(visible.width))x\(Int(visible.height))")
1443
- diag.info(" Primary height: \(Int(primaryHeight))")
1444
-
1445
- // Pre-compute all target slots
1446
- let slots = computeGridSlots(count: windows.count, screen: screen)
1447
- guard slots.count == windows.count else {
1448
- diag.warn(" Slot count mismatch: \(slots.count) slots for \(windows.count) windows")
1449
- return
1450
- }
1451
-
1452
- for (i, slot) in slots.enumerated() {
1453
- diag.info(" Slot \(i): x=\(Int(slot.origin.x)) y=\(Int(slot.origin.y)) w=\(Int(slot.width)) h=\(Int(slot.height))")
1454
- }
1455
-
1456
- // Single CG query for frame lookup
1457
- let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
1458
- var cgFrames: [UInt32: CGRect] = [:]
1459
- var cgNames: [UInt32: String] = [:]
1460
- for info in windowList {
1461
- guard let num = info[kCGWindowNumber as String] as? UInt32,
1462
- let dict = info[kCGWindowBounds as String] as? NSDictionary else { continue }
1463
- var rect = CGRect.zero
1464
- if CGRectMakeWithDictionaryRepresentation(dict, &rect) { cgFrames[num] = rect }
1465
- cgNames[num] = info[kCGWindowOwnerName as String] as? String
1466
- }
1467
-
1468
- // Log before frames
1469
- for (i, win) in windows.enumerated() {
1470
- let app = cgNames[win.wid] ?? "?"
1471
- if let cg = cgFrames[win.wid] {
1472
- diag.info(" Before[\(i)] wid=\(win.wid) \(app): x=\(Int(cg.origin.x)) y=\(Int(cg.origin.y)) w=\(Int(cg.width)) h=\(Int(cg.height))")
1473
- } else {
1474
- diag.warn(" Before[\(i)] wid=\(win.wid) \(app): NO CG FRAME")
1475
- }
1476
- }
1477
-
1478
- // Group by pid for AX queries, keep slot mapping
1479
- var widToSlot: [UInt32: Int] = [:]
1480
- for (i, win) in windows.enumerated() { widToSlot[win.wid] = i }
1481
-
1482
- var byPid: [Int32: [(wid: UInt32, target: CGRect)]] = [:]
1483
- for (i, win) in windows.enumerated() {
1484
- byPid[win.pid, default: []].append((wid: win.wid, target: slots[i]))
1485
- }
1486
-
1487
- struct AXWin { let el: AXUIElement; let pos: CGPoint; let size: CGSize }
1488
-
1489
- // Pass 1: Move all windows to target positions (no raise yet)
1490
- var moved = 0
1491
- var failed: [UInt32] = []
1492
- var resolvedAXElements: [(slotIdx: Int, el: AXUIElement)] = [] // for raise pass
1493
-
1494
- for (pid, windowMoves) in byPid {
1495
- let appRef = AXUIElementCreateApplication(pid)
1496
- var windowsRef: CFTypeRef?
1497
- let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1498
- guard err == .success, let axWindows = windowsRef as? [AXUIElement] else {
1499
- diag.warn(" AX query failed for pid=\(pid) err=\(err.rawValue)")
1500
- failed.append(contentsOf: windowMoves.map(\.wid))
1501
- continue
1502
- }
1503
-
1504
- var axCache: [AXWin] = []
1505
- for axWin in axWindows {
1506
- var posRef: CFTypeRef?; var sizeRef: CFTypeRef?
1507
- AXUIElementCopyAttributeValue(axWin, kAXPositionAttribute as CFString, &posRef)
1508
- AXUIElementCopyAttributeValue(axWin, kAXSizeAttribute as CFString, &sizeRef)
1509
- guard let pv = posRef, let sv = sizeRef else { continue }
1510
- var pos = CGPoint.zero; var size = CGSize.zero
1511
- AXValueGetValue(pv as! AXValue, .cgPoint, &pos)
1512
- AXValueGetValue(sv as! AXValue, .cgSize, &size)
1513
- axCache.append(AXWin(el: axWin, pos: pos, size: size))
1514
- }
1515
-
1516
- for wm in windowMoves {
1517
- guard let cgRect = cgFrames[wm.wid] else {
1518
- diag.warn(" wid=\(wm.wid): no CG frame, skipping")
1519
- failed.append(wm.wid)
1520
- continue
1521
- }
1522
- guard let ax = axCache.first(where: {
1523
- abs(cgRect.origin.x - $0.pos.x) < 2 && abs(cgRect.origin.y - $0.pos.y) < 2 &&
1524
- abs(cgRect.width - $0.size.width) < 2 && abs(cgRect.height - $0.size.height) < 2
1525
- }) else {
1526
- diag.warn(" wid=\(wm.wid): CG frame (\(Int(cgRect.origin.x)),\(Int(cgRect.origin.y)) \(Int(cgRect.width))x\(Int(cgRect.height))) — no AX match among \(axCache.count) AX windows")
1527
- for (j, axw) in axCache.enumerated() {
1528
- diag.info(" AX[\(j)]: pos=(\(Int(axw.pos.x)),\(Int(axw.pos.y))) size=\(Int(axw.size.width))x\(Int(axw.size.height))")
1529
- }
1530
- failed.append(wm.wid)
1531
- continue
1532
- }
1533
-
1534
- let slotIdx = widToSlot[wm.wid] ?? -1
1535
- // Move only — raise comes later
1536
- var newPos = CGPoint(x: wm.target.origin.x, y: wm.target.origin.y)
1537
- var newSize = CGSize(width: wm.target.width, height: wm.target.height)
1538
- let posOk = AXValueCreate(.cgPoint, &newPos).map {
1539
- AXUIElementSetAttributeValue(ax.el, kAXPositionAttribute as CFString, $0)
1540
- }
1541
- let sizeOk = AXValueCreate(.cgSize, &newSize).map {
1542
- AXUIElementSetAttributeValue(ax.el, kAXSizeAttribute as CFString, $0)
1543
- }
1544
- diag.info(" Move[\(slotIdx)] wid=\(wm.wid): target=(\(Int(wm.target.origin.x)),\(Int(wm.target.origin.y))) \(Int(wm.target.width))x\(Int(wm.target.height)) posErr=\(posOk?.rawValue ?? -1) sizeErr=\(sizeOk?.rawValue ?? -1)")
1545
- resolvedAXElements.append((slotIdx: slotIdx, el: ax.el))
1546
- moved += 1
1547
- }
1548
- }
1549
-
1550
- // Pass 2: Raise all windows in slot order so they all come to front
1551
- // Sort by slot index so the layout order is predictable
1552
- resolvedAXElements.sort { $0.slotIdx < $1.slotIdx }
1553
- for item in resolvedAXElements {
1554
- AXUIElementPerformAction(item.el, kAXRaiseAction as CFString)
1555
- }
1556
- diag.info(" Raised \(resolvedAXElements.count) windows in slot order")
1557
-
1558
- // Pass 3: Activate all apps so windows come to front of other apps
1559
- var activatedPids = Set<Int32>()
1560
- for win in windows {
1561
- if !activatedPids.contains(win.pid) {
1562
- if let app = NSRunningApplication(processIdentifier: win.pid) { app.activate() }
1563
- activatedPids.insert(win.pid)
1564
- }
1565
- }
1566
-
1567
- if !failed.isEmpty {
1568
- diag.warn("batchRaiseAndDistribute: failed wids=\(failed)")
1569
- }
1570
- diag.success("batchRaiseAndDistribute: moved \(moved)/\(windows.count) [\(desc) grid]")
1571
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
1572
- NSApp.activate(ignoringOtherApps: true)
1573
- }
1574
- }
1575
-
1576
- /// Batch restore windows to saved frames (single CG query)
1577
- static func batchRestoreWindows(_ restores: [(wid: UInt32, pid: Int32, frame: WindowFrame)]) {
1578
- let moves = restores.map { (wid: $0.wid, pid: $0.pid,
1579
- frame: CGRect(x: $0.frame.x, y: $0.frame.y,
1580
- width: $0.frame.w, height: $0.frame.h)) }
1581
- batchMoveWindows(moves)
1582
- }
1583
-
1584
- /// Restore a window to a saved frame (CG coordinates: top-left origin)
1585
- static func restoreWindowFrame(wid: UInt32, pid: Int32, frame: WindowFrame) {
1586
- guard let axWindow = findAXWindowByFrame(wid: wid, pid: pid) else {
1587
- DiagnosticLog.shared.warn("restoreWindowFrame: couldn't match AX window for wid=\(wid)")
1588
- return
1589
- }
1590
- var newPos = CGPoint(x: frame.x, y: frame.y)
1591
- var newSize = CGSize(width: frame.w, height: frame.h)
1592
- if let posValue = AXValueCreate(.cgPoint, &newPos) {
1593
- AXUIElementSetAttributeValue(axWindow, kAXPositionAttribute as CFString, posValue)
1594
- }
1595
- if let sizeValue = AXValueCreate(.cgSize, &newSize) {
1596
- AXUIElementSetAttributeValue(axWindow, kAXSizeAttribute as CFString, sizeValue)
1597
- }
1598
- DiagnosticLog.shared.success("restoreWindowFrame: restored wid=\(wid)")
1599
- }
1600
-
1601
- /// Find the AX window element for a given CG window ID by matching frames
1602
- static func findAXWindowByFrame(wid: UInt32, pid: Int32) -> AXUIElement? {
1603
- // Get CG frame for the window
1604
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return nil }
1605
-
1606
- var cgRect = CGRect.zero
1607
- for info in windowList {
1608
- if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
1609
- let dict = info[kCGWindowBounds as String] as? NSDictionary {
1610
- CGRectMakeWithDictionaryRepresentation(dict, &cgRect)
1611
- break
1612
- }
1613
- }
1614
- guard cgRect.width > 0 else { return nil }
1615
-
1616
- // Find AX window with matching frame
1617
- let appRef = AXUIElementCreateApplication(pid)
1618
- var windowsRef: CFTypeRef?
1619
- let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1620
- guard err == .success, let windows = windowsRef as? [AXUIElement] else { return nil }
1621
-
1622
- for win in windows {
1623
- var posRef: CFTypeRef?
1624
- var sizeRef: CFTypeRef?
1625
- AXUIElementCopyAttributeValue(win, kAXPositionAttribute as CFString, &posRef)
1626
- AXUIElementCopyAttributeValue(win, kAXSizeAttribute as CFString, &sizeRef)
1627
- guard let pv = posRef, let sv = sizeRef else { continue }
1628
-
1629
- var pos = CGPoint.zero
1630
- var size = CGSize.zero
1631
- AXValueGetValue(pv as! AXValue, .cgPoint, &pos)
1632
- AXValueGetValue(sv as! AXValue, .cgSize, &size)
1633
-
1634
- if abs(cgRect.origin.x - pos.x) < 2 && abs(cgRect.origin.y - pos.y) < 2 &&
1635
- abs(cgRect.width - size.width) < 2 && abs(cgRect.height - size.height) < 2 {
1636
- return win
1637
- }
1638
- }
1639
- return nil
1640
- }
1641
-
1642
- // MARK: - Any-App Tiling via Accessibility
1643
-
1644
- /// Tile the frontmost window of any app to a position using AX API.
1645
- /// Works for any application (Finder, Chrome, etc.), not just terminals.
1646
- static func tileFrontmostViaAX(to position: TilePosition) {
1647
- // 1. Get frontmost application
1648
- guard let frontApp = NSWorkspace.shared.frontmostApplication,
1649
- frontApp.bundleIdentifier != "com.arach.lattices" else { return }
1650
-
1651
- let appRef = AXUIElementCreateApplication(frontApp.processIdentifier)
1652
-
1653
- // 2. Get focused window
1654
- var focusedRef: CFTypeRef?
1655
- guard AXUIElementCopyAttributeValue(appRef, kAXFocusedWindowAttribute as CFString, &focusedRef) == .success,
1656
- let axWindow = focusedRef else { return }
1657
-
1658
- let win = axWindow as! AXUIElement
1659
-
1660
- // 3. Read current position → find containing screen (AX coords: top-left origin)
1661
- var posRef: CFTypeRef?
1662
- var sizeRef: CFTypeRef?
1663
- AXUIElementCopyAttributeValue(win, kAXPositionAttribute as CFString, &posRef)
1664
- AXUIElementCopyAttributeValue(win, kAXSizeAttribute as CFString, &sizeRef)
1665
-
1666
- var currentPos = CGPoint.zero
1667
- var currentSize = CGSize.zero
1668
- if let pv = posRef { AXValueGetValue(pv as! AXValue, .cgPoint, &currentPos) }
1669
- if let sv = sizeRef { AXValueGetValue(sv as! AXValue, .cgSize, &currentSize) }
1670
-
1671
- let primaryHeight = NSScreen.screens.first?.frame.height ?? 1080
1672
- let nsCenterX = currentPos.x + currentSize.width / 2
1673
- let nsCenterY = primaryHeight - (currentPos.y + currentSize.height / 2)
1674
- let screen = NSScreen.screens.first(where: {
1675
- $0.frame.contains(NSPoint(x: nsCenterX, y: nsCenterY))
1676
- }) ?? NSScreen.main ?? NSScreen.screens[0]
1677
-
1678
- // 4. Compute target frame (AX/CGS coords: top-left origin)
1679
- let targetFrame = tileFrame(for: position, on: screen)
1680
- var newPos = CGPoint(x: targetFrame.origin.x, y: targetFrame.origin.y)
1681
- var newSize = CGSize(width: targetFrame.width, height: targetFrame.height)
1682
-
1683
- // 5. Try SkyLight fast path (instant, no animation)
1684
- var wid: CGWindowID = 0
1685
- let hasSkyLight = _SLSMainConnectionID != nil
1686
- && _SLSMoveWindow != nil
1687
- && _AXUIElementGetWindow(win, &wid) == .success
1688
- && wid != 0
1689
-
1690
- if hasSkyLight {
1691
- let cid = _SLSMainConnectionID!()
1692
-
1693
- // Resize via AX (app must handle its own content layout)
1694
- if let sv = AXValueCreate(.cgSize, &newSize) {
1695
- AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
1696
- }
1697
-
1698
- // Move via SkyLight (instant, no animation)
1699
- if _SLSMoveWindow!(cid, UInt32(wid), &newPos) != .success {
1700
- // SLS move failed — fall back to AX position
1701
- if let pv = AXValueCreate(.cgPoint, &newPos) {
1702
- AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
1703
- }
1704
- }
1705
- } else {
1706
- // 6. AX fallback: size then position
1707
- if let sv = AXValueCreate(.cgSize, &newSize) {
1708
- AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
1709
- }
1710
- if let pv = AXValueCreate(.cgPoint, &newPos) {
1711
- AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
1712
- }
1713
- }
1714
- }
1715
-
1716
- // MARK: - Private
1717
-
1718
- private static func tileAppleScript(app: String, tag: String, bounds: (Int, Int, Int, Int)) {
1719
- let (x1, y1, x2, y2) = bounds
1720
- let script = """
1721
- tell application "\(app)"
1722
- repeat with w in windows
1723
- if name of w contains "\(tag)" then
1724
- set bounds of w to {\(x1), \(y1), \(x2), \(y2)}
1725
- set index of w to 1
1726
- exit repeat
1727
- end if
1728
- end repeat
1729
- end tell
1730
- """
1731
- runScript(script)
1732
- }
1733
-
1734
- private static func tileFrontmost(bounds: (Int, Int, Int, Int)) {
1735
- let (x1, y1, x2, y2) = bounds
1736
- let script = """
1737
- tell application "System Events"
1738
- set frontApp to name of first application process whose frontmost is true
1739
- end tell
1740
- tell application frontApp
1741
- set bounds of front window to {\(x1), \(y1), \(x2), \(y2)}
1742
- end tell
1743
- """
1744
- runScript(script)
1745
- }
1746
-
1747
- private static func runScript(_ script: String) {
1748
- let task = Process()
1749
- task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
1750
- task.arguments = ["-e", script]
1751
- task.standardOutput = FileHandle.nullDevice
1752
- task.standardError = FileHandle.nullDevice
1753
- try? task.run()
1754
- }
1755
- }