@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,83 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// Manages the main lattices window as a standalone NSWindow.
5
+ /// Menu bar icon toggles this window open/closed.
6
+ final class MainWindow {
7
+ static let shared = MainWindow()
8
+
9
+ private var window: NSWindow?
10
+ private var keyMonitor: Any?
11
+
12
+ var isVisible: Bool { window?.isVisible ?? false }
13
+
14
+ func toggle() {
15
+ if let w = window, w.isVisible {
16
+ w.orderOut(nil)
17
+ AppDelegate.updateActivationPolicy()
18
+ } else {
19
+ show()
20
+ }
21
+ }
22
+
23
+ func show() {
24
+ if let existing = window {
25
+ existing.makeKeyAndOrderFront(nil)
26
+ NSApp.activate(ignoringOtherApps: true)
27
+ return
28
+ }
29
+
30
+ let view = MainView(scanner: ProjectScanner.shared)
31
+ .preferredColorScheme(.dark)
32
+
33
+ let hostingView = NSHostingView(rootView: view)
34
+ hostingView.frame = NSRect(x: 0, y: 0, width: 380, height: 460)
35
+
36
+ let w = NSWindow(
37
+ contentRect: NSRect(x: 0, y: 0, width: 380, height: 460),
38
+ styleMask: [.titled, .closable, .resizable, .miniaturizable],
39
+ backing: .buffered,
40
+ defer: false
41
+ )
42
+ w.contentView = hostingView
43
+ w.title = "lattices"
44
+ w.titlebarAppearsTransparent = true
45
+ w.titleVisibility = .hidden
46
+ w.isReleasedWhenClosed = false
47
+ w.backgroundColor = NSColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1.0)
48
+ w.appearance = NSAppearance(named: .darkAqua)
49
+ w.minSize = NSSize(width: 340, height: 380)
50
+ w.maxSize = NSSize(width: 600, height: 800)
51
+
52
+ // Position near top-right of screen (close to menu bar area)
53
+ if let screen = NSScreen.main {
54
+ let visibleFrame = screen.visibleFrame
55
+ let x = visibleFrame.maxX - 380 - 20
56
+ let y = visibleFrame.maxY - 460 - 10
57
+ w.setFrameOrigin(NSPoint(x: x, y: y))
58
+ }
59
+
60
+ w.makeKeyAndOrderFront(nil)
61
+ NSApp.activate(ignoringOtherApps: true)
62
+
63
+ window = w
64
+ AppDelegate.updateActivationPolicy()
65
+
66
+ // Escape key → close
67
+ keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
68
+ guard event.keyCode == 53,
69
+ self?.window?.isKeyWindow == true else { return event }
70
+ self?.close()
71
+ return nil
72
+ }
73
+ }
74
+
75
+ func close() {
76
+ window?.orderOut(nil)
77
+ if let monitor = keyMonitor {
78
+ NSEvent.removeMonitor(monitor)
79
+ keyMonitor = nil
80
+ }
81
+ AppDelegate.updateActivationPolicy()
82
+ }
83
+ }
@@ -0,0 +1,430 @@
1
+ import AppKit
2
+ import CryptoKit
3
+ import Vision
4
+
5
+ // MARK: - Data Types
6
+
7
+ enum TextSource: String {
8
+ case accessibility
9
+ case ocr
10
+ }
11
+
12
+ struct OcrTextBlock {
13
+ let text: String
14
+ let confidence: Float // 0.0–1.0
15
+ let boundingBox: CGRect // normalized coordinates within window
16
+ }
17
+
18
+ struct OcrWindowResult {
19
+ let wid: UInt32
20
+ let app: String
21
+ let title: String
22
+ let frame: WindowFrame
23
+ let texts: [OcrTextBlock]
24
+ let fullText: String
25
+ let timestamp: Date
26
+ let source: TextSource
27
+ }
28
+
29
+ // MARK: - OCR Scanner
30
+
31
+ final class OcrModel: ObservableObject {
32
+ static let shared = OcrModel()
33
+
34
+ @Published private(set) var results: [UInt32: OcrWindowResult] = [:]
35
+ @Published private(set) var isScanning: Bool = false
36
+ @Published var interval: TimeInterval = 60
37
+ @Published var enabled: Bool = true
38
+
39
+ private var timer: Timer?
40
+ private var deepTimer: Timer?
41
+ private let queue = DispatchQueue(label: "com.arach.lattices.ocr", qos: .background)
42
+ private let axExtractor = AccessibilityTextExtractor()
43
+ private var imageHashes: [UInt32: Data] = [:]
44
+ private var lastAXHashes: [UInt32: Data] = [:]
45
+ private var lastOCRTextHashes: [UInt32: Data] = [:]
46
+ private var lastScanned: [UInt32: Date] = [:]
47
+ private var scanGeneration: Int = 0
48
+
49
+ private let myPid = ProcessInfo.processInfo.processIdentifier
50
+
51
+ private var prefs: Preferences { Preferences.shared }
52
+
53
+ func start(interval: TimeInterval? = nil) {
54
+ guard timer == nil else { return }
55
+ if let interval { self.interval = interval }
56
+ self.interval = prefs.ocrQuickInterval
57
+ self.enabled = prefs.ocrEnabled
58
+ guard enabled else {
59
+ DiagnosticLog.shared.info("OcrModel: disabled by user preference")
60
+ return
61
+ }
62
+ let deepInterval = prefs.ocrDeepInterval
63
+ // Defer initial scan — let the first timer tick handle it (grace period on launch)
64
+ DiagnosticLog.shared.info("OcrModel: starting (quick=\(self.interval)s/\(prefs.ocrQuickLimit)win, deep=\(deepInterval)s/\(prefs.ocrDeepLimit)win)")
65
+ timer = Timer.scheduledTimer(withTimeInterval: self.interval, repeats: true) { [weak self] _ in
66
+ guard let self, self.enabled else { return }
67
+ self.quickScan()
68
+ }
69
+ // Deep scan on a slower cadence
70
+ deepTimer = Timer.scheduledTimer(withTimeInterval: deepInterval, repeats: true) { [weak self] _ in
71
+ guard let self, self.enabled else { return }
72
+ self.scan()
73
+ }
74
+ }
75
+
76
+ func stop() {
77
+ timer?.invalidate()
78
+ timer = nil
79
+ deepTimer?.invalidate()
80
+ deepTimer = nil
81
+ }
82
+
83
+ func setEnabled(_ on: Bool) {
84
+ enabled = on
85
+ prefs.ocrEnabled = on
86
+ if on && timer == nil {
87
+ start()
88
+ } else if !on {
89
+ stop()
90
+ }
91
+ }
92
+
93
+ // MARK: - Scan
94
+
95
+ /// Quick scan: AX-only text extraction for topmost windows (called every 60s).
96
+ /// No screenshots, no Vision OCR — nearly free.
97
+ func quickScan() {
98
+ guard !isScanning else { return }
99
+ DispatchQueue.main.async { self.isScanning = true }
100
+
101
+ queue.async { [weak self] in
102
+ guard let self else { return }
103
+ let windows = Array(self.enumerateWindows().prefix(self.prefs.ocrQuickLimit))
104
+ let previousResults = self.results
105
+ var fresh = previousResults // carry forward all existing results
106
+ var changed = 0
107
+
108
+ for win in windows {
109
+ if let axResult = self.axExtractor.extract(pid: win.pid, wid: win.wid) {
110
+ let textHash = SHA256.hash(data: Data(axResult.fullText.utf8))
111
+ let hashData = Data(textHash)
112
+
113
+ if hashData == self.lastAXHashes[win.wid], previousResults[win.wid] != nil {
114
+ // Unchanged — carry forward cached result
115
+ continue
116
+ }
117
+
118
+ // Changed — build new result
119
+ self.lastAXHashes[win.wid] = hashData
120
+ changed += 1
121
+
122
+ let blocks = axResult.texts.map { text in
123
+ OcrTextBlock(text: text, confidence: 1.0, boundingBox: .zero)
124
+ }
125
+ let result = OcrWindowResult(
126
+ wid: win.wid,
127
+ app: win.app,
128
+ title: win.title,
129
+ frame: win.frame,
130
+ texts: blocks,
131
+ fullText: axResult.fullText,
132
+ timestamp: Date(),
133
+ source: .accessibility
134
+ )
135
+ fresh[win.wid] = result
136
+ OcrStore.shared.insert(results: [result])
137
+ }
138
+ }
139
+
140
+ DiagnosticLog.shared.info("OcrModel: quick scan (AX) \(windows.count)/\(self.enumerateWindows().count) windows, \(changed) changed")
141
+
142
+ DispatchQueue.main.async {
143
+ self.results = fresh
144
+ self.isScanning = false
145
+ }
146
+
147
+ EventBus.shared.post(.ocrScanComplete(
148
+ windowCount: fresh.count,
149
+ totalBlocks: fresh.values.reduce(0) { $0 + $1.texts.count }
150
+ ))
151
+ }
152
+ }
153
+
154
+ /// Deep scan: all visible windows (called every 2h, or manually via ocr.scan)
155
+ /// Uses a budget to limit how many windows get OCR'd per tick.
156
+ func scan() {
157
+ guard !isScanning else { return }
158
+ DispatchQueue.main.async { self.isScanning = true }
159
+ scanGeneration += 1
160
+ let generation = scanGeneration
161
+
162
+ queue.async { [weak self] in
163
+ guard let self else { return }
164
+ var windows = self.enumerateWindows()
165
+ let limit = self.prefs.ocrDeepLimit
166
+ if windows.count > limit {
167
+ windows = Array(windows.prefix(limit))
168
+ }
169
+
170
+ let previousResults = self.results
171
+ var newHashes: [UInt32: Data] = [:]
172
+ var changedWindows: [WindowEntry] = []
173
+ var unchangedWindows: [WindowEntry] = []
174
+
175
+ // Phase 1: capture + hash all windows (cheap)
176
+ for win in windows {
177
+ if let cgImage = CGWindowListCreateImage(
178
+ .null,
179
+ .optionIncludingWindow,
180
+ CGWindowID(win.wid),
181
+ [.boundsIgnoreFraming, .bestResolution]
182
+ ) {
183
+ let hash = self.imageHash(cgImage)
184
+ newHashes[win.wid] = hash
185
+
186
+ if hash == self.imageHashes[win.wid], previousResults[win.wid] != nil {
187
+ unchangedWindows.append(win)
188
+ } else {
189
+ changedWindows.append(win)
190
+ }
191
+ }
192
+ }
193
+
194
+ // Phase 2: budget which windows actually get OCR'd
195
+ let budget = self.prefs.ocrDeepBudget
196
+ let changedBudgeted = Array(changedWindows.prefix(budget))
197
+ let remaining = max(0, budget - changedBudgeted.count)
198
+
199
+ // Sort unchanged by lastScanned ascending (nil = stalest = highest priority)
200
+ let stalestUnchanged = unchangedWindows.sorted { a, b in
201
+ let aDate = self.lastScanned[a.wid] ?? .distantPast
202
+ let bDate = self.lastScanned[b.wid] ?? .distantPast
203
+ return aDate < bDate
204
+ }
205
+ let unchangedBudgeted = Array(stalestUnchanged.prefix(remaining))
206
+ let toScan = changedBudgeted + unchangedBudgeted
207
+ let toScanWids = Set(toScan.map(\.wid))
208
+
209
+ // Carry forward cached results for non-budgeted windows
210
+ var fresh: [UInt32: OcrWindowResult] = [:]
211
+ var totalBlocks = 0
212
+ for win in windows {
213
+ if !toScanWids.contains(win.wid), let prev = previousResults[win.wid] {
214
+ fresh[win.wid] = prev
215
+ totalBlocks += prev.texts.count
216
+ }
217
+ }
218
+
219
+ self.imageHashes = newHashes
220
+
221
+ DiagnosticLog.shared.info("OcrModel: deep scan budget=\(budget), changed=\(changedWindows.count), scanning=\(toScan.count)/\(windows.count)")
222
+
223
+ // Phase 3: OCR only the budgeted windows
224
+ self.processNextWindow(
225
+ windows: toScan,
226
+ index: 0,
227
+ generation: generation,
228
+ previousResults: previousResults,
229
+ fresh: fresh,
230
+ newHashes: newHashes,
231
+ totalBlocks: totalBlocks,
232
+ changedResults: [],
233
+ updateLastScanned: true
234
+ )
235
+ }
236
+ }
237
+
238
+ /// Process one window at a time, yielding back to the queue between each.
239
+ /// This lets GCD schedule higher-priority work between windows.
240
+ private func processNextWindow(
241
+ windows: [WindowEntry],
242
+ index: Int,
243
+ generation: Int,
244
+ previousResults: [UInt32: OcrWindowResult],
245
+ fresh: [UInt32: OcrWindowResult],
246
+ newHashes: [UInt32: Data],
247
+ totalBlocks: Int,
248
+ changedResults: [OcrWindowResult],
249
+ updateLastScanned: Bool = false
250
+ ) {
251
+ // Stale scan — a newer one started, abandon this one
252
+ guard generation == scanGeneration else {
253
+ DispatchQueue.main.async { self.isScanning = false }
254
+ return
255
+ }
256
+
257
+ // All windows processed — publish results & persist diffs
258
+ guard index < windows.count else {
259
+ self.imageHashes = newHashes
260
+
261
+ if !changedResults.isEmpty {
262
+ OcrStore.shared.insert(results: changedResults)
263
+ }
264
+
265
+ DispatchQueue.main.async {
266
+ self.results = fresh
267
+ self.isScanning = false
268
+ }
269
+
270
+ EventBus.shared.post(.ocrScanComplete(
271
+ windowCount: fresh.count,
272
+ totalBlocks: totalBlocks
273
+ ))
274
+ return
275
+ }
276
+
277
+ var fresh = fresh
278
+ var newHashes = newHashes
279
+ var totalBlocks = totalBlocks
280
+ var changedResults = changedResults
281
+
282
+ let win = windows[index]
283
+
284
+ if let cgImage = CGWindowListCreateImage(
285
+ .null,
286
+ .optionIncludingWindow,
287
+ CGWindowID(win.wid),
288
+ [.boundsIgnoreFraming, .bestResolution]
289
+ ) {
290
+ let hash = imageHash(cgImage)
291
+ newHashes[win.wid] = hash
292
+
293
+ if hash == imageHashes[win.wid], let prev = previousResults[win.wid] {
294
+ // Unchanged — reuse cached result
295
+ fresh[win.wid] = prev
296
+ totalBlocks += prev.texts.count
297
+ } else {
298
+ // Changed — run OCR
299
+ let blocks = recognizeText(in: cgImage)
300
+ let fullText = blocks.map(\.text).joined(separator: "\n")
301
+ totalBlocks += blocks.count
302
+
303
+ let result = OcrWindowResult(
304
+ wid: win.wid,
305
+ app: win.app,
306
+ title: win.title,
307
+ frame: win.frame,
308
+ texts: blocks,
309
+ fullText: fullText,
310
+ timestamp: Date(),
311
+ source: .ocr
312
+ )
313
+ fresh[win.wid] = result
314
+
315
+ // Text-level dedup: if OCR text is identical to previous, skip store insert
316
+ let textHash = Data(SHA256.hash(data: Data(fullText.utf8)))
317
+ if textHash != lastOCRTextHashes[win.wid] {
318
+ changedResults.append(result)
319
+ }
320
+ lastOCRTextHashes[win.wid] = textHash
321
+ }
322
+
323
+ if updateLastScanned {
324
+ self.lastScanned[win.wid] = Date()
325
+ }
326
+ }
327
+
328
+ // Throttle: 100ms delay between windows to reduce CPU bursts
329
+ queue.asyncAfter(deadline: .now() + 0.1) { [weak self] in
330
+ self?.processNextWindow(
331
+ windows: windows,
332
+ index: index + 1,
333
+ generation: generation,
334
+ previousResults: previousResults,
335
+ fresh: fresh,
336
+ newHashes: newHashes,
337
+ totalBlocks: totalBlocks,
338
+ changedResults: changedResults,
339
+ updateLastScanned: updateLastScanned
340
+ )
341
+ }
342
+ }
343
+
344
+ // MARK: - Window Enumeration
345
+
346
+ private func enumerateWindows() -> [WindowEntry] {
347
+ guard let list = CGWindowListCopyWindowInfo(
348
+ [.optionOnScreenOnly, .excludeDesktopElements],
349
+ kCGNullWindowID
350
+ ) as? [[String: Any]] else { return [] }
351
+
352
+ var entries: [WindowEntry] = []
353
+
354
+ for info in list {
355
+ guard let wid = info[kCGWindowNumber as String] as? UInt32,
356
+ let ownerName = info[kCGWindowOwnerName as String] as? String,
357
+ let pid = info[kCGWindowOwnerPID as String] as? Int32,
358
+ let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
359
+ else { continue }
360
+
361
+ // Skip own windows
362
+ guard pid != myPid else { continue }
363
+
364
+ var rect = CGRect.zero
365
+ guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
366
+ rect.width >= 50, rect.height >= 50 else { continue }
367
+
368
+ let title = info[kCGWindowName as String] as? String ?? ""
369
+ let layer = info[kCGWindowLayer as String] as? Int ?? 0
370
+ guard layer == 0 else { continue }
371
+
372
+ let frame = WindowFrame(
373
+ x: Double(rect.origin.x),
374
+ y: Double(rect.origin.y),
375
+ w: Double(rect.width),
376
+ h: Double(rect.height)
377
+ )
378
+
379
+ entries.append(WindowEntry(
380
+ wid: wid,
381
+ app: ownerName,
382
+ pid: pid,
383
+ title: title,
384
+ frame: frame,
385
+ spaceIds: [],
386
+ isOnScreen: true,
387
+ latticesSession: nil
388
+ ))
389
+ }
390
+
391
+ return entries
392
+ }
393
+
394
+ // MARK: - Image Hashing
395
+
396
+ private func imageHash(_ image: CGImage) -> Data {
397
+ guard let dataProvider = image.dataProvider,
398
+ let data = dataProvider.data as Data? else {
399
+ return Data()
400
+ }
401
+ let digest = SHA256.hash(data: data as Data)
402
+ return Data(digest)
403
+ }
404
+
405
+ // MARK: - Vision OCR
406
+
407
+ private func recognizeText(in image: CGImage) -> [OcrTextBlock] {
408
+ let handler = VNImageRequestHandler(cgImage: image, options: [:])
409
+ let request = VNRecognizeTextRequest()
410
+ request.recognitionLevel = prefs.ocrAccuracy == "fast" ? .fast : .accurate
411
+ request.usesLanguageCorrection = true
412
+
413
+ do {
414
+ try handler.perform([request])
415
+ } catch {
416
+ return []
417
+ }
418
+
419
+ guard let observations = request.results else { return [] }
420
+
421
+ return observations.compactMap { obs in
422
+ guard let candidate = obs.topCandidates(1).first else { return nil }
423
+ return OcrTextBlock(
424
+ text: candidate.string,
425
+ confidence: candidate.confidence,
426
+ boundingBox: obs.boundingBox
427
+ )
428
+ }
429
+ }
430
+ }