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