@lattices/cli 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -9
- 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/Package.swift +8 -1
- package/app/Resources/tap.wav +0 -0
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +45 -12
- package/app/Sources/AppShellView.swift +81 -8
- package/app/Sources/AudioProvider.swift +386 -0
- package/app/Sources/CheatSheetHUD.swift +261 -19
- package/app/Sources/DaemonProtocol.swift +13 -0
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DesktopModel.swift +189 -6
- package/app/Sources/DesktopModelTypes.swift +2 -0
- package/app/Sources/DiagnosticLog.swift +104 -2
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HUDBottomBar.swift +279 -0
- package/app/Sources/HUDController.swift +1158 -0
- package/app/Sources/HUDLeftBar.swift +849 -0
- package/app/Sources/HUDMinimap.swift +179 -0
- package/app/Sources/HUDRightBar.swift +774 -0
- package/app/Sources/HUDState.swift +367 -0
- package/app/Sources/HUDTopBar.swift +243 -0
- package/app/Sources/HandsOffSession.swift +802 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +49 -9
- package/app/Sources/IntentEngine.swift +962 -0
- package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
- package/app/Sources/Intents/DistributeIntent.swift +56 -0
- package/app/Sources/Intents/FocusIntent.swift +69 -0
- package/app/Sources/Intents/HelpIntent.swift +41 -0
- package/app/Sources/Intents/KillIntent.swift +47 -0
- package/app/Sources/Intents/LatticeIntent.swift +78 -0
- package/app/Sources/Intents/LaunchIntent.swift +67 -0
- package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
- package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
- package/app/Sources/Intents/ScanIntent.swift +52 -0
- package/app/Sources/Intents/SearchIntent.swift +190 -0
- package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
- package/app/Sources/Intents/TileIntent.swift +61 -0
- package/app/Sources/LatticesApi.swift +1275 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- package/app/Sources/MouseFinder.swift +222 -0
- package/app/Sources/OcrModel.swift +34 -1
- package/app/Sources/OmniSearchState.swift +99 -102
- package/app/Sources/OnboardingView.swift +457 -0
- package/app/Sources/PermissionChecker.swift +2 -12
- package/app/Sources/PiChatDock.swift +454 -0
- package/app/Sources/PiChatSession.swift +815 -0
- package/app/Sources/PiWorkspaceView.swift +364 -0
- package/app/Sources/PlacementSpec.swift +195 -0
- package/app/Sources/Preferences.swift +59 -0
- package/app/Sources/ProjectScanner.swift +58 -45
- package/app/Sources/ScreenMapState.swift +701 -55
- package/app/Sources/ScreenMapView.swift +843 -103
- package/app/Sources/ScreenMapWindowController.swift +22 -0
- package/app/Sources/SessionLayerStore.swift +285 -0
- package/app/Sources/SessionManager.swift +4 -1
- package/app/Sources/SettingsView.swift +186 -3
- package/app/Sources/Theme.swift +9 -8
- package/app/Sources/TmuxModel.swift +7 -0
- package/app/Sources/TmuxQuery.swift +27 -3
- package/app/Sources/VoiceChatView.swift +192 -0
- package/app/Sources/VoiceCommandWindow.swift +1594 -0
- package/app/Sources/VoiceIntentResolver.swift +671 -0
- package/app/Sources/VoxClient.swift +454 -0
- package/app/Sources/WindowTiler.swift +348 -87
- package/app/Sources/WorkspaceManager.swift +127 -18
- 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/client.ts +16 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +280 -0
- package/bin/handsoff-worker.ts +740 -0
- package/bin/lattices-app.ts +338 -0
- package/bin/lattices-dev +208 -0
- package/bin/{lattices.js → lattices.ts} +777 -140
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agent-layer-guide.md +207 -0
- package/docs/agents.md +142 -0
- package/docs/api.md +153 -34
- package/docs/app.md +29 -1
- package/docs/config.md +5 -1
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/layers.md +20 -20
- package/docs/ocr.md +14 -5
- package/docs/overview.md +5 -1
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +374 -0
- package/docs/prompts/hands-off-turn.md +30 -0
- package/docs/prompts/voice-advisor.md +31 -0
- package/docs/prompts/voice-fallback.md +23 -0
- package/docs/tiling-reference.md +167 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice.md +219 -0
- package/package.json +29 -11
- package/bin/client.js +0 -4
- package/bin/lattices-app.js +0 -221
|
@@ -78,7 +78,7 @@ class WorkspaceManager: ObservableObject {
|
|
|
78
78
|
|
|
79
79
|
private let configPath: String
|
|
80
80
|
private let gridConfigPath: String
|
|
81
|
-
private
|
|
81
|
+
private var tmuxPath: String { TmuxQuery.resolvedPath ?? "/opt/homebrew/bin/tmux" }
|
|
82
82
|
private let activeLayerKey = "lattices.activeLayerIndex"
|
|
83
83
|
|
|
84
84
|
init() {
|
|
@@ -173,13 +173,15 @@ class WorkspaceManager: ObservableObject {
|
|
|
173
173
|
|
|
174
174
|
/// Resolve a tile string to fractions: check user presets first, then built-in TilePosition
|
|
175
175
|
func resolveTileFractions(_ tile: String) -> (CGFloat, CGFloat, CGFloat, CGFloat)? {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
resolvePlacement(tile)?.fractions
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func resolvePlacement(_ tile: String) -> PlacementSpec? {
|
|
180
|
+
if let preset = gridPresets[tile],
|
|
181
|
+
let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) {
|
|
182
|
+
return .fractions(placement)
|
|
181
183
|
}
|
|
182
|
-
return
|
|
184
|
+
return PlacementSpec(string: tile)
|
|
183
185
|
}
|
|
184
186
|
|
|
185
187
|
// MARK: - Tab Groups
|
|
@@ -275,7 +277,7 @@ class WorkspaceManager: ObservableObject {
|
|
|
275
277
|
|
|
276
278
|
/// Resolve a session name to a tile target: (wid, pid, frame).
|
|
277
279
|
/// Returns nil if the window isn't tracked or has no tile position.
|
|
278
|
-
private func batchTarget(session: String, position:
|
|
280
|
+
private func batchTarget(session: String, position: PlacementSpec, screen: NSScreen) -> (wid: UInt32, pid: Int32, frame: CGRect)? {
|
|
279
281
|
guard let entry = windowForSession(session) else { return nil }
|
|
280
282
|
let frame = WindowTiler.tileFrame(for: position, on: screen)
|
|
281
283
|
return (entry.wid, entry.pid, frame)
|
|
@@ -309,6 +311,71 @@ class WorkspaceManager: ObservableObject {
|
|
|
309
311
|
return (running, total)
|
|
310
312
|
}
|
|
311
313
|
|
|
314
|
+
// MARK: - Layer Focus (raise only)
|
|
315
|
+
|
|
316
|
+
/// Switch to a layer by raising all its windows in place — no launching, no tiling, no moving.
|
|
317
|
+
/// This is the default hotkey action: just bring the layer's windows to the front.
|
|
318
|
+
func focusLayer(index: Int) {
|
|
319
|
+
guard let config, let layers = config.layers, index < layers.count else { return }
|
|
320
|
+
if index == activeLayerIndex { return }
|
|
321
|
+
|
|
322
|
+
let diag = DiagnosticLog.shared
|
|
323
|
+
let t = diag.startTimed("focusLayer \(activeLayerIndex)→\(index)")
|
|
324
|
+
|
|
325
|
+
DesktopModel.shared.poll()
|
|
326
|
+
|
|
327
|
+
let targetLayer = layers[index]
|
|
328
|
+
var windowsToRaise: [(wid: UInt32, pid: Int32)] = []
|
|
329
|
+
|
|
330
|
+
for lp in targetLayer.projects {
|
|
331
|
+
if let groupId = lp.group, let grp = group(byId: groupId) {
|
|
332
|
+
// Raise all tab windows in the group
|
|
333
|
+
for tab in grp.tabs {
|
|
334
|
+
let sessionName = Self.sessionName(for: tab.path)
|
|
335
|
+
if let entry = windowForSession(sessionName) {
|
|
336
|
+
windowsToRaise.append((entry.wid, entry.pid))
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if let appName = lp.app {
|
|
343
|
+
if let entry = DesktopModel.shared.windowForApp(app: appName, title: lp.title) {
|
|
344
|
+
windowsToRaise.append((entry.wid, entry.pid))
|
|
345
|
+
}
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
guard let path = lp.path else { continue }
|
|
350
|
+
let sessionName = Self.sessionName(for: path)
|
|
351
|
+
if let entry = windowForSession(sessionName) {
|
|
352
|
+
windowsToRaise.append((entry.wid, entry.pid))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Also raise companion windows
|
|
356
|
+
let companions = projectWindows(at: path)
|
|
357
|
+
for cw in companions {
|
|
358
|
+
guard let appName = cw.app else { continue }
|
|
359
|
+
if let entry = DesktopModel.shared.windowForApp(app: appName, title: cw.title) {
|
|
360
|
+
windowsToRaise.append((entry.wid, entry.pid))
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if !windowsToRaise.isEmpty {
|
|
366
|
+
WindowTiler.raiseWindowsAndReactivate(windows: windowsToRaise)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
activeLayerIndex = index
|
|
370
|
+
UserDefaults.standard.set(index, forKey: activeLayerKey)
|
|
371
|
+
|
|
372
|
+
let allLabels = layers.map(\.label)
|
|
373
|
+
LayerBezel.shared.show(label: targetLayer.label, index: index, total: layers.count, allLabels: allLabels)
|
|
374
|
+
HandsOffSession.shared.playCachedCue("Switched.")
|
|
375
|
+
|
|
376
|
+
diag.finish(t)
|
|
377
|
+
}
|
|
378
|
+
|
|
312
379
|
// MARK: - Unified Layer Tiling
|
|
313
380
|
|
|
314
381
|
/// Unified entry point for arranging a layer's windows.
|
|
@@ -332,14 +399,17 @@ class WorkspaceManager: ObservableObject {
|
|
|
332
399
|
let scanner = ProjectScanner.shared
|
|
333
400
|
let targetLayer = layers[index]
|
|
334
401
|
|
|
402
|
+
// Fresh poll so we see windows on all Spaces before matching
|
|
403
|
+
DesktopModel.shared.poll()
|
|
404
|
+
|
|
335
405
|
// Tile debug log (written to ~/.lattices/tile-debug.log)
|
|
336
406
|
let debugPath = (FileManager.default.homeDirectoryForCurrentUser.path as NSString).appendingPathComponent(".lattices/tile-debug.log")
|
|
337
407
|
var debugLines: [String] = ["tileLayer index=\(index) launch=\(launch) force=\(force) layer=\(targetLayer.id)"]
|
|
338
408
|
|
|
339
409
|
// Phase 1: classify each project
|
|
340
410
|
var batchMoves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
|
|
341
|
-
var fallbacks: [(session: String, position:
|
|
342
|
-
var launchQueue: [(session: String, position:
|
|
411
|
+
var fallbacks: [(session: String, position: PlacementSpec, screen: NSScreen)] = []
|
|
412
|
+
var launchQueue: [(session: String, position: PlacementSpec?, screen: NSScreen, launchAction: () -> Void)] = []
|
|
343
413
|
|
|
344
414
|
// Log screen info
|
|
345
415
|
for (i, s) in NSScreen.screens.enumerated() {
|
|
@@ -351,7 +421,7 @@ class WorkspaceManager: ObservableObject {
|
|
|
351
421
|
|
|
352
422
|
if let groupId = lp.group, let grp = group(byId: groupId) {
|
|
353
423
|
let firstTabSession = grp.tabs.first.map { Self.sessionName(for: $0.path) } ?? ""
|
|
354
|
-
let position = lp.tile.flatMap {
|
|
424
|
+
let position = lp.tile.flatMap { resolvePlacement($0) }
|
|
355
425
|
let groupRunning = isGroupRunning(grp)
|
|
356
426
|
|
|
357
427
|
if groupRunning, let pos = position,
|
|
@@ -373,12 +443,19 @@ class WorkspaceManager: ObservableObject {
|
|
|
373
443
|
|
|
374
444
|
// App-based window matching
|
|
375
445
|
if let appName = lp.app {
|
|
376
|
-
let position = lp.tile.flatMap {
|
|
446
|
+
let position = lp.tile.flatMap { resolvePlacement($0) }
|
|
377
447
|
if let entry = DesktopModel.shared.windowForApp(app: appName, title: lp.title) {
|
|
378
448
|
if let pos = position {
|
|
379
449
|
let frame = WindowTiler.tileFrame(for: pos, on: lpScreen)
|
|
380
450
|
batchMoves.append((entry.wid, entry.pid, frame))
|
|
381
451
|
}
|
|
452
|
+
} else if let found = Self.findAppWindow(app: appName, title: lp.title) {
|
|
453
|
+
// Window exists but wasn't in DesktopModel (e.g. different Space) — tile it
|
|
454
|
+
diag.info(" found app via CGWindowList fallback: \(appName) wid=\(found.wid)")
|
|
455
|
+
if let pos = position {
|
|
456
|
+
let frame = WindowTiler.tileFrame(for: pos, on: lpScreen)
|
|
457
|
+
batchMoves.append((found.wid, found.pid, frame))
|
|
458
|
+
}
|
|
382
459
|
} else if launch {
|
|
383
460
|
diag.info(" launch app: \(appName)")
|
|
384
461
|
let capturedLp = lp
|
|
@@ -407,13 +484,13 @@ class WorkspaceManager: ObservableObject {
|
|
|
407
484
|
guard let path = lp.path else { continue }
|
|
408
485
|
let sessionName = Self.sessionName(for: path)
|
|
409
486
|
let project = scanner.projects.first(where: { $0.path == path })
|
|
410
|
-
let position = lp.tile.flatMap {
|
|
487
|
+
let position = lp.tile.flatMap { resolvePlacement($0) }
|
|
411
488
|
// Check scanner first, fall back to direct tmux check for projects without .lattices.json
|
|
412
489
|
let isRunning = project?.isRunning == true || shell([tmuxPath, "has-session", "-t", sessionName]) == 0
|
|
413
490
|
|
|
414
491
|
if isRunning {
|
|
415
492
|
let foundWindow = windowForSession(sessionName)
|
|
416
|
-
let msg = " \(sessionName): running=\(isRunning) window=\(foundWindow?.wid ?? 0) tile=\(position?.
|
|
493
|
+
let msg = " \(sessionName): running=\(isRunning) window=\(foundWindow?.wid ?? 0) tile=\(position?.wireValue ?? "nil") desktopCount=\(DesktopModel.shared.windows.count)"
|
|
417
494
|
diag.info(msg)
|
|
418
495
|
debugLines.append(msg)
|
|
419
496
|
if let pos = position,
|
|
@@ -422,7 +499,7 @@ class WorkspaceManager: ObservableObject {
|
|
|
422
499
|
debugLines.append(" → batch move wid=\(target.wid) frame=\(target.frame)")
|
|
423
500
|
} else if let pos = position {
|
|
424
501
|
fallbacks.append((sessionName, pos, lpScreen))
|
|
425
|
-
debugLines.append(" → fallback \(pos.
|
|
502
|
+
debugLines.append(" → fallback \(pos.wireValue)")
|
|
426
503
|
}
|
|
427
504
|
} else if launch {
|
|
428
505
|
if let project {
|
|
@@ -443,7 +520,7 @@ class WorkspaceManager: ObservableObject {
|
|
|
443
520
|
for cw in companions {
|
|
444
521
|
guard let appName = cw.app else { continue }
|
|
445
522
|
let cwScreen = screen(for: cw.display ?? lp.display) ?? lpScreen
|
|
446
|
-
let cwPosition = cw.tile.flatMap {
|
|
523
|
+
let cwPosition = cw.tile.flatMap { resolvePlacement($0) }
|
|
447
524
|
if let entry = DesktopModel.shared.windowForApp(app: appName, title: cw.title) {
|
|
448
525
|
if let pos = cwPosition {
|
|
449
526
|
let frame = WindowTiler.tileFrame(for: pos, on: cwScreen)
|
|
@@ -486,7 +563,7 @@ class WorkspaceManager: ObservableObject {
|
|
|
486
563
|
for (i, fb) in fallbacks.enumerated() {
|
|
487
564
|
let delay = Double(i) * 0.15 + 0.1
|
|
488
565
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
489
|
-
diag.info(" tile fallback: \(fb.session) → \(fb.position.
|
|
566
|
+
diag.info(" tile fallback: \(fb.session) → \(fb.position.wireValue)")
|
|
490
567
|
WindowTiler.navigateToWindow(session: fb.session, terminal: terminal)
|
|
491
568
|
WindowTiler.tile(session: fb.session, terminal: terminal, to: fb.position, on: fb.screen)
|
|
492
569
|
}
|
|
@@ -498,7 +575,7 @@ class WorkspaceManager: ObservableObject {
|
|
|
498
575
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
499
576
|
item.launchAction()
|
|
500
577
|
if let pos = item.position {
|
|
501
|
-
let t = diag.startTimed("tile launched: \(item.session) → \(pos.
|
|
578
|
+
let t = diag.startTimed("tile launched: \(item.session) → \(pos.wireValue)")
|
|
502
579
|
WindowTiler.tile(session: item.session, terminal: terminal, to: pos, on: item.screen)
|
|
503
580
|
diag.finish(t)
|
|
504
581
|
}
|
|
@@ -558,6 +635,38 @@ class WorkspaceManager: ObservableObject {
|
|
|
558
635
|
}
|
|
559
636
|
}
|
|
560
637
|
|
|
638
|
+
// MARK: - App Window Fallback (CGWindowList .optionAll)
|
|
639
|
+
|
|
640
|
+
/// Find an app window across ALL Spaces via CGWindowList (bypasses DesktopModel cache)
|
|
641
|
+
static func findAppWindow(app: String, title: String?) -> (wid: UInt32, pid: Int32)? {
|
|
642
|
+
guard let list = CGWindowListCopyWindowInfo(
|
|
643
|
+
[.optionAll, .excludeDesktopElements],
|
|
644
|
+
kCGNullWindowID
|
|
645
|
+
) as? [[String: Any]] else { return nil }
|
|
646
|
+
|
|
647
|
+
for info in list {
|
|
648
|
+
guard let ownerName = info[kCGWindowOwnerName as String] as? String,
|
|
649
|
+
ownerName.localizedCaseInsensitiveContains(app),
|
|
650
|
+
let wid = info[kCGWindowNumber as String] as? UInt32,
|
|
651
|
+
let pid = info[kCGWindowOwnerPID as String] as? Int32,
|
|
652
|
+
let layer = info[kCGWindowLayer as String] as? Int, layer == 0,
|
|
653
|
+
let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
|
|
654
|
+
else { continue }
|
|
655
|
+
|
|
656
|
+
var rect = CGRect.zero
|
|
657
|
+
guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
|
|
658
|
+
rect.width >= 50, rect.height >= 50 else { continue }
|
|
659
|
+
|
|
660
|
+
if let title {
|
|
661
|
+
let windowTitle = info[kCGWindowName as String] as? String ?? ""
|
|
662
|
+
guard windowTitle.localizedCaseInsensitiveContains(title) else { continue }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return (wid, pid)
|
|
666
|
+
}
|
|
667
|
+
return nil
|
|
668
|
+
}
|
|
669
|
+
|
|
561
670
|
// MARK: - Session Name Helper
|
|
562
671
|
|
|
563
672
|
/// Replicates Project.sessionName logic from a bare path
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
import AppKit
|
|
4
|
+
|
|
5
|
+
/// Attempt to create stages by simulating drag gestures from the strip.
|
|
6
|
+
///
|
|
7
|
+
/// When a user drags a strip thumbnail into the center stage, SM joins them.
|
|
8
|
+
/// Can we replicate this with synthetic mouse events?
|
|
9
|
+
final class StageDragTests: XCTestCase {
|
|
10
|
+
|
|
11
|
+
// MARK: - Helpers
|
|
12
|
+
|
|
13
|
+
struct LiveWindow {
|
|
14
|
+
let wid: UInt32
|
|
15
|
+
let app: String
|
|
16
|
+
let pid: Int32
|
|
17
|
+
let title: String
|
|
18
|
+
let bounds: CGRect
|
|
19
|
+
let isOnScreen: Bool
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func getRealWindows() -> [LiveWindow] {
|
|
23
|
+
guard let list = CGWindowListCopyWindowInfo(
|
|
24
|
+
[.optionAll, .excludeDesktopElements],
|
|
25
|
+
kCGNullWindowID
|
|
26
|
+
) as? [[String: Any]] else { return [] }
|
|
27
|
+
|
|
28
|
+
let skip: Set<String> = [
|
|
29
|
+
"Window Server", "Dock", "Control Center", "SystemUIServer",
|
|
30
|
+
"Notification Center", "Spotlight", "WindowManager", "Lattices",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
return list.compactMap { info in
|
|
34
|
+
guard let wid = info[kCGWindowNumber as String] as? UInt32,
|
|
35
|
+
let owner = info[kCGWindowOwnerName as String] as? String,
|
|
36
|
+
let pid = info[kCGWindowOwnerPID as String] as? Int32,
|
|
37
|
+
let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
|
|
38
|
+
else { return nil }
|
|
39
|
+
|
|
40
|
+
var rect = CGRect.zero
|
|
41
|
+
guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { return nil }
|
|
42
|
+
let title = info[kCGWindowName as String] as? String ?? ""
|
|
43
|
+
let layer = info[kCGWindowLayer as String] as? Int ?? 0
|
|
44
|
+
let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? false
|
|
45
|
+
|
|
46
|
+
guard layer == 0, rect.width >= 50, rect.height >= 50 else { return nil }
|
|
47
|
+
guard !skip.contains(owner) else { return nil }
|
|
48
|
+
|
|
49
|
+
return LiveWindow(wid: wid, app: owner, pid: pid, title: title,
|
|
50
|
+
bounds: rect, isOnScreen: isOnScreen)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Find strip thumbnails (small onscreen windows on the left edge)
|
|
55
|
+
func getStripThumbnails() -> [LiveWindow] {
|
|
56
|
+
getRealWindows().filter {
|
|
57
|
+
$0.isOnScreen && $0.bounds.width < 250 && $0.bounds.height < 250
|
|
58
|
+
&& $0.bounds.origin.x < 220 && $0.bounds.origin.x >= 0
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Find active stage windows (large onscreen windows)
|
|
63
|
+
func getActiveStage() -> [LiveWindow] {
|
|
64
|
+
getRealWindows().filter { $0.isOnScreen && $0.bounds.width > 250 }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func printStageState(label: String) {
|
|
68
|
+
let active = getActiveStage()
|
|
69
|
+
let strip = getStripThumbnails()
|
|
70
|
+
print("\n[\(label)]")
|
|
71
|
+
print(" Active: \(active.map { "\($0.app)(\($0.wid))" }.joined(separator: ", "))")
|
|
72
|
+
print(" Strip: \(Set(strip.map(\.app)).sorted().joined(separator: ", "))")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// MARK: - Synthetic mouse event helpers
|
|
76
|
+
|
|
77
|
+
/// Post a mouse event at a screen coordinate
|
|
78
|
+
func postMouse(_ type: CGEventType, at point: CGPoint, button: CGMouseButton = .left) {
|
|
79
|
+
guard let event = CGEvent(
|
|
80
|
+
mouseEventSource: nil,
|
|
81
|
+
mouseType: type,
|
|
82
|
+
mouseCursorPosition: point,
|
|
83
|
+
mouseButton: button
|
|
84
|
+
) else { return }
|
|
85
|
+
event.post(tap: .cghidEventTap)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Simulate a smooth drag from point A to point B
|
|
89
|
+
func simulateDrag(from start: CGPoint, to end: CGPoint, steps: Int = 30, duration: TimeInterval = 0.4) {
|
|
90
|
+
// Mouse down at start
|
|
91
|
+
postMouse(.leftMouseDown, at: start)
|
|
92
|
+
Thread.sleep(forTimeInterval: 0.05)
|
|
93
|
+
|
|
94
|
+
// Interpolate drag path
|
|
95
|
+
for i in 1...steps {
|
|
96
|
+
let t = CGFloat(i) / CGFloat(steps)
|
|
97
|
+
let x = start.x + (end.x - start.x) * t
|
|
98
|
+
let y = start.y + (end.y - start.y) * t
|
|
99
|
+
postMouse(.leftMouseDragged, at: CGPoint(x: x, y: y))
|
|
100
|
+
Thread.sleep(forTimeInterval: duration / TimeInterval(steps))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Mouse up at end
|
|
104
|
+
postMouse(.leftMouseUp, at: end)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// MARK: - Approach 5: Drag strip thumbnail to center
|
|
108
|
+
|
|
109
|
+
func testJoinByDragFromStrip() throws {
|
|
110
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
111
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
112
|
+
|
|
113
|
+
let thumbnails = getStripThumbnails()
|
|
114
|
+
let active = getActiveStage()
|
|
115
|
+
|
|
116
|
+
guard !thumbnails.isEmpty else {
|
|
117
|
+
print("No strip thumbnails found")
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
guard let anchor = active.first else {
|
|
121
|
+
print("No active stage window")
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Target specific apps: Chrome, iTerm2, Vox
|
|
126
|
+
let activeApps = Set(active.map(\.app))
|
|
127
|
+
let preferred = ["Google Chrome", "iTerm2", "Vox"]
|
|
128
|
+
guard let thumb = thumbnails.first(where: { preferred.contains($0.app) && !activeApps.contains($0.app) })
|
|
129
|
+
?? thumbnails.first(where: { !activeApps.contains($0.app) }) else {
|
|
130
|
+
print("No suitable strip thumbnail found")
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
|
|
135
|
+
let stageCenter = CGPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
|
|
136
|
+
|
|
137
|
+
print("Dragging \(thumb.app) thumbnail from strip (\(Int(thumbCenter.x)),\(Int(thumbCenter.y))) to center (\(Int(stageCenter.x)),\(Int(stageCenter.y)))")
|
|
138
|
+
printStageState(label: "BEFORE")
|
|
139
|
+
|
|
140
|
+
simulateDrag(from: thumbCenter, to: stageCenter, steps: 40, duration: 0.6)
|
|
141
|
+
|
|
142
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
143
|
+
printStageState(label: "After drag to center")
|
|
144
|
+
|
|
145
|
+
let finalActive = getActiveStage()
|
|
146
|
+
let finalApps = Set(finalActive.map(\.app))
|
|
147
|
+
let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
|
|
148
|
+
print("\nResult: \(thumb.app) joined stage? \(joined)")
|
|
149
|
+
print("Active apps: \(finalApps.sorted())")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// MARK: - Approach 6: Drag thumbnail to top half (join zone)
|
|
153
|
+
|
|
154
|
+
func testJoinByDragToTopHalf() throws {
|
|
155
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
156
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
157
|
+
|
|
158
|
+
let thumbnails = getStripThumbnails()
|
|
159
|
+
let active = getActiveStage()
|
|
160
|
+
|
|
161
|
+
guard !thumbnails.isEmpty, let anchor = active.first else { return }
|
|
162
|
+
|
|
163
|
+
let activeApps = Set(active.map(\.app))
|
|
164
|
+
let preferred6 = ["Google Chrome", "iTerm2", "Vox"]
|
|
165
|
+
guard let thumb = thumbnails.first(where: { preferred6.contains($0.app) && !activeApps.contains($0.app) })
|
|
166
|
+
?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
|
|
167
|
+
|
|
168
|
+
// SM has specific drop zones. Try dragging to the top half of the active
|
|
169
|
+
// stage area — this might be the "join" zone vs "replace" zone.
|
|
170
|
+
let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
|
|
171
|
+
let topHalf = CGPoint(x: anchor.bounds.midX, y: anchor.bounds.origin.y + 100)
|
|
172
|
+
|
|
173
|
+
print("Dragging \(thumb.app) to top-half of active area (\(Int(topHalf.x)),\(Int(topHalf.y)))")
|
|
174
|
+
printStageState(label: "BEFORE")
|
|
175
|
+
|
|
176
|
+
simulateDrag(from: thumbCenter, to: topHalf, steps: 50, duration: 0.8)
|
|
177
|
+
|
|
178
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
179
|
+
printStageState(label: "After drag to top half")
|
|
180
|
+
|
|
181
|
+
let finalApps = Set(getActiveStage().map(\.app))
|
|
182
|
+
let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
|
|
183
|
+
print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// MARK: - Approach 7: Long press on thumbnail, then drag
|
|
187
|
+
|
|
188
|
+
func testJoinByLongPressDrag() throws {
|
|
189
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
190
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
191
|
+
|
|
192
|
+
let thumbnails = getStripThumbnails()
|
|
193
|
+
let active = getActiveStage()
|
|
194
|
+
|
|
195
|
+
guard !thumbnails.isEmpty, let anchor = active.first else { return }
|
|
196
|
+
|
|
197
|
+
let activeApps = Set(active.map(\.app))
|
|
198
|
+
let preferred7 = ["Google Chrome", "iTerm2", "Vox"]
|
|
199
|
+
guard let thumb = thumbnails.first(where: { preferred7.contains($0.app) && !activeApps.contains($0.app) })
|
|
200
|
+
?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
|
|
201
|
+
|
|
202
|
+
let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
|
|
203
|
+
let stageCenter = CGPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
|
|
204
|
+
|
|
205
|
+
print("Long-press + drag \(thumb.app) from strip to center")
|
|
206
|
+
printStageState(label: "BEFORE")
|
|
207
|
+
|
|
208
|
+
// Long press: mouse down + wait
|
|
209
|
+
postMouse(.leftMouseDown, at: thumbCenter)
|
|
210
|
+
Thread.sleep(forTimeInterval: 0.8) // Hold for long press recognition
|
|
211
|
+
|
|
212
|
+
// Slow drag out of strip area
|
|
213
|
+
let steps = 60
|
|
214
|
+
let duration: TimeInterval = 1.0
|
|
215
|
+
for i in 1...steps {
|
|
216
|
+
let t = CGFloat(i) / CGFloat(steps)
|
|
217
|
+
let x = thumbCenter.x + (stageCenter.x - thumbCenter.x) * t
|
|
218
|
+
let y = thumbCenter.y + (stageCenter.y - thumbCenter.y) * t
|
|
219
|
+
postMouse(.leftMouseDragged, at: CGPoint(x: x, y: y))
|
|
220
|
+
Thread.sleep(forTimeInterval: duration / TimeInterval(steps))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Hold at destination briefly
|
|
224
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
225
|
+
postMouse(.leftMouseUp, at: stageCenter)
|
|
226
|
+
|
|
227
|
+
Thread.sleep(forTimeInterval: 1.0)
|
|
228
|
+
printStageState(label: "After long-press drag")
|
|
229
|
+
|
|
230
|
+
let finalApps = Set(getActiveStage().map(\.app))
|
|
231
|
+
let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
|
|
232
|
+
print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// MARK: - Approach 8: Click thumbnail while holding Option key
|
|
236
|
+
|
|
237
|
+
func testJoinByOptionClick() throws {
|
|
238
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
239
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
240
|
+
|
|
241
|
+
let thumbnails = getStripThumbnails()
|
|
242
|
+
let active = getActiveStage()
|
|
243
|
+
|
|
244
|
+
guard !thumbnails.isEmpty else { return }
|
|
245
|
+
|
|
246
|
+
let activeApps = Set(active.map(\.app))
|
|
247
|
+
let preferred8 = ["Google Chrome", "iTerm2", "Vox"]
|
|
248
|
+
guard let thumb = thumbnails.first(where: { preferred8.contains($0.app) && !activeApps.contains($0.app) })
|
|
249
|
+
?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
|
|
250
|
+
|
|
251
|
+
let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
|
|
252
|
+
|
|
253
|
+
print("Option-clicking \(thumb.app) thumbnail at (\(Int(thumbCenter.x)),\(Int(thumbCenter.y)))")
|
|
254
|
+
printStageState(label: "BEFORE")
|
|
255
|
+
|
|
256
|
+
// Option + click on strip thumbnail
|
|
257
|
+
guard let downEvent = CGEvent(
|
|
258
|
+
mouseEventSource: nil,
|
|
259
|
+
mouseType: .leftMouseDown,
|
|
260
|
+
mouseCursorPosition: thumbCenter,
|
|
261
|
+
mouseButton: .left
|
|
262
|
+
) else { return }
|
|
263
|
+
downEvent.flags = .maskAlternate // Option key
|
|
264
|
+
downEvent.post(tap: .cghidEventTap)
|
|
265
|
+
|
|
266
|
+
Thread.sleep(forTimeInterval: 0.05)
|
|
267
|
+
|
|
268
|
+
guard let upEvent = CGEvent(
|
|
269
|
+
mouseEventSource: nil,
|
|
270
|
+
mouseType: .leftMouseUp,
|
|
271
|
+
mouseCursorPosition: thumbCenter,
|
|
272
|
+
mouseButton: .left
|
|
273
|
+
) else { return }
|
|
274
|
+
upEvent.flags = .maskAlternate
|
|
275
|
+
upEvent.post(tap: .cghidEventTap)
|
|
276
|
+
|
|
277
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
278
|
+
printStageState(label: "After Option-click")
|
|
279
|
+
|
|
280
|
+
let finalApps = Set(getActiveStage().map(\.app))
|
|
281
|
+
let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
|
|
282
|
+
print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// MARK: - Approach 9: Shift-click (common modifier for "add to selection")
|
|
286
|
+
|
|
287
|
+
func testJoinByShiftClick() throws {
|
|
288
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
289
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
290
|
+
|
|
291
|
+
let thumbnails = getStripThumbnails()
|
|
292
|
+
let active = getActiveStage()
|
|
293
|
+
|
|
294
|
+
guard !thumbnails.isEmpty else { return }
|
|
295
|
+
|
|
296
|
+
let activeApps = Set(active.map(\.app))
|
|
297
|
+
let preferred9 = ["Google Chrome", "iTerm2", "Vox"]
|
|
298
|
+
guard let thumb = thumbnails.first(where: { preferred9.contains($0.app) && !activeApps.contains($0.app) })
|
|
299
|
+
?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
|
|
300
|
+
|
|
301
|
+
let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
|
|
302
|
+
|
|
303
|
+
print("Shift-clicking \(thumb.app) thumbnail")
|
|
304
|
+
printStageState(label: "BEFORE")
|
|
305
|
+
|
|
306
|
+
guard let downEvent = CGEvent(
|
|
307
|
+
mouseEventSource: nil,
|
|
308
|
+
mouseType: .leftMouseDown,
|
|
309
|
+
mouseCursorPosition: thumbCenter,
|
|
310
|
+
mouseButton: .left
|
|
311
|
+
) else { return }
|
|
312
|
+
downEvent.flags = .maskShift
|
|
313
|
+
downEvent.post(tap: .cghidEventTap)
|
|
314
|
+
|
|
315
|
+
Thread.sleep(forTimeInterval: 0.05)
|
|
316
|
+
|
|
317
|
+
guard let upEvent = CGEvent(
|
|
318
|
+
mouseEventSource: nil,
|
|
319
|
+
mouseType: .leftMouseUp,
|
|
320
|
+
mouseCursorPosition: thumbCenter,
|
|
321
|
+
mouseButton: .left
|
|
322
|
+
) else { return }
|
|
323
|
+
upEvent.flags = .maskShift
|
|
324
|
+
upEvent.post(tap: .cghidEventTap)
|
|
325
|
+
|
|
326
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
327
|
+
printStageState(label: "After Shift-click")
|
|
328
|
+
|
|
329
|
+
let finalApps = Set(getActiveStage().map(\.app))
|
|
330
|
+
let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
|
|
331
|
+
print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
|
|
332
|
+
}
|
|
333
|
+
}
|