@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.
Files changed (74) hide show
  1. package/README.md +155 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/AccessibilityTextExtractor.swift +111 -0
  5. package/app/Sources/ActionRow.swift +61 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +242 -0
  8. package/app/Sources/AppShellView.swift +62 -0
  9. package/app/Sources/AppTypeClassifier.swift +70 -0
  10. package/app/Sources/AppWindowShell.swift +63 -0
  11. package/app/Sources/CheatSheetHUD.swift +332 -0
  12. package/app/Sources/CommandModeState.swift +1362 -0
  13. package/app/Sources/CommandModeView.swift +1405 -0
  14. package/app/Sources/CommandModeWindow.swift +192 -0
  15. package/app/Sources/CommandPaletteView.swift +307 -0
  16. package/app/Sources/CommandPaletteWindow.swift +134 -0
  17. package/app/Sources/DaemonProtocol.swift +101 -0
  18. package/app/Sources/DaemonServer.swift +414 -0
  19. package/app/Sources/DesktopModel.swift +149 -0
  20. package/app/Sources/DesktopModelTypes.swift +71 -0
  21. package/app/Sources/DiagnosticLog.swift +271 -0
  22. package/app/Sources/EventBus.swift +30 -0
  23. package/app/Sources/HotkeyManager.swift +254 -0
  24. package/app/Sources/HotkeyStore.swift +338 -0
  25. package/app/Sources/InventoryManager.swift +35 -0
  26. package/app/Sources/InventoryPath.swift +43 -0
  27. package/app/Sources/KeyRecorderView.swift +210 -0
  28. package/app/Sources/LatticesApi.swift +1234 -0
  29. package/app/Sources/LayerBezel.swift +203 -0
  30. package/app/Sources/MainView.swift +479 -0
  31. package/app/Sources/MainWindow.swift +83 -0
  32. package/app/Sources/OcrModel.swift +430 -0
  33. package/app/Sources/OcrStore.swift +329 -0
  34. package/app/Sources/OmniSearchState.swift +283 -0
  35. package/app/Sources/OmniSearchView.swift +288 -0
  36. package/app/Sources/OmniSearchWindow.swift +105 -0
  37. package/app/Sources/OrphanRow.swift +129 -0
  38. package/app/Sources/PaletteCommand.swift +419 -0
  39. package/app/Sources/PermissionChecker.swift +125 -0
  40. package/app/Sources/Preferences.swift +99 -0
  41. package/app/Sources/ProcessModel.swift +199 -0
  42. package/app/Sources/ProcessQuery.swift +151 -0
  43. package/app/Sources/Project.swift +28 -0
  44. package/app/Sources/ProjectRow.swift +368 -0
  45. package/app/Sources/ProjectScanner.swift +128 -0
  46. package/app/Sources/ScreenMapState.swift +2387 -0
  47. package/app/Sources/ScreenMapView.swift +2820 -0
  48. package/app/Sources/ScreenMapWindowController.swift +89 -0
  49. package/app/Sources/SessionManager.swift +72 -0
  50. package/app/Sources/SettingsView.swift +1064 -0
  51. package/app/Sources/SettingsWindow.swift +20 -0
  52. package/app/Sources/TabGroupRow.swift +178 -0
  53. package/app/Sources/Terminal.swift +259 -0
  54. package/app/Sources/TerminalQuery.swift +156 -0
  55. package/app/Sources/TerminalSynthesizer.swift +200 -0
  56. package/app/Sources/Theme.swift +163 -0
  57. package/app/Sources/TilePickerView.swift +209 -0
  58. package/app/Sources/TmuxModel.swift +53 -0
  59. package/app/Sources/TmuxQuery.swift +81 -0
  60. package/app/Sources/WindowTiler.swift +1778 -0
  61. package/app/Sources/WorkspaceManager.swift +575 -0
  62. package/bin/client.js +4 -0
  63. package/bin/daemon-client.js +187 -0
  64. package/bin/lattices-app.js +221 -0
  65. package/bin/lattices.js +1551 -0
  66. package/docs/api.md +924 -0
  67. package/docs/app.md +297 -0
  68. package/docs/concepts.md +135 -0
  69. package/docs/config.md +245 -0
  70. package/docs/layers.md +410 -0
  71. package/docs/ocr.md +185 -0
  72. package/docs/overview.md +94 -0
  73. package/docs/quickstart.md +75 -0
  74. package/package.json +42 -0
