@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.
- package/README.md +155 -0
- package/app/Lattices.app/Contents/Info.plist +24 -0
- package/app/Package.swift +13 -0
- package/app/Sources/AccessibilityTextExtractor.swift +111 -0
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +10 -0
- package/app/Sources/AppDelegate.swift +242 -0
- package/app/Sources/AppShellView.swift +62 -0
- package/app/Sources/AppTypeClassifier.swift +70 -0
- package/app/Sources/AppWindowShell.swift +63 -0
- package/app/Sources/CheatSheetHUD.swift +332 -0
- package/app/Sources/CommandModeState.swift +1362 -0
- package/app/Sources/CommandModeView.swift +1405 -0
- package/app/Sources/CommandModeWindow.swift +192 -0
- package/app/Sources/CommandPaletteView.swift +307 -0
- package/app/Sources/CommandPaletteWindow.swift +134 -0
- package/app/Sources/DaemonProtocol.swift +101 -0
- package/app/Sources/DaemonServer.swift +414 -0
- package/app/Sources/DesktopModel.swift +149 -0
- package/app/Sources/DesktopModelTypes.swift +71 -0
- package/app/Sources/DiagnosticLog.swift +271 -0
- package/app/Sources/EventBus.swift +30 -0
- package/app/Sources/HotkeyManager.swift +254 -0
- package/app/Sources/HotkeyStore.swift +338 -0
- package/app/Sources/InventoryManager.swift +35 -0
- package/app/Sources/InventoryPath.swift +43 -0
- package/app/Sources/KeyRecorderView.swift +210 -0
- package/app/Sources/LatticesApi.swift +1234 -0
- package/app/Sources/LayerBezel.swift +203 -0
- package/app/Sources/MainView.swift +479 -0
- package/app/Sources/MainWindow.swift +83 -0
- package/app/Sources/OcrModel.swift +430 -0
- package/app/Sources/OcrStore.swift +329 -0
- package/app/Sources/OmniSearchState.swift +283 -0
- package/app/Sources/OmniSearchView.swift +288 -0
- package/app/Sources/OmniSearchWindow.swift +105 -0
- package/app/Sources/OrphanRow.swift +129 -0
- package/app/Sources/PaletteCommand.swift +419 -0
- package/app/Sources/PermissionChecker.swift +125 -0
- package/app/Sources/Preferences.swift +99 -0
- package/app/Sources/ProcessModel.swift +199 -0
- package/app/Sources/ProcessQuery.swift +151 -0
- package/app/Sources/Project.swift +28 -0
- package/app/Sources/ProjectRow.swift +368 -0
- package/app/Sources/ProjectScanner.swift +128 -0
- package/app/Sources/ScreenMapState.swift +2387 -0
- package/app/Sources/ScreenMapView.swift +2820 -0
- package/app/Sources/ScreenMapWindowController.swift +89 -0
- package/app/Sources/SessionManager.swift +72 -0
- package/app/Sources/SettingsView.swift +1064 -0
- package/app/Sources/SettingsWindow.swift +20 -0
- package/app/Sources/TabGroupRow.swift +178 -0
- package/app/Sources/Terminal.swift +259 -0
- package/app/Sources/TerminalQuery.swift +156 -0
- package/app/Sources/TerminalSynthesizer.swift +200 -0
- package/app/Sources/Theme.swift +163 -0
- package/app/Sources/TilePickerView.swift +209 -0
- package/app/Sources/TmuxModel.swift +53 -0
- package/app/Sources/TmuxQuery.swift +81 -0
- package/app/Sources/WindowTiler.swift +1778 -0
- package/app/Sources/WorkspaceManager.swift +575 -0
- package/bin/client.js +4 -0
- package/bin/daemon-client.js +187 -0
- package/bin/lattices-app.js +221 -0
- package/bin/lattices.js +1551 -0
- package/docs/api.md +924 -0
- package/docs/app.md +297 -0
- package/docs/concepts.md +135 -0
- package/docs/config.md +245 -0
- package/docs/layers.md +410 -0
- package/docs/ocr.md +185 -0
- package/docs/overview.md +94 -0
- package/docs/quickstart.md +75 -0
- 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
|
+
}
|