@lattices/cli 0.4.0 → 0.4.2
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/app/Info.plist +30 -0
- package/app/Lattices.app/Contents/Info.plist +8 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
- package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
- package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
- package/app/Lattices.entitlements +15 -0
- package/app/Resources/tap.wav +0 -0
- package/app/Sources/ActionRow.swift +43 -26
- package/app/Sources/AppDelegate.swift +13 -7
- package/app/Sources/DesktopModel.swift +26 -2
- package/app/Sources/HandsOffSession.swift +121 -26
- package/app/Sources/HotkeyStore.swift +5 -1
- package/app/Sources/IntentEngine.swift +37 -0
- package/app/Sources/LatticesApi.swift +40 -0
- package/app/Sources/MainView.swift +73 -21
- package/app/Sources/MouseFinder.swift +222 -0
- package/app/Sources/ProjectScanner.swift +57 -44
- package/app/Tests/StageDragTests.swift +333 -0
- package/app/Tests/StageJoinTests.swift +313 -0
- package/app/Tests/StageManagerTests.swift +280 -0
- package/app/Tests/StageTileTests.swift +353 -0
- package/assets/AppIcon.icns +0 -0
- package/bin/handsoff-worker.ts +10 -1
- package/bin/lattices-app.ts +123 -39
- package/bin/lattices-dev +51 -3
- package/bin/lattices.ts +181 -7
- package/docs/agent-layer-guide.md +207 -0
- package/package.json +12 -4
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
import AppKit
|
|
4
|
+
|
|
5
|
+
/// Experiments: can we programmatically create a new stage by joining windows?
|
|
6
|
+
///
|
|
7
|
+
/// Stage Manager groups windows into stages. When a user drags a window from
|
|
8
|
+
/// the strip onto the center stage, it joins that stage. Can we replicate this
|
|
9
|
+
/// via AX, CGS, or simulated events?
|
|
10
|
+
final class StageJoinTests: XCTestCase {
|
|
11
|
+
|
|
12
|
+
// MARK: - Helpers
|
|
13
|
+
|
|
14
|
+
struct LiveWindow {
|
|
15
|
+
let wid: UInt32
|
|
16
|
+
let app: String
|
|
17
|
+
let pid: Int32
|
|
18
|
+
let title: String
|
|
19
|
+
let bounds: CGRect
|
|
20
|
+
let isOnScreen: Bool
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Get all real app windows (layer 0, >= 50x50)
|
|
24
|
+
func getRealWindows() -> [LiveWindow] {
|
|
25
|
+
guard let list = CGWindowListCopyWindowInfo(
|
|
26
|
+
[.optionAll, .excludeDesktopElements],
|
|
27
|
+
kCGNullWindowID
|
|
28
|
+
) as? [[String: Any]] else { return [] }
|
|
29
|
+
|
|
30
|
+
let skip: Set<String> = [
|
|
31
|
+
"Window Server", "Dock", "Control Center", "SystemUIServer",
|
|
32
|
+
"Notification Center", "Spotlight", "WindowManager",
|
|
33
|
+
"Lattices",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
return list.compactMap { info in
|
|
37
|
+
guard let wid = info[kCGWindowNumber as String] as? UInt32,
|
|
38
|
+
let owner = info[kCGWindowOwnerName as String] as? String,
|
|
39
|
+
let pid = info[kCGWindowOwnerPID as String] as? Int32,
|
|
40
|
+
let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
|
|
41
|
+
else { return nil }
|
|
42
|
+
|
|
43
|
+
var rect = CGRect.zero
|
|
44
|
+
guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { return nil }
|
|
45
|
+
let title = info[kCGWindowName as String] as? String ?? ""
|
|
46
|
+
let layer = info[kCGWindowLayer as String] as? Int ?? 0
|
|
47
|
+
let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? false
|
|
48
|
+
|
|
49
|
+
guard layer == 0, rect.width >= 50, rect.height >= 50 else { return nil }
|
|
50
|
+
guard !skip.contains(owner) else { return nil }
|
|
51
|
+
|
|
52
|
+
return LiveWindow(wid: wid, app: owner, pid: pid, title: title,
|
|
53
|
+
bounds: rect, isOnScreen: isOnScreen)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Get AX window elements for a PID
|
|
58
|
+
func getAXWindows(pid: Int32) -> [AXUIElement] {
|
|
59
|
+
let app = AXUIElementCreateApplication(pid)
|
|
60
|
+
var ref: CFTypeRef?
|
|
61
|
+
guard AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &ref) == .success,
|
|
62
|
+
let windows = ref as? [AXUIElement] else { return [] }
|
|
63
|
+
return windows
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Move an AX window to a position
|
|
67
|
+
func moveAXWindow(_ axWin: AXUIElement, to point: CGPoint) -> Bool {
|
|
68
|
+
var p = point
|
|
69
|
+
let posValue = AXValueCreate(.cgPoint, &p)!
|
|
70
|
+
return AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, posValue) == .success
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Resize an AX window
|
|
74
|
+
func resizeAXWindow(_ axWin: AXUIElement, to size: CGSize) -> Bool {
|
|
75
|
+
var s = size
|
|
76
|
+
let sizeValue = AXValueCreate(.cgSize, &s)!
|
|
77
|
+
return AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sizeValue) == .success
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Raise an AX window
|
|
81
|
+
func raiseAXWindow(_ axWin: AXUIElement) -> Bool {
|
|
82
|
+
AXUIElementPerformAction(axWin, kAXRaiseAction as CFString) == .success
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Snapshot which windows are onscreen (active stage)
|
|
86
|
+
func activeStageWids() -> Set<UInt32> {
|
|
87
|
+
Set(getRealWindows().filter { $0.isOnScreen && $0.bounds.width > 250 }.map(\.wid))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Print stage state
|
|
91
|
+
func printStageState(label: String) {
|
|
92
|
+
let windows = getRealWindows()
|
|
93
|
+
let active = windows.filter { $0.isOnScreen && $0.bounds.width > 250 }
|
|
94
|
+
let strip = windows.filter { $0.isOnScreen && $0.bounds.width < 250 && $0.bounds.origin.x < 220 }
|
|
95
|
+
print("\n[\(label)]")
|
|
96
|
+
print(" Active stage: \(active.map { "\($0.app)(\($0.wid))" }.joined(separator: ", "))")
|
|
97
|
+
print(" Strip: \(Set(strip.map(\.app)).sorted().joined(separator: ", "))")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// MARK: - Approach 1: Activate two apps rapidly
|
|
101
|
+
|
|
102
|
+
func testJoinByActivation() throws {
|
|
103
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
104
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
105
|
+
|
|
106
|
+
let windows = getRealWindows()
|
|
107
|
+
|
|
108
|
+
// Find two different apps that are NOT in the current stage
|
|
109
|
+
let offscreenApps = Dictionary(grouping: windows.filter { !$0.isOnScreen && $0.bounds.width > 250 },
|
|
110
|
+
by: \.app)
|
|
111
|
+
let candidates = offscreenApps.keys.sorted()
|
|
112
|
+
guard candidates.count >= 2 else {
|
|
113
|
+
print("Need at least 2 offscreen apps, found: \(candidates)")
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let appA = candidates[0]
|
|
118
|
+
let appB = candidates[1]
|
|
119
|
+
let winA = offscreenApps[appA]!.first!
|
|
120
|
+
let winB = offscreenApps[appB]!.first!
|
|
121
|
+
|
|
122
|
+
print("Attempting to join \(appA) and \(appB) by rapid activation")
|
|
123
|
+
printStageState(label: "BEFORE")
|
|
124
|
+
|
|
125
|
+
// Activate app A
|
|
126
|
+
let nsAppA = NSRunningApplication(processIdentifier: winA.pid)
|
|
127
|
+
nsAppA?.activate()
|
|
128
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
129
|
+
|
|
130
|
+
printStageState(label: "After activating \(appA)")
|
|
131
|
+
|
|
132
|
+
// Now immediately activate app B — does SM merge them?
|
|
133
|
+
let nsAppB = NSRunningApplication(processIdentifier: winB.pid)
|
|
134
|
+
nsAppB?.activate()
|
|
135
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
136
|
+
|
|
137
|
+
printStageState(label: "After activating \(appB)")
|
|
138
|
+
|
|
139
|
+
// Check: are both apps in the active stage?
|
|
140
|
+
let finalActive = getRealWindows().filter { $0.isOnScreen && $0.bounds.width > 250 }
|
|
141
|
+
let activeApps = Set(finalActive.map(\.app))
|
|
142
|
+
let joined = activeApps.contains(appA) && activeApps.contains(appB)
|
|
143
|
+
print("\nResult: both apps in active stage? \(joined)")
|
|
144
|
+
print("Active apps: \(activeApps.sorted())")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// MARK: - Approach 2: Move offscreen window into active stage area via AX
|
|
148
|
+
|
|
149
|
+
func testJoinByAXMove() throws {
|
|
150
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
151
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
152
|
+
|
|
153
|
+
let windows = getRealWindows()
|
|
154
|
+
|
|
155
|
+
// Find the active stage center
|
|
156
|
+
let activeWindows = windows.filter { $0.isOnScreen && $0.bounds.width > 250 }
|
|
157
|
+
guard let anchor = activeWindows.first else {
|
|
158
|
+
print("No active stage window found")
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Find an offscreen app to pull in
|
|
163
|
+
let offscreen = windows.filter { !$0.isOnScreen && $0.bounds.width > 250 && $0.app != anchor.app }
|
|
164
|
+
guard let target = offscreen.first else {
|
|
165
|
+
print("No offscreen window to test with")
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
print("Attempting to join \(target.app)[\(target.wid)] into \(anchor.app)'s stage via AX move")
|
|
170
|
+
printStageState(label: "BEFORE")
|
|
171
|
+
|
|
172
|
+
// Get AX window for target
|
|
173
|
+
let axWindows = getAXWindows(pid: target.pid)
|
|
174
|
+
guard let axWin = axWindows.first else {
|
|
175
|
+
print("Could not get AX window for \(target.app)")
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Move it right next to the anchor window
|
|
180
|
+
let destPoint = CGPoint(x: anchor.bounds.origin.x + 50, y: anchor.bounds.origin.y + 50)
|
|
181
|
+
let moved = moveAXWindow(axWin, to: destPoint)
|
|
182
|
+
print("AX move result: \(moved)")
|
|
183
|
+
|
|
184
|
+
// Raise it
|
|
185
|
+
let raised = raiseAXWindow(axWin)
|
|
186
|
+
print("AX raise result: \(raised)")
|
|
187
|
+
|
|
188
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
189
|
+
printStageState(label: "After AX move + raise")
|
|
190
|
+
|
|
191
|
+
// Did it join the stage?
|
|
192
|
+
let finalActive = getRealWindows().filter { $0.isOnScreen && $0.bounds.width > 250 }
|
|
193
|
+
let activeApps = Set(finalActive.map(\.app))
|
|
194
|
+
let joined = activeApps.contains(target.app) && activeApps.contains(anchor.app)
|
|
195
|
+
print("\nResult: \(target.app) joined \(anchor.app)'s stage? \(joined)")
|
|
196
|
+
print("Active apps: \(activeApps.sorted())")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// MARK: - Approach 3: AX move + activate target app (without switching stage)
|
|
200
|
+
|
|
201
|
+
func testJoinByMoveAndActivate() throws {
|
|
202
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
203
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
204
|
+
|
|
205
|
+
let windows = getRealWindows()
|
|
206
|
+
let activeWindows = windows.filter { $0.isOnScreen && $0.bounds.width > 250 }
|
|
207
|
+
guard let anchor = activeWindows.first else { return }
|
|
208
|
+
|
|
209
|
+
let offscreen = windows.filter { !$0.isOnScreen && $0.bounds.width > 250 && $0.app != anchor.app }
|
|
210
|
+
guard let target = offscreen.first else { return }
|
|
211
|
+
|
|
212
|
+
print("Attempting: move \(target.app) via AX, then activate it")
|
|
213
|
+
printStageState(label: "BEFORE")
|
|
214
|
+
|
|
215
|
+
// Step 1: Move target window into center area via AX
|
|
216
|
+
let axWindows = getAXWindows(pid: target.pid)
|
|
217
|
+
guard let axWin = axWindows.first else { return }
|
|
218
|
+
|
|
219
|
+
let dest = CGPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
|
|
220
|
+
_ = moveAXWindow(axWin, to: dest)
|
|
221
|
+
_ = resizeAXWindow(axWin, to: CGSize(width: 800, height: 600))
|
|
222
|
+
|
|
223
|
+
Thread.sleep(forTimeInterval: 0.2)
|
|
224
|
+
|
|
225
|
+
// Step 2: Activate anchor app first (keep it in stage)
|
|
226
|
+
NSRunningApplication(processIdentifier: anchor.pid)?.activate()
|
|
227
|
+
Thread.sleep(forTimeInterval: 0.1)
|
|
228
|
+
|
|
229
|
+
// Step 3: Raise the target window (without activate, to avoid stage switch)
|
|
230
|
+
_ = raiseAXWindow(axWin)
|
|
231
|
+
|
|
232
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
233
|
+
printStageState(label: "After move + raise (no activate)")
|
|
234
|
+
|
|
235
|
+
// Step 4: Now try activating both
|
|
236
|
+
NSRunningApplication(processIdentifier: anchor.pid)?.activate()
|
|
237
|
+
Thread.sleep(forTimeInterval: 0.1)
|
|
238
|
+
// Use AX to set focused on target window
|
|
239
|
+
AXUIElementSetAttributeValue(axWin, kAXFocusedAttribute as CFString, kCFBooleanTrue)
|
|
240
|
+
AXUIElementSetAttributeValue(axWin, kAXMainAttribute as CFString, kCFBooleanTrue)
|
|
241
|
+
|
|
242
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
243
|
+
printStageState(label: "After setting focus+main on target")
|
|
244
|
+
|
|
245
|
+
let finalActive = getRealWindows().filter { $0.isOnScreen && $0.bounds.width > 250 }
|
|
246
|
+
let activeApps = Set(finalActive.map(\.app))
|
|
247
|
+
let joined = activeApps.contains(target.app) && activeApps.contains(anchor.app)
|
|
248
|
+
print("\nResult: joined? \(joined) — active apps: \(activeApps.sorted())")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// MARK: - Approach 4: CGS space manipulation — put both windows on same space
|
|
252
|
+
|
|
253
|
+
func testJoinBySameSpace() throws {
|
|
254
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
255
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
256
|
+
|
|
257
|
+
// Load CGS functions
|
|
258
|
+
typealias CGSConnectionID = UInt32
|
|
259
|
+
typealias CGSMainConnectionIDFunc = @convention(c) () -> CGSConnectionID
|
|
260
|
+
typealias CGSAddWindowsToSpacesFunc = @convention(c) (CGSConnectionID, CFArray, CFArray) -> Void
|
|
261
|
+
typealias CGSGetActiveSpaceFunc = @convention(c) (CGSConnectionID) -> Int
|
|
262
|
+
|
|
263
|
+
guard let handle = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY),
|
|
264
|
+
let mainConnSym = dlsym(handle, "CGSMainConnectionID"),
|
|
265
|
+
let addToSpacesSym = dlsym(handle, "CGSAddWindowsToSpaces"),
|
|
266
|
+
let activeSpaceSym = dlsym(handle, "CGSGetActiveSpace")
|
|
267
|
+
else {
|
|
268
|
+
print("Could not load SkyLight functions")
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let CGSMainConnectionID = unsafeBitCast(mainConnSym, to: CGSMainConnectionIDFunc.self)
|
|
273
|
+
let CGSAddWindowsToSpaces = unsafeBitCast(addToSpacesSym, to: CGSAddWindowsToSpacesFunc.self)
|
|
274
|
+
let CGSGetActiveSpace = unsafeBitCast(activeSpaceSym, to: CGSGetActiveSpaceFunc.self)
|
|
275
|
+
|
|
276
|
+
let cid = CGSMainConnectionID()
|
|
277
|
+
let activeSpace = CGSGetActiveSpace(cid)
|
|
278
|
+
print("Connection: \(cid), Active space: \(activeSpace)")
|
|
279
|
+
|
|
280
|
+
let windows = getRealWindows()
|
|
281
|
+
let activeWindows = windows.filter { $0.isOnScreen && $0.bounds.width > 250 }
|
|
282
|
+
guard let anchor = activeWindows.first else { return }
|
|
283
|
+
|
|
284
|
+
let offscreen = windows.filter { !$0.isOnScreen && $0.bounds.width > 250 && $0.app != anchor.app }
|
|
285
|
+
guard let target = offscreen.first else { return }
|
|
286
|
+
|
|
287
|
+
print("Attempting: add \(target.app)[\(target.wid)] to space \(activeSpace) via CGSAddWindowsToSpaces")
|
|
288
|
+
printStageState(label: "BEFORE")
|
|
289
|
+
|
|
290
|
+
// Add target window to active space
|
|
291
|
+
let windowIDs = [target.wid] as CFArray
|
|
292
|
+
let spaceIDs = [activeSpace] as CFArray
|
|
293
|
+
CGSAddWindowsToSpaces(cid, windowIDs, spaceIDs)
|
|
294
|
+
|
|
295
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
296
|
+
printStageState(label: "After CGSAddWindowsToSpaces")
|
|
297
|
+
|
|
298
|
+
// Also try raising it via AX
|
|
299
|
+
let axWindows = getAXWindows(pid: target.pid)
|
|
300
|
+
if let axWin = axWindows.first {
|
|
301
|
+
_ = raiseAXWindow(axWin)
|
|
302
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
303
|
+
printStageState(label: "After raise")
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let finalActive = getRealWindows().filter { $0.isOnScreen && $0.bounds.width > 250 }
|
|
307
|
+
let activeApps = Set(finalActive.map(\.app))
|
|
308
|
+
let joined = activeApps.contains(target.app) && activeApps.contains(anchor.app)
|
|
309
|
+
print("\nResult: joined? \(joined) — active apps: \(activeApps.sorted())")
|
|
310
|
+
|
|
311
|
+
dlclose(handle)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
import AppKit
|
|
4
|
+
|
|
5
|
+
final class StageManagerTests: XCTestCase {
|
|
6
|
+
|
|
7
|
+
// MARK: - Detection
|
|
8
|
+
|
|
9
|
+
func testStageManagerEnabled() {
|
|
10
|
+
let defaults = UserDefaults(suiteName: "com.apple.WindowManager")
|
|
11
|
+
let enabled = defaults?.bool(forKey: "GloballyEnabled") ?? false
|
|
12
|
+
print("Stage Manager enabled: \(enabled)")
|
|
13
|
+
|
|
14
|
+
// Also read all known keys
|
|
15
|
+
let keys = [
|
|
16
|
+
"GloballyEnabled",
|
|
17
|
+
"GloballyEnabledEver",
|
|
18
|
+
"AutoHide",
|
|
19
|
+
"AppWindowGroupingBehavior",
|
|
20
|
+
"HideDesktop",
|
|
21
|
+
"StageManagerHideWidgets",
|
|
22
|
+
"EnableStandardClickToShowDesktop",
|
|
23
|
+
"EnableTiledWindowMargins",
|
|
24
|
+
"EnableTilingByEdgeDrag",
|
|
25
|
+
"EnableTopTilingByEdgeDrag",
|
|
26
|
+
"StandardHideDesktopIcons",
|
|
27
|
+
"StandardHideWidgets",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
print("\n=== com.apple.WindowManager preferences ===")
|
|
31
|
+
for key in keys {
|
|
32
|
+
let val = defaults?.object(forKey: key)
|
|
33
|
+
print(" \(key): \(val ?? "nil" as Any)")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Not asserting true/false — just verifying we can read the domain
|
|
37
|
+
XCTAssertNotNil(defaults, "Should be able to open com.apple.WindowManager domain")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// MARK: - Window Classification
|
|
41
|
+
|
|
42
|
+
func testClassifyWindows() {
|
|
43
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
44
|
+
|
|
45
|
+
guard smEnabled else {
|
|
46
|
+
print("Stage Manager is OFF — skipping window classification")
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
guard let windowList = CGWindowListCopyWindowInfo(
|
|
51
|
+
[.optionAll, .excludeDesktopElements],
|
|
52
|
+
kCGNullWindowID
|
|
53
|
+
) as? [[String: Any]] else {
|
|
54
|
+
XCTFail("Could not get window list")
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
var activeStage: [(wid: UInt32, app: String, title: String, bounds: CGRect)] = []
|
|
59
|
+
var stripThumbnails: [(wid: UInt32, app: String, title: String, bounds: CGRect)] = []
|
|
60
|
+
var hiddenStage: [(wid: UInt32, app: String, title: String, bounds: CGRect)] = []
|
|
61
|
+
var gestureOverlays: [(wid: UInt32, bounds: CGRect)] = []
|
|
62
|
+
var appIcons: [(wid: UInt32, bounds: CGRect)] = []
|
|
63
|
+
|
|
64
|
+
let mainScreen = NSScreen.main!
|
|
65
|
+
let stripMaxX: CGFloat = 220 // strip occupies roughly left 220px
|
|
66
|
+
let thumbnailMaxSize: CGFloat = 250
|
|
67
|
+
|
|
68
|
+
for info in windowList {
|
|
69
|
+
guard let wid = info[kCGWindowNumber as String] as? UInt32,
|
|
70
|
+
let owner = info[kCGWindowOwnerName as String] as? String,
|
|
71
|
+
let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
|
|
72
|
+
else { continue }
|
|
73
|
+
|
|
74
|
+
var rect = CGRect.zero
|
|
75
|
+
guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { continue }
|
|
76
|
+
|
|
77
|
+
let title = info[kCGWindowName as String] as? String ?? ""
|
|
78
|
+
let layer = info[kCGWindowLayer as String] as? Int ?? 0
|
|
79
|
+
let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? false
|
|
80
|
+
|
|
81
|
+
guard layer == 0 else { continue }
|
|
82
|
+
guard rect.width >= 10, rect.height >= 10 else { continue }
|
|
83
|
+
|
|
84
|
+
// WindowManager process windows (GBOs and app icons)
|
|
85
|
+
if owner == "WindowManager" {
|
|
86
|
+
if title == "Gesture Blocking Overlay" || title.isEmpty {
|
|
87
|
+
if rect.width <= 80 && rect.height <= 80 {
|
|
88
|
+
appIcons.append((wid: wid, bounds: rect))
|
|
89
|
+
} else {
|
|
90
|
+
gestureOverlays.append((wid: wid, bounds: rect))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Skip non-app processes
|
|
97
|
+
let skipOwners: Set<String> = [
|
|
98
|
+
"Window Server", "Dock", "Control Center", "SystemUIServer",
|
|
99
|
+
"Notification Center", "Spotlight",
|
|
100
|
+
]
|
|
101
|
+
if skipOwners.contains(owner) { continue }
|
|
102
|
+
|
|
103
|
+
if !isOnScreen {
|
|
104
|
+
// Hidden in another stage
|
|
105
|
+
hiddenStage.append((wid: wid, app: owner, title: title, bounds: rect))
|
|
106
|
+
} else if rect.width < thumbnailMaxSize && rect.height < thumbnailMaxSize
|
|
107
|
+
&& rect.origin.x < stripMaxX {
|
|
108
|
+
// Strip thumbnail
|
|
109
|
+
stripThumbnails.append((wid: wid, app: owner, title: title, bounds: rect))
|
|
110
|
+
} else if rect.width >= 50 && rect.height >= 50 {
|
|
111
|
+
// Active stage window
|
|
112
|
+
activeStage.append((wid: wid, app: owner, title: title, bounds: rect))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
print("\n=== Stage Manager Window Classification ===")
|
|
117
|
+
print("Screen: \(Int(mainScreen.frame.width))x\(Int(mainScreen.frame.height))")
|
|
118
|
+
|
|
119
|
+
print("\n--- Active Stage (\(activeStage.count) windows) ---")
|
|
120
|
+
for w in activeStage {
|
|
121
|
+
print(" [\(w.wid)] \(w.app) — \"\(w.title)\"")
|
|
122
|
+
print(" bounds: \(Int(w.bounds.origin.x)),\(Int(w.bounds.origin.y)) \(Int(w.bounds.width))x\(Int(w.bounds.height))")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
print("\n--- Strip Thumbnails (\(stripThumbnails.count) windows) ---")
|
|
126
|
+
for w in stripThumbnails {
|
|
127
|
+
print(" [\(w.wid)] \(w.app) — \"\(w.title)\"")
|
|
128
|
+
print(" bounds: \(Int(w.bounds.origin.x)),\(Int(w.bounds.origin.y)) \(Int(w.bounds.width))x\(Int(w.bounds.height))")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
print("\n--- Hidden in Other Stages (\(hiddenStage.count) windows) ---")
|
|
132
|
+
for w in hiddenStage {
|
|
133
|
+
print(" [\(w.wid)] \(w.app) — \"\(w.title)\"")
|
|
134
|
+
print(" bounds: \(Int(w.bounds.origin.x)),\(Int(w.bounds.origin.y)) \(Int(w.bounds.width))x\(Int(w.bounds.height))")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
print("\n--- Gesture Blocking Overlays (\(gestureOverlays.count)) ---")
|
|
138
|
+
for g in gestureOverlays {
|
|
139
|
+
print(" [\(g.wid)] bounds: \(Int(g.bounds.origin.x)),\(Int(g.bounds.origin.y)) \(Int(g.bounds.width))x\(Int(g.bounds.height))")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
print("\n--- App Icons in Strip (\(appIcons.count)) ---")
|
|
143
|
+
for a in appIcons {
|
|
144
|
+
print(" [\(a.wid)] bounds: \(Int(a.bounds.origin.x)),\(Int(a.bounds.origin.y)) \(Int(a.bounds.width))x\(Int(a.bounds.height))")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Try to correlate GBOs to strip thumbnails by proximity
|
|
148
|
+
print("\n--- GBO ↔ Thumbnail Correlation ---")
|
|
149
|
+
for gbo in gestureOverlays {
|
|
150
|
+
let gboCenter = CGPoint(x: gbo.bounds.midX, y: gbo.bounds.midY)
|
|
151
|
+
var closest: (wid: UInt32, app: String, dist: CGFloat) = (0, "", .greatestFiniteMagnitude)
|
|
152
|
+
for thumb in stripThumbnails {
|
|
153
|
+
let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
|
|
154
|
+
let dist = hypot(gboCenter.x - thumbCenter.x, gboCenter.y - thumbCenter.y)
|
|
155
|
+
if dist < closest.dist {
|
|
156
|
+
closest = (thumb.wid, thumb.app, dist)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if closest.dist < 300 {
|
|
160
|
+
print(" GBO [\(gbo.wid)] → Thumbnail [\(closest.wid)] \(closest.app) (dist: \(Int(closest.dist))px)")
|
|
161
|
+
} else {
|
|
162
|
+
print(" GBO [\(gbo.wid)] → no match (closest: \(Int(closest.dist))px)")
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// MARK: - Stage Grouping Heuristic
|
|
168
|
+
|
|
169
|
+
func testInferStageGroups() {
|
|
170
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
171
|
+
|
|
172
|
+
guard smEnabled else {
|
|
173
|
+
print("Stage Manager is OFF — skipping stage grouping")
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
guard let windowList = CGWindowListCopyWindowInfo(
|
|
178
|
+
[.optionAll, .excludeDesktopElements],
|
|
179
|
+
kCGNullWindowID
|
|
180
|
+
) as? [[String: Any]] else {
|
|
181
|
+
XCTFail("Could not get window list")
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Stage Manager groups windows together. When "All at Once" is selected,
|
|
186
|
+
// all windows from one app move together. We can infer groups by looking
|
|
187
|
+
// at which offscreen windows share similar thumbnail strip positions.
|
|
188
|
+
|
|
189
|
+
struct WinInfo {
|
|
190
|
+
let wid: UInt32
|
|
191
|
+
let app: String
|
|
192
|
+
let title: String
|
|
193
|
+
let bounds: CGRect
|
|
194
|
+
let isOnScreen: Bool
|
|
195
|
+
let pid: Int32
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
var appWindows: [String: [WinInfo]] = [:]
|
|
199
|
+
let skipOwners: Set<String> = [
|
|
200
|
+
"Window Server", "Dock", "Control Center", "SystemUIServer",
|
|
201
|
+
"Notification Center", "Spotlight", "WindowManager",
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
for info in windowList {
|
|
205
|
+
guard let wid = info[kCGWindowNumber as String] as? UInt32,
|
|
206
|
+
let owner = info[kCGWindowOwnerName as String] as? String,
|
|
207
|
+
let pid = info[kCGWindowOwnerPID as String] as? Int32,
|
|
208
|
+
let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
|
|
209
|
+
else { continue }
|
|
210
|
+
|
|
211
|
+
var rect = CGRect.zero
|
|
212
|
+
guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { continue }
|
|
213
|
+
let title = info[kCGWindowName as String] as? String ?? ""
|
|
214
|
+
let layer = info[kCGWindowLayer as String] as? Int ?? 0
|
|
215
|
+
let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? false
|
|
216
|
+
|
|
217
|
+
guard layer == 0, rect.width >= 50, rect.height >= 50 else { continue }
|
|
218
|
+
guard !skipOwners.contains(owner) else { continue }
|
|
219
|
+
|
|
220
|
+
let w = WinInfo(wid: wid, app: owner, title: title, bounds: rect,
|
|
221
|
+
isOnScreen: isOnScreen, pid: pid)
|
|
222
|
+
appWindows[owner, default: []].append(w)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Classify into stages
|
|
226
|
+
var currentStageApps: Set<String> = []
|
|
227
|
+
var otherStageApps: Set<String> = []
|
|
228
|
+
|
|
229
|
+
for (app, wins) in appWindows {
|
|
230
|
+
let hasOnScreen = wins.contains { $0.isOnScreen && $0.bounds.width > 250 }
|
|
231
|
+
if hasOnScreen {
|
|
232
|
+
currentStageApps.insert(app)
|
|
233
|
+
} else {
|
|
234
|
+
otherStageApps.insert(app)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
print("\n=== Inferred Stage Groups ===")
|
|
239
|
+
print("\n🟢 Current Stage:")
|
|
240
|
+
for app in currentStageApps.sorted() {
|
|
241
|
+
let wins = appWindows[app]!.filter { $0.isOnScreen }
|
|
242
|
+
print(" \(app) (\(wins.count) window\(wins.count == 1 ? "" : "s"))")
|
|
243
|
+
for w in wins {
|
|
244
|
+
print(" [\(w.wid)] \"\(w.title)\" — \(Int(w.bounds.width))x\(Int(w.bounds.height))")
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
print("\n🔵 Other Stages:")
|
|
249
|
+
for app in otherStageApps.sorted() {
|
|
250
|
+
let wins = appWindows[app]!
|
|
251
|
+
let onScreen = wins.filter { $0.isOnScreen }
|
|
252
|
+
let offScreen = wins.filter { !$0.isOnScreen }
|
|
253
|
+
print(" \(app) (\(offScreen.count) hidden, \(onScreen.count) thumbnail)")
|
|
254
|
+
for w in offScreen.prefix(3) {
|
|
255
|
+
print(" [\(w.wid)] \"\(w.title)\" — \(Int(w.bounds.width))x\(Int(w.bounds.height))")
|
|
256
|
+
}
|
|
257
|
+
if offScreen.count > 3 { print(" ... and \(offScreen.count - 3) more") }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// MARK: - Preferences Change Detection
|
|
262
|
+
|
|
263
|
+
func testStageManagerPrefsObservation() {
|
|
264
|
+
// Test that we can observe preference changes via polling
|
|
265
|
+
let defaults = UserDefaults(suiteName: "com.apple.WindowManager")
|
|
266
|
+
let initial = defaults?.bool(forKey: "GloballyEnabled") ?? false
|
|
267
|
+
print("Initial Stage Manager state: \(initial)")
|
|
268
|
+
|
|
269
|
+
// Quick re-read to verify consistency
|
|
270
|
+
let reread = defaults?.bool(forKey: "GloballyEnabled") ?? false
|
|
271
|
+
XCTAssertEqual(initial, reread, "Preference should be stable across reads")
|
|
272
|
+
|
|
273
|
+
// Read AppWindowGroupingBehavior
|
|
274
|
+
let grouping = defaults?.integer(forKey: "AppWindowGroupingBehavior") ?? -1
|
|
275
|
+
print("AppWindowGroupingBehavior: \(grouping) (\(grouping == 0 ? "All at Once" : grouping == 1 ? "One at a Time" : "unknown"))")
|
|
276
|
+
|
|
277
|
+
let autoHide = defaults?.bool(forKey: "AutoHide") ?? false
|
|
278
|
+
print("AutoHide (strip): \(autoHide)")
|
|
279
|
+
}
|
|
280
|
+
}
|