@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.
- package/LICENSE +21 -0
- package/README.md +172 -86
- package/apps/mac/Info.plist +43 -0
- package/apps/mac/Lattices.app/Contents/Info.plist +43 -0
- package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/apps/mac/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
- package/apps/mac/Lattices.app/Contents/Resources/docs/assistant-knowledge.md +130 -0
- package/apps/mac/Lattices.app/Contents/Resources/tap.wav +0 -0
- package/apps/mac/Lattices.app/Contents/_CodeSignature/CodeResources +150 -0
- package/apps/mac/Lattices.entitlements +21 -0
- package/apps/mac/Resources/Pets/assistant-spark/pet.json +62 -0
- package/apps/mac/Resources/Pets/assistant-spark/spritesheet.webp +0 -0
- package/apps/mac/Resources/Pets/scout-ranger/pet.json +6 -0
- package/apps/mac/Resources/Pets/scout-ranger/spritesheet.webp +0 -0
- package/apps/mac/Resources/tap.wav +0 -0
- package/assets/AppIcon.icns +0 -0
- package/bin/assistant-intelligence.ts +912 -0
- package/bin/cli/capture.ts +252 -0
- package/bin/cli/daemon.ts +22 -0
- package/bin/cli/helpers.ts +105 -0
- package/bin/cli/layer.ts +178 -0
- package/bin/cli/runs.ts +43 -0
- package/bin/cli/search.ts +141 -0
- package/bin/cli/session.ts +32 -0
- package/bin/client.ts +17 -0
- package/bin/cua.ts +26 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +96 -0
- package/bin/handsoff-worker.ts +531 -0
- package/bin/infer.ts +424 -0
- package/bin/keychain.ts +75 -0
- package/bin/lattices-app.ts +655 -0
- package/bin/lattices-build +125 -0
- package/bin/lattices-build-env.ts +77 -0
- package/bin/lattices-dev +362 -0
- package/bin/lattices.ts +3260 -0
- 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 +233 -0
- package/docs/ai-chat-ux-review.md +416 -0
- package/docs/api.md +1041 -47
- package/docs/app.md +96 -13
- package/docs/assistant-knowledge.md +130 -0
- package/docs/companion-deck.md +209 -0
- package/docs/component-extraction-roadmap.md +392 -0
- package/docs/concepts.md +13 -12
- package/docs/config.md +83 -10
- package/docs/gesture-customization-proposal.md +520 -0
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/hyperspace-grid-snappiness.md +210 -0
- package/docs/layers.md +176 -28
- package/docs/mouse-gestures.md +244 -0
- package/docs/ocr.md +21 -9
- package/docs/overview.md +42 -23
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +382 -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/proposals/LAT-001-gesture-visual-customization.md +522 -0
- package/docs/proposals/LAT-002-shared-overlay-canvas.md +353 -0
- package/docs/proposals/LAT-003-menu-bar-controller-architecture.md +291 -0
- package/docs/proposals/LAT-004-interactive-overlay-actors.md +534 -0
- package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
- package/docs/proposals/LAT-006-followup-gaps.md +103 -0
- package/docs/proposals/LAT-006-runs-and-capture-in-lattices.md +566 -0
- package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
- package/docs/quickstart.md +8 -12
- package/docs/reference/dewey.config.ts +74 -0
- package/docs/reference/install-agent.md +79 -0
- package/docs/release.md +172 -0
- package/docs/repo-structure.md +100 -0
- package/docs/terminal-kit.md +87 -0
- package/docs/tiling-reference.md +224 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice-error-model.md +73 -0
- package/docs/voice.md +221 -0
- package/package.json +69 -16
- package/packages/npm/sdk/cua.d.mts +1 -0
- package/packages/npm/sdk/cua.d.ts +188 -0
- package/packages/npm/sdk/cua.mjs +376 -0
- package/app/Lattices.app/Contents/Info.plist +0 -24
- package/app/Package.swift +0 -13
- package/app/Sources/ActionRow.swift +0 -61
- package/app/Sources/App.swift +0 -10
- package/app/Sources/AppDelegate.swift +0 -234
- package/app/Sources/AppShellView.swift +0 -62
- package/app/Sources/AppTypeClassifier.swift +0 -70
- package/app/Sources/AppWindowShell.swift +0 -63
- package/app/Sources/CheatSheetHUD.swift +0 -332
- package/app/Sources/CommandModeState.swift +0 -1362
- package/app/Sources/CommandModeView.swift +0 -1405
- package/app/Sources/CommandModeWindow.swift +0 -192
- package/app/Sources/CommandPaletteView.swift +0 -307
- package/app/Sources/CommandPaletteWindow.swift +0 -134
- package/app/Sources/DaemonProtocol.swift +0 -101
- package/app/Sources/DaemonServer.swift +0 -414
- package/app/Sources/DesktopModel.swift +0 -121
- package/app/Sources/DesktopModelTypes.swift +0 -71
- package/app/Sources/DiagnosticLog.swift +0 -271
- package/app/Sources/EventBus.swift +0 -30
- package/app/Sources/HotkeyManager.swift +0 -250
- package/app/Sources/HotkeyStore.swift +0 -338
- package/app/Sources/InventoryManager.swift +0 -35
- package/app/Sources/InventoryPath.swift +0 -43
- package/app/Sources/KeyRecorderView.swift +0 -210
- package/app/Sources/LatticesApi.swift +0 -1125
- package/app/Sources/MainView.swift +0 -467
- package/app/Sources/MainWindow.swift +0 -83
- package/app/Sources/OcrModel.swift +0 -309
- package/app/Sources/OcrStore.swift +0 -295
- package/app/Sources/OmniSearchState.swift +0 -283
- package/app/Sources/OmniSearchView.swift +0 -288
- package/app/Sources/OmniSearchWindow.swift +0 -105
- package/app/Sources/OrphanRow.swift +0 -129
- package/app/Sources/PaletteCommand.swift +0 -419
- package/app/Sources/PermissionChecker.swift +0 -125
- package/app/Sources/Preferences.swift +0 -92
- package/app/Sources/ProcessModel.swift +0 -199
- package/app/Sources/ProcessQuery.swift +0 -151
- package/app/Sources/Project.swift +0 -28
- package/app/Sources/ProjectRow.swift +0 -368
- package/app/Sources/ProjectScanner.swift +0 -121
- package/app/Sources/ScreenMapState.swift +0 -2387
- package/app/Sources/ScreenMapView.swift +0 -2820
- package/app/Sources/ScreenMapWindowController.swift +0 -89
- package/app/Sources/SessionManager.swift +0 -72
- package/app/Sources/SettingsView.swift +0 -1053
- package/app/Sources/SettingsWindow.swift +0 -20
- package/app/Sources/TabGroupRow.swift +0 -178
- package/app/Sources/Terminal.swift +0 -259
- package/app/Sources/TerminalQuery.swift +0 -156
- package/app/Sources/TerminalSynthesizer.swift +0 -200
- package/app/Sources/Theme.swift +0 -163
- package/app/Sources/TilePickerView.swift +0 -209
- package/app/Sources/TmuxModel.swift +0 -53
- package/app/Sources/TmuxQuery.swift +0 -81
- package/app/Sources/WindowTiler.swift +0 -1755
- package/app/Sources/WorkspaceManager.swift +0 -434
- package/bin/lattices-app.js +0 -221
- 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
|
-
}
|
package/bin/lattices-app.js
DELETED
|
@@ -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
|
-
}
|