@arach/lattices 0.2.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -86
  3. package/apps/mac/Info.plist +43 -0
  4. package/apps/mac/Lattices.app/Contents/Info.plist +43 -0
  5. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  6. package/apps/mac/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  7. package/apps/mac/Lattices.app/Contents/Resources/docs/assistant-knowledge.md +130 -0
  8. package/apps/mac/Lattices.app/Contents/Resources/tap.wav +0 -0
  9. package/apps/mac/Lattices.app/Contents/_CodeSignature/CodeResources +150 -0
  10. package/apps/mac/Lattices.entitlements +21 -0
  11. package/apps/mac/Resources/Pets/assistant-spark/pet.json +62 -0
  12. package/apps/mac/Resources/Pets/assistant-spark/spritesheet.webp +0 -0
  13. package/apps/mac/Resources/Pets/scout-ranger/pet.json +6 -0
  14. package/apps/mac/Resources/Pets/scout-ranger/spritesheet.webp +0 -0
  15. package/apps/mac/Resources/tap.wav +0 -0
  16. package/assets/AppIcon.icns +0 -0
  17. package/bin/assistant-intelligence.ts +912 -0
  18. package/bin/cli/capture.ts +252 -0
  19. package/bin/cli/daemon.ts +22 -0
  20. package/bin/cli/helpers.ts +105 -0
  21. package/bin/cli/layer.ts +178 -0
  22. package/bin/cli/runs.ts +43 -0
  23. package/bin/cli/search.ts +141 -0
  24. package/bin/cli/session.ts +32 -0
  25. package/bin/client.ts +17 -0
  26. package/bin/cua.ts +26 -0
  27. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  28. package/bin/handsoff-infer.ts +96 -0
  29. package/bin/handsoff-worker.ts +531 -0
  30. package/bin/infer.ts +424 -0
  31. package/bin/keychain.ts +75 -0
  32. package/bin/lattices-app.ts +655 -0
  33. package/bin/lattices-build +125 -0
  34. package/bin/lattices-build-env.ts +77 -0
  35. package/bin/lattices-dev +362 -0
  36. package/bin/lattices.ts +3260 -0
  37. package/bin/project-twin.ts +645 -0
  38. package/docs/agent-execution-plan.md +562 -0
  39. package/docs/agent-layer-guide.md +207 -0
  40. package/docs/agents.md +233 -0
  41. package/docs/ai-chat-ux-review.md +416 -0
  42. package/docs/api.md +1041 -47
  43. package/docs/app.md +96 -13
  44. package/docs/assistant-knowledge.md +130 -0
  45. package/docs/companion-deck.md +209 -0
  46. package/docs/component-extraction-roadmap.md +392 -0
  47. package/docs/concepts.md +13 -12
  48. package/docs/config.md +83 -10
  49. package/docs/gesture-customization-proposal.md +520 -0
  50. package/docs/handsoff-test-scenarios.md +84 -0
  51. package/docs/hyperspace-grid-snappiness.md +210 -0
  52. package/docs/layers.md +176 -28
  53. package/docs/mouse-gestures.md +244 -0
  54. package/docs/ocr.md +21 -9
  55. package/docs/overview.md +42 -23
  56. package/docs/presentation-execution-review.md +491 -0
  57. package/docs/prompts/hands-off-system.md +382 -0
  58. package/docs/prompts/hands-off-turn.md +30 -0
  59. package/docs/prompts/voice-advisor.md +31 -0
  60. package/docs/prompts/voice-fallback.md +23 -0
  61. package/docs/proposals/LAT-001-gesture-visual-customization.md +522 -0
  62. package/docs/proposals/LAT-002-shared-overlay-canvas.md +353 -0
  63. package/docs/proposals/LAT-003-menu-bar-controller-architecture.md +291 -0
  64. package/docs/proposals/LAT-004-interactive-overlay-actors.md +534 -0
  65. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  66. package/docs/proposals/LAT-006-followup-gaps.md +103 -0
  67. package/docs/proposals/LAT-006-runs-and-capture-in-lattices.md +566 -0
  68. package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
  69. package/docs/quickstart.md +8 -12
  70. package/docs/reference/dewey.config.ts +74 -0
  71. package/docs/reference/install-agent.md +79 -0
  72. package/docs/release.md +172 -0
  73. package/docs/repo-structure.md +100 -0
  74. package/docs/terminal-kit.md +87 -0
  75. package/docs/tiling-reference.md +224 -0
  76. package/docs/twins.md +138 -0
  77. package/docs/voice-command-protocol.md +278 -0
  78. package/docs/voice-error-model.md +73 -0
  79. package/docs/voice.md +221 -0
  80. package/package.json +69 -16
  81. package/packages/npm/sdk/cua.d.mts +1 -0
  82. package/packages/npm/sdk/cua.d.ts +188 -0
  83. package/packages/npm/sdk/cua.mjs +376 -0
  84. package/app/Lattices.app/Contents/Info.plist +0 -24
  85. package/app/Package.swift +0 -13
  86. package/app/Sources/ActionRow.swift +0 -61
  87. package/app/Sources/App.swift +0 -10
  88. package/app/Sources/AppDelegate.swift +0 -234
  89. package/app/Sources/AppShellView.swift +0 -62
  90. package/app/Sources/AppTypeClassifier.swift +0 -70
  91. package/app/Sources/AppWindowShell.swift +0 -63
  92. package/app/Sources/CheatSheetHUD.swift +0 -332
  93. package/app/Sources/CommandModeState.swift +0 -1362
  94. package/app/Sources/CommandModeView.swift +0 -1405
  95. package/app/Sources/CommandModeWindow.swift +0 -192
  96. package/app/Sources/CommandPaletteView.swift +0 -307
  97. package/app/Sources/CommandPaletteWindow.swift +0 -134
  98. package/app/Sources/DaemonProtocol.swift +0 -101
  99. package/app/Sources/DaemonServer.swift +0 -414
  100. package/app/Sources/DesktopModel.swift +0 -121
  101. package/app/Sources/DesktopModelTypes.swift +0 -71
  102. package/app/Sources/DiagnosticLog.swift +0 -271
  103. package/app/Sources/EventBus.swift +0 -30
  104. package/app/Sources/HotkeyManager.swift +0 -250
  105. package/app/Sources/HotkeyStore.swift +0 -338
  106. package/app/Sources/InventoryManager.swift +0 -35
  107. package/app/Sources/InventoryPath.swift +0 -43
  108. package/app/Sources/KeyRecorderView.swift +0 -210
  109. package/app/Sources/LatticesApi.swift +0 -1125
  110. package/app/Sources/MainView.swift +0 -467
  111. package/app/Sources/MainWindow.swift +0 -83
  112. package/app/Sources/OcrModel.swift +0 -309
  113. package/app/Sources/OcrStore.swift +0 -295
  114. package/app/Sources/OmniSearchState.swift +0 -283
  115. package/app/Sources/OmniSearchView.swift +0 -288
  116. package/app/Sources/OmniSearchWindow.swift +0 -105
  117. package/app/Sources/OrphanRow.swift +0 -129
  118. package/app/Sources/PaletteCommand.swift +0 -419
  119. package/app/Sources/PermissionChecker.swift +0 -125
  120. package/app/Sources/Preferences.swift +0 -92
  121. package/app/Sources/ProcessModel.swift +0 -199
  122. package/app/Sources/ProcessQuery.swift +0 -151
  123. package/app/Sources/Project.swift +0 -28
  124. package/app/Sources/ProjectRow.swift +0 -368
  125. package/app/Sources/ProjectScanner.swift +0 -121
  126. package/app/Sources/ScreenMapState.swift +0 -2387
  127. package/app/Sources/ScreenMapView.swift +0 -2820
  128. package/app/Sources/ScreenMapWindowController.swift +0 -89
  129. package/app/Sources/SessionManager.swift +0 -72
  130. package/app/Sources/SettingsView.swift +0 -1053
  131. package/app/Sources/SettingsWindow.swift +0 -20
  132. package/app/Sources/TabGroupRow.swift +0 -178
  133. package/app/Sources/Terminal.swift +0 -259
  134. package/app/Sources/TerminalQuery.swift +0 -156
  135. package/app/Sources/TerminalSynthesizer.swift +0 -200
  136. package/app/Sources/Theme.swift +0 -163
  137. package/app/Sources/TilePickerView.swift +0 -209
  138. package/app/Sources/TmuxModel.swift +0 -53
  139. package/app/Sources/TmuxQuery.swift +0 -81
  140. package/app/Sources/WindowTiler.swift +0 -1755
  141. package/app/Sources/WorkspaceManager.swift +0 -434
  142. package/bin/lattices-app.js +0 -221
  143. package/bin/lattices.js +0 -1418