@@ -0,0 +1,575 @@
1
+ import AppKit
2
+ import CryptoKit
3
+ import Foundation
4
+
5
+ // MARK: - Data Model
6
+
7
+ struct TabGroupTab: Codable {
8
+ let path: String
9
+ let label: String?
10
+ }
11
+
12
+ struct TabGroup: Codable, Identifiable {
13
+ let id: String
14
+ let label: String
15
+ let tabs: [TabGroupTab]
16
+ }
17
+
18
+ struct LayerProject: Codable {
19
+ let path: String?
20
+ let group: String?
21
+ let tile: String?
22
+ let display: Int?
23
+ let app: String? // match by owner app name (e.g. "Google Chrome", "Xcode")
24
+ let title: String? // substring match on window title (case-insensitive)
25
+ let url: String? // URL to open if no matching window found
26
+ let launch: String? // app name to launch if not running (via `open -a`)
27
+ }
28
+
29
+ struct Layer: Codable, Identifiable {
30
+ let id: String
31
+ let label: String
32
+ let projects: [LayerProject]
33
+ }
34
+
35
+ struct WorkspaceConfig: Codable {
36
+ let name: String
37
+ let groups: [TabGroup]?
38
+ let layers: [Layer]?
39
+ }
40
+
41
+ // MARK: - Grid Presets & Named Layouts
42
+
43
+ struct GridPreset: Codable {
44
+ let x: CGFloat
45
+ let y: CGFloat
46
+ let w: CGFloat
47
+ let h: CGFloat
48
+
49
+ var fractions: (CGFloat, CGFloat, CGFloat, CGFloat) { (x, y, w, h) }
50
+ }
51
+
52
+ struct LayoutWindowSpec: Codable {
53
+ let app: String
54
+ let tile: String // TilePosition name or preset name
55
+ let display: Int? // spatial display number (1-based), nil = current
56
+ let title: String? // optional title match for disambiguation
57
+ }
58
+
59
+ struct LayoutConfig: Codable {
60
+ let windows: [LayoutWindowSpec]
61
+ }
62
+
63
+ struct GridFile: Codable {
64
+ let presets: [String: GridPreset]?
65
+ let layouts: [String: LayoutConfig]?
66
+ }
67
+
68
+ // MARK: - Manager
69
+
70
+ class WorkspaceManager: ObservableObject {
71
+ static let shared = WorkspaceManager()
72
+
73
+ @Published var config: WorkspaceConfig?
74
+ @Published var activeLayerIndex: Int = 0
75
+ @Published var isSwitching: Bool = false
76
+ @Published var gridPresets: [String: GridPreset] = [:]
77
+ @Published var gridLayouts: [String: LayoutConfig] = [:]
78
+
79
+ private let configPath: String
80
+ private let gridConfigPath: String
81
+ private let tmuxPath = "/opt/homebrew/bin/tmux"
82
+ private let activeLayerKey = "lattices.activeLayerIndex"
83
+
84
+ init() {
85
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
86
+ self.configPath = (home as NSString).appendingPathComponent(".lattices/workspace.json")
87
+ self.gridConfigPath = (home as NSString).appendingPathComponent(".lattices/grid.json")
88
+ self.activeLayerIndex = UserDefaults.standard.integer(forKey: activeLayerKey)
89
+ loadConfig()
90
+ loadGridConfig()
91
+ }
92
+
93
+ var activeLayer: Layer? {
94
+ guard let config, let layers = config.layers, activeLayerIndex < layers.count else { return nil }
95
+ return layers[activeLayerIndex]
96
+ }
97
+
98
+ /// Look up a layer index by id or label (case-insensitive)
99
+ func layerIndex(named name: String) -> Int? {
100
+ guard let layers = config?.layers else { return nil }
101
+ // Try exact id match first
102
+ if let i = layers.firstIndex(where: { $0.id == name }) { return i }
103
+ // Then case-insensitive id
104
+ if let i = layers.firstIndex(where: { $0.id.localizedCaseInsensitiveCompare(name) == .orderedSame }) { return i }
105
+ // Then case-insensitive label
106
+ if let i = layers.firstIndex(where: { $0.label.localizedCaseInsensitiveCompare(name) == .orderedSame }) { return i }
107
+ return nil
108
+ }
109
+
110
+ // MARK: - Config I/O
111
+
112
+ func loadConfig() {
113
+ guard FileManager.default.fileExists(atPath: configPath),
114
+ let data = FileManager.default.contents(atPath: configPath) else {
115
+ config = nil
116
+ return
117
+ }
118
+ do {
119
+ config = try JSONDecoder().decode(WorkspaceConfig.self, from: data)
120
+ // Clamp saved index
121
+ if let config, let layers = config.layers, activeLayerIndex >= layers.count {
122
+ activeLayerIndex = 0
123
+ }
124
+ } catch {
125
+ DiagnosticLog.shared.error("WorkspaceManager: failed to decode workspace.json — \(error.localizedDescription)")
126
+ config = nil
127
+ }
128
+ }
129
+
130
+ func reloadConfig() {
131
+ loadConfig()
132
+ loadGridConfig()
133
+ }
134
+
135
+ // MARK: - Grid Config I/O
136
+
137
+ func loadGridConfig() {
138
+ var presets: [String: GridPreset] = [:]
139
+ var layouts: [String: LayoutConfig] = [:]
140
+
141
+ // Load global ~/.lattices/grid.json
142
+ if FileManager.default.fileExists(atPath: gridConfigPath),
143
+ let data = FileManager.default.contents(atPath: gridConfigPath) {
144
+ do {
145
+ let gridFile = try JSONDecoder().decode(GridFile.self, from: data)
146
+ if let p = gridFile.presets { presets.merge(p) { _, new in new } }
147
+ if let l = gridFile.layouts { layouts.merge(l) { _, new in new } }
148
+ } catch {
149
+ DiagnosticLog.shared.error("WorkspaceManager: failed to decode grid.json — \(error.localizedDescription)")
150
+ }
151
+ }
152
+
153
+ // Merge per-project .lattices.json "grid" section on top
154
+ let projectGridPath = ".lattices.json"
155
+ if FileManager.default.fileExists(atPath: projectGridPath),
156
+ let data = FileManager.default.contents(atPath: projectGridPath) {
157
+ do {
158
+ if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
159
+ let gridDict = json["grid"] {
160
+ let gridData = try JSONSerialization.data(withJSONObject: gridDict)
161
+ let gridFile = try JSONDecoder().decode(GridFile.self, from: gridData)
162
+ if let p = gridFile.presets { presets.merge(p) { _, new in new } }
163
+ if let l = gridFile.layouts { layouts.merge(l) { _, new in new } }
164
+ }
165
+ } catch {
166
+ DiagnosticLog.shared.error("WorkspaceManager: failed to decode .lattices.json grid — \(error.localizedDescription)")
167
+ }
168
+ }
169
+
170
+ self.gridPresets = presets
171
+ self.gridLayouts = layouts
172
+ }
173
+
174
+ /// Resolve a tile string to fractions: check user presets first, then built-in TilePosition
175
+ func resolveTileFractions(_ tile: String) -> (CGFloat, CGFloat, CGFloat, CGFloat)? {
176
+ if let preset = gridPresets[tile] {
177
+ return preset.fractions
178
+ }
179
+ if let position = TilePosition(rawValue: tile) {
180
+ return position.rect
181
+ }
182
+ return nil
183
+ }
184
+
185
+ // MARK: - Tab Groups
186
+
187
+ func group(byId id: String) -> TabGroup? {
188
+ config?.groups?.first(where: { $0.id == id })
189
+ }
190
+
191
+ func isGroupRunning(_ group: TabGroup) -> Bool {
192
+ group.tabs.contains { tab in
193
+ let name = Self.sessionName(for: tab.path)
194
+ return shell([tmuxPath, "has-session", "-t", name]) == 0
195
+ }
196
+ }
197
+
198
+ /// Count how many tabs in the group have running sessions
199
+ func runningTabCount(_ group: TabGroup) -> Int {
200
+ group.tabs.filter { tab in
201
+ let name = Self.sessionName(for: tab.path)
202
+ return shell([tmuxPath, "has-session", "-t", name]) == 0
203
+ }.count
204
+ }
205
+
206
+ /// Launch a group by opening each tab as a separate iTerm/Terminal tab
207
+ func launchGroup(_ group: TabGroup) {
208
+ let terminal = Preferences.shared.terminal
209
+ for (i, tab) in group.tabs.enumerated() {
210
+ let label = tab.label ?? (tab.path as NSString).lastPathComponent
211
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.4) {
212
+ if i == 0 {
213
+ terminal.launch(command: "/opt/homebrew/bin/lattices", in: tab.path)
214
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
215
+ terminal.nameTab(label)
216
+ }
217
+ } else {
218
+ terminal.launchTab(command: "/opt/homebrew/bin/lattices", in: tab.path, tabName: label)
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ /// Kill all individual tab sessions for a group
225
+ func killGroup(_ group: TabGroup) {
226
+ for tab in group.tabs {
227
+ let name = Self.sessionName(for: tab.path)
228
+ let task = Process()
229
+ task.executableURL = URL(fileURLWithPath: tmuxPath)
230
+ task.arguments = ["kill-session", "-t", name]
231
+ task.standardOutput = FileHandle.nullDevice
232
+ task.standardError = FileHandle.nullDevice
233
+ try? task.run()
234
+ task.waitUntilExit()
235
+ }
236
+ }
237
+
238
+ /// Focus a specific tab's session in the terminal
239
+ func focusTab(group: TabGroup, tabIndex: Int) {
240
+ guard tabIndex >= 0, tabIndex < group.tabs.count else { return }
241
+ let tab = group.tabs[tabIndex]
242
+ let sessionName = Self.sessionName(for: tab.path)
243
+ let terminal = Preferences.shared.terminal
244
+ terminal.focusOrAttach(session: sessionName)
245
+ }
246
+
247
+ /// Run a command and return exit code
248
+ private func shell(_ args: [String]) -> Int32 {
249
+ let task = Process()
250
+ task.executableURL = URL(fileURLWithPath: args[0])
251
+ task.arguments = Array(args.dropFirst())
252
+ task.standardOutput = FileHandle.nullDevice
253
+ task.standardError = FileHandle.nullDevice
254
+ try? task.run()
255
+ task.waitUntilExit()
256
+ return task.terminationStatus
257
+ }
258
+
259
+ // MARK: - Display Helper
260
+
261
+ /// Resolve a display index to an NSScreen (falls back to first screen)
262
+ private func screen(for displayIndex: Int?) -> NSScreen? {
263
+ let screens = NSScreen.screens
264
+ guard !screens.isEmpty else { return nil }
265
+ let idx = displayIndex ?? 0
266
+ return idx < screens.count ? screens[idx] : screens[0]
267
+ }
268
+
269
+ // MARK: - Window Lookup
270
+
271
+ /// Find a tracked window for a session name (instant — uses DesktopModel cache)
272
+ private func windowForSession(_ sessionName: String) -> WindowEntry? {
273
+ DesktopModel.shared.windowForSession(sessionName)
274
+ }
275
+
276
+ /// Resolve a session name to a tile target: (wid, pid, frame).
277
+ /// Returns nil if the window isn't tracked or has no tile position.
278
+ private func batchTarget(session: String, position: TilePosition, screen: NSScreen) -> (wid: UInt32, pid: Int32, frame: CGRect)? {
279
+ guard let entry = windowForSession(session) else { return nil }
280
+ let frame = WindowTiler.tileFrame(for: position, on: screen)
281
+ return (entry.wid, entry.pid, frame)
282
+ }
283
+
284
+ // MARK: - Tiling
285
+
286
+ /// Re-tile the current layer without switching (for "tile all")
287
+ func retileCurrentLayer() {
288
+ tileLayer(index: activeLayerIndex, launch: false, force: true)
289
+ }
290
+
291
+ /// Count running projects+groups in a layer
292
+ func layerRunningCount(index: Int) -> (running: Int, total: Int) {
293
+ guard let config, let layers = config.layers, index < layers.count else { return (0, 0) }
294
+ let layer = layers[index]
295
+ let scanner = ProjectScanner.shared
296
+ var running = 0
297
+ let total = layer.projects.count
298
+
299
+ for lp in layer.projects {
300
+ if let groupId = lp.group, let group = group(byId: groupId) {
301
+ if isGroupRunning(group) { running += 1 }
302
+ } else if let appName = lp.app {
303
+ if DesktopModel.shared.windowForApp(app: appName, title: lp.title) != nil { running += 1 }
304
+ } else if let path = lp.path {
305
+ let project = scanner.projects.first(where: { $0.path == path })
306
+ if project?.isRunning == true { running += 1 }
307
+ }
308
+ }
309
+ return (running, total)
310
+ }
311
+
312
+ // MARK: - Unified Layer Tiling
313
+
314
+ /// Unified entry point for arranging a layer's windows.
315
+ ///
316
+ /// | launch | force | Behavior |
317
+ /// |--------|-------|----------|
318
+ /// | false | false | Tile running projects only (focus) |
319
+ /// | true | false | Launch stopped + tile all, skip if same layer |
320
+ /// | true | true | Re-launch current layer |
321
+ /// | false | true | Re-tile current layer |
322
+ func tileLayer(index: Int, launch: Bool = false, force: Bool = false) {
323
+ guard let config, let layers = config.layers, index < layers.count else { return }
324
+ if launch && !force && index == activeLayerIndex { return }
325
+
326
+ let diag = DiagnosticLog.shared
327
+ let label = launch ? "tileLayer(launch)" : "tileLayer(focus)"
328
+ let overall = diag.startTimed("\(label) \(activeLayerIndex)→\(index)")
329
+
330
+ isSwitching = true
331
+ let terminal = Preferences.shared.terminal
332
+ let scanner = ProjectScanner.shared
333
+ let targetLayer = layers[index]
334
+
335
+ // Tile debug log (written to ~/.lattices/tile-debug.log)
336
+ let debugPath = (FileManager.default.homeDirectoryForCurrentUser.path as NSString).appendingPathComponent(".lattices/tile-debug.log")
337
+ var debugLines: [String] = ["tileLayer index=\(index) launch=\(launch) force=\(force) layer=\(targetLayer.id)"]
338
+
339
+ // Phase 1: classify each project
340
+ var batchMoves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
341
+ var fallbacks: [(session: String, position: TilePosition, screen: NSScreen)] = []
342
+ var launchQueue: [(session: String, position: TilePosition?, screen: NSScreen, launchAction: () -> Void)] = []
343
+
344
+ // Log screen info
345
+ for (i, s) in NSScreen.screens.enumerated() {
346
+ debugLines.append("screen[\(i)]: frame=\(s.frame) visible=\(s.visibleFrame)")
347
+ }
348
+
349
+ for lp in targetLayer.projects {
350
+ guard let lpScreen = screen(for: lp.display) else { continue }
351
+
352
+ if let groupId = lp.group, let grp = group(byId: groupId) {
353
+ let firstTabSession = grp.tabs.first.map { Self.sessionName(for: $0.path) } ?? ""
354
+ let position = lp.tile.flatMap { TilePosition(rawValue: $0) }
355
+ let groupRunning = isGroupRunning(grp)
356
+
357
+ if groupRunning, let pos = position,
358
+ let target = batchTarget(session: firstTabSession, position: pos, screen: lpScreen) {
359
+ batchMoves.append(target)
360
+ } else if !groupRunning && launch {
361
+ diag.info(" launch group: \(grp.label)")
362
+ launchQueue.append((firstTabSession, position, lpScreen, { [weak self] in
363
+ self?.launchGroup(grp)
364
+ }))
365
+ } else if groupRunning, let pos = position {
366
+ // Running but not in DesktopModel — fallback
367
+ fallbacks.append((firstTabSession, pos, lpScreen))
368
+ } else if !groupRunning {
369
+ diag.info(" skip (not running): \(grp.label)")
370
+ }
371
+ continue
372
+ }
373
+
374
+ // App-based window matching
375
+ if let appName = lp.app {
376
+ let position = lp.tile.flatMap { TilePosition(rawValue: $0) }
377
+ if let entry = DesktopModel.shared.windowForApp(app: appName, title: lp.title) {
378
+ if let pos = position {
379
+ let frame = WindowTiler.tileFrame(for: pos, on: lpScreen)
380
+ batchMoves.append((entry.wid, entry.pid, frame))
381
+ }
382
+ } else if launch {
383
+ diag.info(" launch app: \(appName)")
384
+ let capturedLp = lp
385
+ let capturedScreen = lpScreen
386
+ launchQueue.append(("app:\(appName)", nil, capturedScreen, { [weak self] in
387
+ self?.launchAppEntry(capturedLp)
388
+ }))
389
+ // Queue a delayed tile after launch
390
+ if let pos = position {
391
+ let capturedTitle = lp.title
392
+ let delay = Double(launchQueue.count) * 0.5 + 1.0
393
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
394
+ DesktopModel.shared.poll()
395
+ if let entry = DesktopModel.shared.windowForApp(app: appName, title: capturedTitle) {
396
+ let frame = WindowTiler.tileFrame(for: pos, on: capturedScreen)
397
+ WindowTiler.batchMoveAndRaiseWindows([(entry.wid, entry.pid, frame)])
398
+ }
399
+ }
400
+ }
401
+ } else {
402
+ diag.info(" skip (not found): \(appName)")
403
+ }
404
+ continue
405
+ }
406
+
407
+ guard let path = lp.path else { continue }
408
+ let sessionName = Self.sessionName(for: path)
409
+ let project = scanner.projects.first(where: { $0.path == path })
410
+ let position = lp.tile.flatMap { TilePosition(rawValue: $0) }
411
+ // Check scanner first, fall back to direct tmux check for projects without .lattices.json
412
+ let isRunning = project?.isRunning == true || shell([tmuxPath, "has-session", "-t", sessionName]) == 0
413
+
414
+ if isRunning {
415
+ let foundWindow = windowForSession(sessionName)
416
+ let msg = " \(sessionName): running=\(isRunning) window=\(foundWindow?.wid ?? 0) tile=\(position?.rawValue ?? "nil") desktopCount=\(DesktopModel.shared.windows.count)"
417
+ diag.info(msg)
418
+ debugLines.append(msg)
419
+ if let pos = position,
420
+ let target = batchTarget(session: sessionName, position: pos, screen: lpScreen) {
421
+ batchMoves.append(target)
422
+ debugLines.append(" → batch move wid=\(target.wid) frame=\(target.frame)")
423
+ } else if let pos = position {
424
+ fallbacks.append((sessionName, pos, lpScreen))
425
+ debugLines.append(" → fallback \(pos.rawValue)")
426
+ }
427
+ } else if launch {
428
+ if let project {
429
+ let t = diag.startTimed("launch: \(project.name)")
430
+ SessionManager.launch(project: project)
431
+ diag.finish(t)
432
+ } else {
433
+ diag.info(" launch (direct): \(sessionName)")
434
+ terminal.launch(command: "/opt/homebrew/bin/lattices", in: path)
435
+ }
436
+ launchQueue.append((sessionName, position, lpScreen, {}))
437
+ } else {
438
+ diag.info(" skip (not running): \(sessionName)")
439
+ }
440
+
441
+ // Compose companion windows from project's .lattices.json "windows" array
442
+ let companions = projectWindows(at: path)
443
+ for cw in companions {
444
+ guard let appName = cw.app else { continue }
445
+ let cwScreen = screen(for: cw.display ?? lp.display) ?? lpScreen
446
+ let cwPosition = cw.tile.flatMap { TilePosition(rawValue: $0) }
447
+ if let entry = DesktopModel.shared.windowForApp(app: appName, title: cw.title) {
448
+ if let pos = cwPosition {
449
+ let frame = WindowTiler.tileFrame(for: pos, on: cwScreen)
450
+ batchMoves.append((entry.wid, entry.pid, frame))
451
+ }
452
+ } else if launch {
453
+ diag.info(" launch companion: \(appName)")
454
+ let capturedCw = cw
455
+ launchQueue.append(("app:\(appName)", nil, cwScreen, { [weak self] in
456
+ self?.launchAppEntry(capturedCw)
457
+ }))
458
+ if let pos = cwPosition {
459
+ let capturedTitle = cw.title
460
+ let capturedScreen = cwScreen
461
+ let delay = Double(launchQueue.count) * 0.5 + 1.0
462
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
463
+ DesktopModel.shared.poll()
464
+ if let entry = DesktopModel.shared.windowForApp(app: appName, title: capturedTitle) {
465
+ let frame = WindowTiler.tileFrame(for: pos, on: capturedScreen)
466
+ WindowTiler.batchMoveAndRaiseWindows([(entry.wid, entry.pid, frame)])
467
+ }
468
+ }
469
+ }
470
+ }
471
+ }
472
+ }
473
+
474
+ // Write debug log
475
+ debugLines.append("batchMoves=\(batchMoves.count) fallbacks=\(fallbacks.count) launchQueue=\(launchQueue.count)")
476
+ try? debugLines.joined(separator: "\n").write(toFile: debugPath, atomically: true, encoding: .utf8)
477
+
478
+ // Phase 2: batch tile all tracked windows
479
+ if !batchMoves.isEmpty {
480
+ let t = diag.startTimed("batch tile \(batchMoves.count) windows")
481
+ WindowTiler.batchMoveAndRaiseWindows(batchMoves)
482
+ diag.finish(t)
483
+ }
484
+
485
+ // Phase 3: fallback for running-but-untracked windows
486
+ for (i, fb) in fallbacks.enumerated() {
487
+ let delay = Double(i) * 0.15 + 0.1
488
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
489
+ diag.info(" tile fallback: \(fb.session) → \(fb.position.rawValue)")
490
+ WindowTiler.navigateToWindow(session: fb.session, terminal: terminal)
491
+ WindowTiler.tile(session: fb.session, terminal: terminal, to: fb.position, on: fb.screen)
492
+ }
493
+ }
494
+
495
+ // Phase 4: staggered tile for newly-launched windows
496
+ for (i, item) in launchQueue.enumerated() {
497
+ let delay = Double(i) * 0.15 + 0.2
498
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
499
+ item.launchAction()
500
+ if let pos = item.position {
501
+ let t = diag.startTimed("tile launched: \(item.session) → \(pos.rawValue)")
502
+ WindowTiler.tile(session: item.session, terminal: terminal, to: pos, on: item.screen)
503
+ diag.finish(t)
504
+ }
505
+ }
506
+ }
507
+
508
+ activeLayerIndex = index
509
+ UserDefaults.standard.set(index, forKey: activeLayerKey)
510
+
511
+ // Show layer bezel
512
+ let totalLayers = layers.count
513
+ let allLabels = layers.map(\.label)
514
+ LayerBezel.shared.show(label: targetLayer.label, index: index, total: totalLayers, allLabels: allLabels)
515
+
516
+ let maxDelay = max(
517
+ fallbacks.isEmpty ? 0.0 : Double(fallbacks.count) * 0.15 + 0.3,
518
+ launchQueue.isEmpty ? 0.0 : Double(launchQueue.count) * 0.15 + 0.5
519
+ )
520
+ let cleanupDelay = max(0.2, maxDelay)
521
+ DispatchQueue.main.asyncAfter(deadline: .now() + cleanupDelay) {
522
+ scanner.refreshStatus()
523
+ self.isSwitching = false
524
+ diag.finish(overall)
525
+ }
526
+ }
527
+
528
+ // MARK: - Per-Project Window Config
529
+
530
+ /// Read companion window entries from a project's .lattices.json "windows" array
531
+ func projectWindows(at projectPath: String) -> [LayerProject] {
532
+ let configPath = (projectPath as NSString).appendingPathComponent(".lattices.json")
533
+ guard let data = FileManager.default.contents(atPath: configPath),
534
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
535
+ let windowsArray = json["windows"] else { return [] }
536
+ do {
537
+ let windowsData = try JSONSerialization.data(withJSONObject: windowsArray)
538
+ return try JSONDecoder().decode([LayerProject].self, from: windowsData)
539
+ } catch {
540
+ DiagnosticLog.shared.error("WorkspaceManager: failed to decode windows in \(configPath) — \(error.localizedDescription)")
541
+ return []
542
+ }
543
+ }
544
+
545
+ // MARK: - App Launch Helper
546
+
547
+ /// Launch an app-based layer project (open URL or launch app by name)
548
+ private func launchAppEntry(_ lp: LayerProject) {
549
+ if let urlStr = lp.url, let url = URL(string: urlStr) {
550
+ NSWorkspace.shared.open(url)
551
+ } else if let appName = lp.launch ?? lp.app {
552
+ let task = Process()
553
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
554
+ task.arguments = ["-a", appName]
555
+ task.standardOutput = FileHandle.nullDevice
556
+ task.standardError = FileHandle.nullDevice
557
+ try? task.run()
558
+ }
559
+ }
560
+
561
+ // MARK: - Session Name Helper
562
+
563
+ /// Replicates Project.sessionName logic from a bare path
564
+ static func sessionName(for path: String) -> String {
565
+ let name = (path as NSString).lastPathComponent
566
+ let base = name.replacingOccurrences(
567
+ of: "[^a-zA-Z0-9_-]",
568
+ with: "-",
569
+ options: .regularExpression
570
+ )
571
+ let hash = SHA256.hash(data: Data(path.utf8))
572
+ let short = hash.prefix(3).map { String(format: "%02x", $0) }.joined()
573
+ return "\(base)-\(short)"
574
+ }
575
+ }
package/bin/client.js ADDED
@@ -0,0 +1,4 @@
1
+ // Public API — re-exports from daemon-client for a cleaner import path.
2
+ // Usage: import { daemonCall, isDaemonRunning } from '@lattices/cli'
3
+
4
+ export { daemonCall, isDaemonRunning } from "./daemon-client.js";