@@ -1,434 +0,0 @@
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
- }
24
-
25
- struct Layer: Codable, Identifiable {
26
- let id: String
27
- let label: String
28
- let projects: [LayerProject]
29
- }
30
-
31
- struct WorkspaceConfig: Codable {
32
- let name: String
33
- let groups: [TabGroup]?
34
- let layers: [Layer]?
35
- }
36
-
37
- // MARK: - Grid Presets & Named Layouts
38
-
39
- struct GridPreset: Codable {
40
- let x: CGFloat
41
- let y: CGFloat
42
- let w: CGFloat
43
- let h: CGFloat
44
-
45
- var fractions: (CGFloat, CGFloat, CGFloat, CGFloat) { (x, y, w, h) }
46
- }
47
-
48
- struct LayoutWindowSpec: Codable {
49
- let app: String
50
- let tile: String // TilePosition name or preset name
51
- let display: Int? // spatial display number (1-based), nil = current
52
- let title: String? // optional title match for disambiguation
53
- }
54
-
55
- struct LayoutConfig: Codable {
56
- let windows: [LayoutWindowSpec]
57
- }
58
-
59
- struct GridFile: Codable {
60
- let presets: [String: GridPreset]?
61
- let layouts: [String: LayoutConfig]?
62
- }
63
-
64
- // MARK: - Manager
65
-
66
- class WorkspaceManager: ObservableObject {
67
- static let shared = WorkspaceManager()
68
-
69
- @Published var config: WorkspaceConfig?
70
- @Published var activeLayerIndex: Int = 0
71
- @Published var isSwitching: Bool = false
72
- @Published var gridPresets: [String: GridPreset] = [:]
73
- @Published var gridLayouts: [String: LayoutConfig] = [:]
74
-
75
- private let configPath: String
76
- private let gridConfigPath: String
77
- private let tmuxPath = "/opt/homebrew/bin/tmux"
78
- private let activeLayerKey = "lattices.activeLayerIndex"
79
-
80
- init() {
81
- let home = FileManager.default.homeDirectoryForCurrentUser.path
82
- self.configPath = (home as NSString).appendingPathComponent(".lattices/workspace.json")
83
- self.gridConfigPath = (home as NSString).appendingPathComponent(".lattices/grid.json")
84
- self.activeLayerIndex = UserDefaults.standard.integer(forKey: activeLayerKey)
85
- loadConfig()
86
- loadGridConfig()
87
- }
88
-
89
- var activeLayer: Layer? {
90
- guard let config, let layers = config.layers, activeLayerIndex < layers.count else { return nil }
91
- return layers[activeLayerIndex]
92
- }
93
-
94
- // MARK: - Config I/O
95
-
96
- func loadConfig() {
97
- guard FileManager.default.fileExists(atPath: configPath),
98
- let data = FileManager.default.contents(atPath: configPath) else {
99
- config = nil
100
- return
101
- }
102
- do {
103
- config = try JSONDecoder().decode(WorkspaceConfig.self, from: data)
104
- // Clamp saved index
105
- if let config, let layers = config.layers, activeLayerIndex >= layers.count {
106
- activeLayerIndex = 0
107
- }
108
- } catch {
109
- DiagnosticLog.shared.error("WorkspaceManager: failed to decode workspace.json — \(error.localizedDescription)")
110
- config = nil
111
- }
112
- }
113
-
114
- func reloadConfig() {
115
- loadConfig()
116
- loadGridConfig()
117
- }
118
-
119
- // MARK: - Grid Config I/O
120
-
121
- func loadGridConfig() {
122
- var presets: [String: GridPreset] = [:]
123
- var layouts: [String: LayoutConfig] = [:]
124
-
125
- // Load global ~/.lattices/grid.json
126
- if FileManager.default.fileExists(atPath: gridConfigPath),
127
- let data = FileManager.default.contents(atPath: gridConfigPath) {
128
- do {
129
- let gridFile = try JSONDecoder().decode(GridFile.self, from: data)
130
- if let p = gridFile.presets { presets.merge(p) { _, new in new } }
131
- if let l = gridFile.layouts { layouts.merge(l) { _, new in new } }
132
- } catch {
133
- DiagnosticLog.shared.error("WorkspaceManager: failed to decode grid.json — \(error.localizedDescription)")
134
- }
135
- }
136
-
137
- // Merge per-project .lattices.json "grid" section on top
138
- let projectGridPath = ".lattices.json"
139
- if FileManager.default.fileExists(atPath: projectGridPath),
140
- let data = FileManager.default.contents(atPath: projectGridPath) {
141
- do {
142
- if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
143
- let gridDict = json["grid"] {
144
- let gridData = try JSONSerialization.data(withJSONObject: gridDict)
145
- let gridFile = try JSONDecoder().decode(GridFile.self, from: gridData)
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
- }
149
- } catch {
150
- DiagnosticLog.shared.error("WorkspaceManager: failed to decode .lattices.json grid — \(error.localizedDescription)")
151
- }
152
- }
153
-
154
- self.gridPresets = presets
155
- self.gridLayouts = layouts
156
- }
157
-
158
- /// Resolve a tile string to fractions: check user presets first, then built-in TilePosition
159
- func resolveTileFractions(_ tile: String) -> (CGFloat, CGFloat, CGFloat, CGFloat)? {
160
- if let preset = gridPresets[tile] {
161
- return preset.fractions
162
- }
163
- if let position = TilePosition(rawValue: tile) {
164
- return position.rect
165
- }
166
- return nil
167
- }
168
-
169
- // MARK: - Tab Groups
170
-
171
- func group(byId id: String) -> TabGroup? {
172
- config?.groups?.first(where: { $0.id == id })
173
- }
174
-
175
- func isGroupRunning(_ group: TabGroup) -> Bool {
176
- group.tabs.contains { tab in
177
- let name = Self.sessionName(for: tab.path)
178
- return shell([tmuxPath, "has-session", "-t", name]) == 0
179
- }
180
- }
181
-
182
- /// Count how many tabs in the group have running sessions
183
- func runningTabCount(_ group: TabGroup) -> Int {
184
- group.tabs.filter { tab in
185
- let name = Self.sessionName(for: tab.path)
186
- return shell([tmuxPath, "has-session", "-t", name]) == 0
187
- }.count
188
- }
189
-
190
- /// Launch a group by opening each tab as a separate iTerm/Terminal tab
191
- func launchGroup(_ group: TabGroup) {
192
- let terminal = Preferences.shared.terminal
193
- for (i, tab) in group.tabs.enumerated() {
194
- let label = tab.label ?? (tab.path as NSString).lastPathComponent
195
- DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.4) {
196
- if i == 0 {
197
- terminal.launch(command: "/opt/homebrew/bin/lattices", in: tab.path)
198
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
199
- terminal.nameTab(label)
200
- }
201
- } else {
202
- terminal.launchTab(command: "/opt/homebrew/bin/lattices", in: tab.path, tabName: label)
203
- }
204
- }
205
- }
206
- }
207
-
208
- /// Kill all individual tab sessions for a group
209
- func killGroup(_ group: TabGroup) {
210
- for tab in group.tabs {
211
- let name = Self.sessionName(for: tab.path)
212
- let task = Process()
213
- task.executableURL = URL(fileURLWithPath: tmuxPath)
214
- task.arguments = ["kill-session", "-t", name]
215
- task.standardOutput = FileHandle.nullDevice
216
- task.standardError = FileHandle.nullDevice
217
- try? task.run()
218
- task.waitUntilExit()
219
- }
220
- }
221
-
222
- /// Focus a specific tab's session in the terminal
223
- func focusTab(group: TabGroup, tabIndex: Int) {
224
- guard tabIndex >= 0, tabIndex < group.tabs.count else { return }
225
- let tab = group.tabs[tabIndex]
226
- let sessionName = Self.sessionName(for: tab.path)
227
- let terminal = Preferences.shared.terminal
228
- terminal.focusOrAttach(session: sessionName)
229
- }
230
-
231
- /// Run a command and return exit code
232
- private func shell(_ args: [String]) -> Int32 {
233
- let task = Process()
234
- task.executableURL = URL(fileURLWithPath: args[0])
235
- task.arguments = Array(args.dropFirst())
236
- task.standardOutput = FileHandle.nullDevice
237
- task.standardError = FileHandle.nullDevice
238
- try? task.run()
239
- task.waitUntilExit()
240
- return task.terminationStatus
241
- }
242
-
243
- // MARK: - Display Helper
244
-
245
- /// Resolve a display index to an NSScreen (falls back to first screen)
246
- private func screen(for displayIndex: Int?) -> NSScreen? {
247
- let screens = NSScreen.screens
248
- guard !screens.isEmpty else { return nil }
249
- let idx = displayIndex ?? 0
250
- return idx < screens.count ? screens[idx] : screens[0]
251
- }
252
-
253
- // MARK: - Window Lookup
254
-
255
- /// Find a tracked window for a session name (instant — uses DesktopModel cache)
256
- private func windowForSession(_ sessionName: String) -> WindowEntry? {
257
- DesktopModel.shared.windowForSession(sessionName)
258
- }
259
-
260
- /// Resolve a session name to a tile target: (wid, pid, frame).
261
- /// Returns nil if the window isn't tracked or has no tile position.
262
- private func batchTarget(session: String, position: TilePosition, screen: NSScreen) -> (wid: UInt32, pid: Int32, frame: CGRect)? {
263
- guard let entry = windowForSession(session) else { return nil }
264
- let frame = WindowTiler.tileFrame(for: position, on: screen)
265
- return (entry.wid, entry.pid, frame)
266
- }
267
-
268
- // MARK: - Tiling
269
-
270
- /// Re-tile the current layer without switching (for "tile all")
271
- func retileCurrentLayer() {
272
- tileLayer(index: activeLayerIndex, launch: false, force: true)
273
- }
274
-
275
- /// Count running projects+groups in a layer
276
- func layerRunningCount(index: Int) -> (running: Int, total: Int) {
277
- guard let config, let layers = config.layers, index < layers.count else { return (0, 0) }
278
- let layer = layers[index]
279
- let scanner = ProjectScanner.shared
280
- var running = 0
281
- let total = layer.projects.count
282
-
283
- for lp in layer.projects {
284
- if let groupId = lp.group, let group = group(byId: groupId) {
285
- if isGroupRunning(group) { running += 1 }
286
- } else if let path = lp.path {
287
- let project = scanner.projects.first(where: { $0.path == path })
288
- if project?.isRunning == true { running += 1 }
289
- }
290
- }
291
- return (running, total)
292
- }
293
-
294
- // MARK: - Unified Layer Tiling
295
-
296
- /// Unified entry point for arranging a layer's windows.
297
- ///
298
- /// | launch | force | Behavior |
299
- /// |--------|-------|----------|
300
- /// | false | false | Tile running projects only (focus) |
301
- /// | true | false | Launch stopped + tile all, skip if same layer |
302
- /// | true | true | Re-launch current layer |
303
- /// | false | true | Re-tile current layer |
304
- func tileLayer(index: Int, launch: Bool = false, force: Bool = false) {
305
- guard let config, let layers = config.layers, index < layers.count else { return }
306
- if launch && !force && index == activeLayerIndex { return }
307
-
308
- let diag = DiagnosticLog.shared
309
- let label = launch ? "tileLayer(launch)" : "tileLayer(focus)"
310
- let overall = diag.startTimed("\(label) \(activeLayerIndex)→\(index)")
311
-
312
- isSwitching = true
313
- let terminal = Preferences.shared.terminal
314
- let scanner = ProjectScanner.shared
315
- let targetLayer = layers[index]
316
-
317
- // Phase 1: classify each project
318
- var batchMoves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
319
- var fallbacks: [(session: String, position: TilePosition, screen: NSScreen)] = []
320
- var launchQueue: [(session: String, position: TilePosition?, screen: NSScreen, launchAction: () -> Void)] = []
321
-
322
- for lp in targetLayer.projects {
323
- guard let lpScreen = screen(for: lp.display) else { continue }
324
-
325
- if let groupId = lp.group, let grp = group(byId: groupId) {
326
- let firstTabSession = grp.tabs.first.map { Self.sessionName(for: $0.path) } ?? ""
327
- let position = lp.tile.flatMap { TilePosition(rawValue: $0) }
328
- let groupRunning = isGroupRunning(grp)
329
-
330
- if groupRunning, let pos = position,
331
- let target = batchTarget(session: firstTabSession, position: pos, screen: lpScreen) {
332
- batchMoves.append(target)
333
- } else if !groupRunning && launch {
334
- diag.info(" launch group: \(grp.label)")
335
- launchQueue.append((firstTabSession, position, lpScreen, { [weak self] in
336
- self?.launchGroup(grp)
337
- }))
338
- } else if groupRunning, let pos = position {
339
- // Running but not in DesktopModel — fallback
340
- fallbacks.append((firstTabSession, pos, lpScreen))
341
- } else if !groupRunning {
342
- diag.info(" skip (not running): \(grp.label)")
343
- }
344
- continue
345
- }
346
-
347
- guard let path = lp.path else { continue }
348
- let sessionName = Self.sessionName(for: path)
349
- let project = scanner.projects.first(where: { $0.path == path })
350
- let position = lp.tile.flatMap { TilePosition(rawValue: $0) }
351
- let isRunning = project?.isRunning == true
352
-
353
- if isRunning {
354
- if let pos = position,
355
- let target = batchTarget(session: sessionName, position: pos, screen: lpScreen) {
356
- batchMoves.append(target)
357
- } else if let pos = position {
358
- fallbacks.append((sessionName, pos, lpScreen))
359
- }
360
- } else if launch {
361
- if let project {
362
- let t = diag.startTimed("launch: \(project.name)")
363
- SessionManager.launch(project: project)
364
- diag.finish(t)
365
- } else {
366
- diag.info(" launch (direct): \(sessionName)")
367
- terminal.launch(command: "/opt/homebrew/bin/lattices", in: path)
368
- }
369
- launchQueue.append((sessionName, position, lpScreen, {}))
370
- } else {
371
- diag.info(" skip (not running): \(sessionName)")
372
- }
373
- }
374
-
375
- // Phase 2: batch tile all tracked windows
376
- if !batchMoves.isEmpty {
377
- let t = diag.startTimed("batch tile \(batchMoves.count) windows")
378
- WindowTiler.batchMoveAndRaiseWindows(batchMoves)
379
- diag.finish(t)
380
- }
381
-
382
- // Phase 3: fallback for running-but-untracked windows
383
- for (i, fb) in fallbacks.enumerated() {
384
- let delay = Double(i) * 0.15 + 0.1
385
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
386
- diag.info(" tile fallback: \(fb.session) → \(fb.position.rawValue)")
387
- WindowTiler.navigateToWindow(session: fb.session, terminal: terminal)
388
- WindowTiler.tile(session: fb.session, terminal: terminal, to: fb.position, on: fb.screen)
389
- }
390
- }
391
-
392
- // Phase 4: staggered tile for newly-launched windows
393
- for (i, item) in launchQueue.enumerated() {
394
- let delay = Double(i) * 0.15 + 0.2
395
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
396
- item.launchAction()
397
- if let pos = item.position {
398
- let t = diag.startTimed("tile launched: \(item.session) → \(pos.rawValue)")
399
- WindowTiler.tile(session: item.session, terminal: terminal, to: pos, on: item.screen)
400
- diag.finish(t)
401
- }
402
- }
403
- }
404
-
405
- activeLayerIndex = index
406
- UserDefaults.standard.set(index, forKey: activeLayerKey)
407
-
408
- let maxDelay = max(
409
- fallbacks.isEmpty ? 0.0 : Double(fallbacks.count) * 0.15 + 0.3,
410
- launchQueue.isEmpty ? 0.0 : Double(launchQueue.count) * 0.15 + 0.5
411
- )
412
- let cleanupDelay = max(0.2, maxDelay)
413
- DispatchQueue.main.asyncAfter(deadline: .now() + cleanupDelay) {
414
- scanner.refreshStatus()
415
- self.isSwitching = false
416
- diag.finish(overall)
417
- }
418
- }
419
-
420
- // MARK: - Session Name Helper
421
-
422
- /// Replicates Project.sessionName logic from a bare path
423
- static func sessionName(for path: String) -> String {
424
- let name = (path as NSString).lastPathComponent
425
- let base = name.replacingOccurrences(
426
- of: "[^a-zA-Z0-9_-]",
427
- with: "-",
428
- options: .regularExpression
429
- )
430
- let hash = SHA256.hash(data: Data(path.utf8))
431
- let short = hash.prefix(3).map { String(format: "%02x", $0) }.joined()
432
- return "\(base)-\(short)"
433
- }
434
- }
@@ -1,221 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { execSync, spawn } from "node:child_process";
4
- import { existsSync, mkdirSync, chmodSync, createWriteStream } from "node:fs";
5
- import { resolve, dirname } from "node:path";
6
- import { fileURLToPath } from "node:url";
7
- import { get } from "node:https";
8
-
9
- const __dirname = dirname(fileURLToPath(import.meta.url));
10
- const appDir = resolve(__dirname, "../app");
11
- const bundlePath = resolve(appDir, "Lattices.app");
12
- const binaryDir = resolve(bundlePath, "Contents/MacOS");
13
- const binaryPath = resolve(binaryDir, "Lattices");
14
-
15
- const REPO = "arach/lattices";
16
- const ASSET_NAME = "Lattices-macos-arm64";
17
-
18
- // ── Helpers ──────────────────────────────────────────────────────────
19
-
20
- function isRunning() {
21
- try {
22
- execSync("pgrep -x Lattices", { stdio: "pipe" });
23
- return true;
24
- } catch {
25
- return false;
26
- }
27
- }
28
-
29
- function quit() {
30
- try {
31
- execSync("pkill -x Lattices", { stdio: "pipe" });
32
- // Wait briefly for process to exit
33
- try { execSync("sleep 0.5", { stdio: "pipe" }); } catch {}
34
- // Force kill if still running
35
- if (isRunning()) {
36
- execSync("pkill -9 -x Lattices", { stdio: "pipe" });
37
- }
38
- return true;
39
- } catch {
40
- return false;
41
- }
42
- }
43
-
44
- function hasSwift() {
45
- try {
46
- execSync("which swift", { stdio: "pipe" });
47
- return true;
48
- } catch {
49
- return false;
50
- }
51
- }
52
-
53
- function launch(extraArgs = []) {
54
- if (isRunning()) {
55
- console.log("lattices app is already running.");
56
- return;
57
- }
58
- const args = [bundlePath];
59
- if (extraArgs.length) args.push("--args", ...extraArgs);
60
- spawn("open", args, { detached: true, stdio: "ignore" }).unref();
61
- console.log("lattices app launched.");
62
- }
63
-
64
- // ── Build from source (current arch only) ────────────────────────────
65
-
66
- function buildFromSource() {
67
- console.log("Building lattices app from source...");
68
- try {
69
- execSync("swift build -c release", {
70
- cwd: appDir,
71
- stdio: "inherit",
72
- });
73
- } catch {
74
- return false;
75
- }
76
-
77
- const builtPath = resolve(appDir, ".build/release/Lattices");
78
- if (!existsSync(builtPath)) return false;
79
-
80
- mkdirSync(binaryDir, { recursive: true });
81
- execSync(`cp '${builtPath}' '${binaryPath}'`);
82
-
83
- // Copy app icon into bundle
84
- const iconSrc = resolve(__dirname, "../assets/AppIcon.icns");
85
- const resourcesDir = resolve(bundlePath, "Contents/Resources");
86
- mkdirSync(resourcesDir, { recursive: true });
87
- if (existsSync(iconSrc)) {
88
- execSync(`cp '${iconSrc}' '${resolve(resourcesDir, "AppIcon.icns")}'`);
89
- }
90
-
91
- // Re-sign the bundle so macOS TCC recognizes a stable identity across rebuilds.
92
- // Without this, each build gets a new ad-hoc signature and permission grants are lost.
93
- try {
94
- // Prefer a real signing identity for stable TCC grants; fall back to ad-hoc with fixed identifier
95
- const identities = execSync("security find-identity -v -p codesigning", { stdio: "pipe" }).toString();
96
- const devId = identities.match(/"(Developer ID Application:[^"]+)"/)?.[1]
97
- || identities.match(/"(Apple Development:[^"]+)"/)?.[1];
98
- const signArg = devId ? `'${devId}'` : "-";
99
- execSync(
100
- `codesign --force --sign ${signArg} --identifier com.arach.lattices '${bundlePath}'`,
101
- { stdio: "pipe" }
102
- );
103
- } catch (e) {
104
- // Non-fatal — app still works, just permissions won't persist across rebuilds
105
- console.log("Warning: code signing failed — permissions may not persist across rebuilds.");
106
- }
107
- // Update bundle timestamp so Finder shows the correct modified date
108
- try { execSync(`touch '${bundlePath}'`, { stdio: "pipe" }); } catch {}
109
- console.log("Build complete.");
110
- return true;
111
- }
112
-
113
- // ── Download from GitHub releases ────────────────────────────────────
114
-
115
- function httpsGet(url) {
116
- return new Promise((resolve, reject) => {
117
- get(url, { headers: { "User-Agent": "lattices" } }, (res) => {
118
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
119
- return httpsGet(res.headers.location).then(resolve, reject);
120
- }
121
- if (res.statusCode !== 200) {
122
- reject(new Error(`HTTP ${res.statusCode}`));
123
- res.resume();
124
- return;
125
- }
126
- resolve(res);
127
- }).on("error", reject);
128
- });
129
- }
130
-
131
- async function download() {
132
- console.log("Downloading pre-built binary...");
133
-
134
- try {
135
- const apiUrl = `https://api.github.com/repos/${REPO}/releases/latest`;
136
- const apiRes = await httpsGet(apiUrl);
137
- const chunks = [];
138
- for await (const chunk of apiRes) chunks.push(chunk);
139
- const release = JSON.parse(Buffer.concat(chunks).toString());
140
-
141
- const asset = release.assets?.find((a) => a.name === ASSET_NAME);
142
- if (!asset) throw new Error("Binary not found in release assets");
143
-
144
- const dlRes = await httpsGet(asset.browser_download_url);
145
-
146
- mkdirSync(binaryDir, { recursive: true });
147
- const ws = createWriteStream(binaryPath);
148
- await new Promise((resolve, reject) => {
149
- dlRes.pipe(ws);
150
- ws.on("finish", resolve);
151
- ws.on("error", reject);
152
- });
153
-
154
- chmodSync(binaryPath, 0o755);
155
- console.log("Download complete.");
156
- return true;
157
- } catch (e) {
158
- console.log(`Download failed: ${e.message}`);
159
- return false;
160
- }
161
- }
162
-
163
- // ── Commands ─────────────────────────────────────────────────────────
164
-
165
- async function ensureBinary() {
166
- if (existsSync(binaryPath)) return;
167
-
168
- // 1. Try local compile (fast, matches exact system)
169
- if (hasSwift()) {
170
- if (buildFromSource()) return;
171
- console.log("Local build failed, trying download...");
172
- }
173
-
174
- // 2. Fall back to pre-built binary from GitHub releases
175
- const downloaded = await download();
176
- if (downloaded) return;
177
-
178
- // 3. Nothing worked
179
- console.error(
180
- "Could not build or download the lattices app.\n" +
181
- "Options:\n" +
182
- " • Install Xcode CLI tools: xcode-select --install\n" +
183
- " • Download manually from: https://github.com/" + REPO + "/releases"
184
- );
185
- process.exit(1);
186
- }
187
-
188
- const cmd = process.argv[2];
189
- const flags = process.argv.slice(3);
190
- const launchFlags = [];
191
- if (flags.includes("--diagnostics") || flags.includes("-d")) launchFlags.push("--diagnostics");
192
- if (flags.includes("--screen-map") || flags.includes("-m")) launchFlags.push("--screen-map");
193
-
194
- if (cmd === "build") {
195
- if (!hasSwift()) {
196
- console.error("Swift is required. Install with: xcode-select --install");
197
- process.exit(1);
198
- }
199
- buildFromSource();
200
- } else if (cmd === "quit") {
201
- if (quit()) {
202
- console.log("lattices app stopped.");
203
- } else {
204
- console.log("lattices app is not running.");
205
- }
206
- } else if (cmd === "restart") {
207
- // Quit → rebuild → relaunch
208
- quit();
209
- if (!hasSwift()) {
210
- console.error("Swift is required. Install with: xcode-select --install");
211
- process.exit(1);
212
- }
213
- if (!buildFromSource()) {
214
- console.error("Build failed.");
215
- process.exit(1);
216
- }
217
- launch(launchFlags);
218
- } else {
219
- await ensureBinary();
220
- launch(launchFlags);
221
- }