@arach/lattices 0.1.0 → 0.2.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/README.md +101 -90
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +1 -40
- package/app/Sources/AppDelegate.swift +154 -24
- package/app/Sources/CheatSheetHUD.swift +1 -0
- package/app/Sources/CommandModeState.swift +40 -19
- package/app/Sources/CommandModeView.swift +27 -2
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DiagnosticLog.swift +19 -1
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HotkeyManager.swift +1 -0
- package/app/Sources/HotkeyStore.swift +9 -1
- package/app/Sources/LatticesApi.swift +210 -0
- package/app/Sources/MainView.swift +46 -86
- package/app/Sources/MainWindow.swift +13 -0
- package/app/Sources/OcrModel.swift +309 -0
- package/app/Sources/OcrStore.swift +295 -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/PaletteCommand.swift +11 -1
- package/app/Sources/PermissionChecker.swift +12 -2
- package/app/Sources/Preferences.swift +44 -0
- package/app/Sources/ScreenMapState.swift +7 -17
- package/app/Sources/ScreenMapView.swift +3 -0
- package/app/Sources/SettingsView.swift +534 -122
- package/app/Sources/Theme.swift +39 -0
- package/app/Sources/WindowTiler.swift +59 -56
- package/bin/lattices-app.js +23 -7
- package/bin/lattices.js +123 -0
- package/docs/api.md +390 -249
- package/docs/app.md +75 -28
- package/docs/concepts.md +45 -136
- package/docs/config.md +8 -7
- package/docs/layers.md +16 -18
- package/docs/ocr.md +185 -0
- package/docs/overview.md +39 -34
- package/docs/quickstart.md +34 -35
- package/package.json +6 -2
|
@@ -71,16 +71,7 @@ struct MainView: View {
|
|
|
71
71
|
Spacer()
|
|
72
72
|
|
|
73
73
|
headerButton(icon: "arrow.up.left.and.arrow.down.right") {
|
|
74
|
-
|
|
75
|
-
for window in NSApp.windows {
|
|
76
|
-
if window is NSPanel, window.isVisible,
|
|
77
|
-
!CommandPaletteWindow.shared.isVisible || window.frame.width < 500 {
|
|
78
|
-
// MenuBarExtra panels are small (~380px); command palette is 540px
|
|
79
|
-
if window.frame.width <= 400 {
|
|
80
|
-
window.orderOut(nil)
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
74
|
+
(NSApp.delegate as? AppDelegate)?.dismissPopover()
|
|
84
75
|
MainWindow.shared.show()
|
|
85
76
|
}
|
|
86
77
|
headerButton(icon: "arrow.clockwise") { scanner.scan(); inventory.refresh() }
|
|
@@ -190,8 +181,8 @@ struct MainView: View {
|
|
|
190
181
|
.fill(Palette.border)
|
|
191
182
|
.frame(height: 0.5)
|
|
192
183
|
|
|
193
|
-
//
|
|
194
|
-
|
|
184
|
+
// Actions footer
|
|
185
|
+
actionsSection
|
|
195
186
|
}
|
|
196
187
|
}
|
|
197
188
|
|
|
@@ -253,96 +244,65 @@ struct MainView: View {
|
|
|
253
244
|
}
|
|
254
245
|
}
|
|
255
246
|
|
|
256
|
-
// MARK: -
|
|
247
|
+
// MARK: - Actions footer
|
|
257
248
|
|
|
258
|
-
private var
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
Image(systemName: "gearshape")
|
|
263
|
-
.font(.system(size: 10, weight: .medium))
|
|
264
|
-
.foregroundColor(Palette.textMuted)
|
|
249
|
+
private var actionsSection: some View {
|
|
250
|
+
VStack(spacing: 0) {
|
|
251
|
+
ActionRow(shortcut: "1", label: "Command Palette", hotkey: hotkeyLabel(.palette), icon: "command", accentColor: Palette.running) {
|
|
252
|
+
CommandPaletteWindow.shared.toggle()
|
|
265
253
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
254
|
+
ActionRow(shortcut: "2", label: "Screen Map", hotkey: hotkeyLabel(.screenMap), icon: "rectangle.3.group") {
|
|
255
|
+
ScreenMapWindowController.shared.toggle()
|
|
256
|
+
}
|
|
257
|
+
ActionRow(shortcut: "3", label: "Desktop Inventory", hotkey: hotkeyLabel(.desktopInventory), icon: "rectangle.split.2x1") {
|
|
258
|
+
CommandModeWindow.shared.toggle()
|
|
259
|
+
}
|
|
260
|
+
ActionRow(shortcut: "4", label: "Window Bezel", hotkey: hotkeyLabel(.bezel), icon: "macwindow") {
|
|
261
|
+
WindowBezel.showBezelForFrontmostWindow()
|
|
262
|
+
}
|
|
263
|
+
ActionRow(shortcut: "5", label: "Cheat Sheet", hotkey: hotkeyLabel(.cheatSheet), icon: "keyboard") {
|
|
264
|
+
CheatSheetHUD.shared.toggle()
|
|
265
|
+
}
|
|
266
|
+
ActionRow(shortcut: "6", label: "Omni Search", hotkey: hotkeyLabel(.omniSearch), icon: "magnifyingglass", accentColor: Palette.running) {
|
|
267
|
+
OmniSearchWindow.shared.toggle()
|
|
281
268
|
}
|
|
282
|
-
.buttonStyle(.plain)
|
|
283
|
-
.help(!permChecker.allGranted ? "Permissions missing — open diagnostics" : "Toggle diagnostics")
|
|
284
269
|
|
|
285
270
|
Rectangle()
|
|
286
271
|
.fill(Palette.border)
|
|
287
|
-
.frame(
|
|
288
|
-
.padding(.horizontal,
|
|
289
|
-
|
|
290
|
-
// Config summary — keys dim, values white
|
|
291
|
-
statusLine
|
|
292
|
-
|
|
293
|
-
Spacer()
|
|
272
|
+
.frame(height: 0.5)
|
|
273
|
+
.padding(.horizontal, 10)
|
|
294
274
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
275
|
+
ActionRow(shortcut: "S", label: "Settings", icon: "gearshape") {
|
|
276
|
+
SettingsWindowController.shared.show()
|
|
277
|
+
}
|
|
278
|
+
HStack(spacing: 0) {
|
|
279
|
+
ActionRow(shortcut: "D", label: "Diagnostics", icon: "stethoscope") {
|
|
280
|
+
DiagnosticWindow.shared.toggle()
|
|
281
|
+
}
|
|
282
|
+
if !permChecker.allGranted {
|
|
283
|
+
Circle()
|
|
284
|
+
.fill(Palette.detach)
|
|
285
|
+
.frame(width: 6, height: 6)
|
|
286
|
+
.padding(.trailing, 14)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
300
289
|
|
|
301
290
|
Rectangle()
|
|
302
291
|
.fill(Palette.border)
|
|
303
|
-
.frame(
|
|
304
|
-
.padding(.horizontal,
|
|
292
|
+
.frame(height: 0.5)
|
|
293
|
+
.padding(.horizontal, 10)
|
|
305
294
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
Image(systemName: "power")
|
|
309
|
-
.font(.system(size: 9, weight: .medium))
|
|
310
|
-
.foregroundColor(Palette.textMuted)
|
|
295
|
+
ActionRow(shortcut: "Q", label: "Quit", icon: "power", accentColor: Palette.kill) {
|
|
296
|
+
NSApp.terminate(nil)
|
|
311
297
|
}
|
|
312
|
-
.buttonStyle(.plain)
|
|
313
|
-
.help("Quit lattices")
|
|
314
298
|
}
|
|
315
|
-
.padding(.
|
|
316
|
-
.padding(.vertical, 7)
|
|
299
|
+
.padding(.vertical, 4)
|
|
317
300
|
.background(Palette.surface.opacity(0.4))
|
|
318
301
|
}
|
|
319
302
|
|
|
320
|
-
private
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
statusDot
|
|
324
|
-
statusPair("mode", prefs.mode.rawValue)
|
|
325
|
-
statusDot
|
|
326
|
-
statusPair("home", "~/\((prefs.scanRoot as NSString).lastPathComponent)")
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
private func statusPair(_ key: String, _ value: String) -> some View {
|
|
331
|
-
HStack(spacing: 3) {
|
|
332
|
-
Text(key + ":")
|
|
333
|
-
.font(Typo.mono(9))
|
|
334
|
-
.foregroundColor(Palette.textMuted)
|
|
335
|
-
Text(value)
|
|
336
|
-
.font(Typo.mono(9))
|
|
337
|
-
.foregroundColor(Palette.text)
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
private var statusDot: some View {
|
|
342
|
-
Circle()
|
|
343
|
-
.fill(Palette.textMuted)
|
|
344
|
-
.frame(width: 2, height: 2)
|
|
345
|
-
.padding(.horizontal, 4)
|
|
303
|
+
private func hotkeyLabel(_ action: HotkeyAction) -> String? {
|
|
304
|
+
guard let binding = HotkeyStore.shared.bindings[action] else { return nil }
|
|
305
|
+
return binding.displayParts.joined(separator: "")
|
|
346
306
|
}
|
|
347
307
|
|
|
348
308
|
// MARK: - Empty state
|
|
@@ -7,6 +7,7 @@ final class MainWindow {
|
|
|
7
7
|
static let shared = MainWindow()
|
|
8
8
|
|
|
9
9
|
private var window: NSWindow?
|
|
10
|
+
private var keyMonitor: Any?
|
|
10
11
|
|
|
11
12
|
var isVisible: Bool { window?.isVisible ?? false }
|
|
12
13
|
|
|
@@ -61,10 +62,22 @@ final class MainWindow {
|
|
|
61
62
|
|
|
62
63
|
window = w
|
|
63
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
|
+
}
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
func close() {
|
|
67
76
|
window?.orderOut(nil)
|
|
77
|
+
if let monitor = keyMonitor {
|
|
78
|
+
NSEvent.removeMonitor(monitor)
|
|
79
|
+
keyMonitor = nil
|
|
80
|
+
}
|
|
68
81
|
AppDelegate.updateActivationPolicy()
|
|
69
82
|
}
|
|
70
83
|
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import CryptoKit
|
|
3
|
+
import Vision
|
|
4
|
+
|
|
5
|
+
// MARK: - Data Types
|
|
6
|
+
|
|
7
|
+
struct OcrTextBlock {
|
|
8
|
+
let text: String
|
|
9
|
+
let confidence: Float // 0.0–1.0
|
|
10
|
+
let boundingBox: CGRect // normalized coordinates within window
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
struct OcrWindowResult {
|
|
14
|
+
let wid: UInt32
|
|
15
|
+
let app: String
|
|
16
|
+
let title: String
|
|
17
|
+
let frame: WindowFrame
|
|
18
|
+
let texts: [OcrTextBlock]
|
|
19
|
+
let fullText: String
|
|
20
|
+
let timestamp: Date
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// MARK: - OCR Scanner
|
|
24
|
+
|
|
25
|
+
final class OcrModel: ObservableObject {
|
|
26
|
+
static let shared = OcrModel()
|
|
27
|
+
|
|
28
|
+
@Published private(set) var results: [UInt32: OcrWindowResult] = [:]
|
|
29
|
+
@Published private(set) var isScanning: Bool = false
|
|
30
|
+
@Published var interval: TimeInterval = 60
|
|
31
|
+
@Published var enabled: Bool = true
|
|
32
|
+
|
|
33
|
+
private var timer: Timer?
|
|
34
|
+
private var deepTimer: Timer?
|
|
35
|
+
private let queue = DispatchQueue(label: "com.arach.lattices.ocr", qos: .background)
|
|
36
|
+
private var imageHashes: [UInt32: Data] = [:]
|
|
37
|
+
private var scanGeneration: Int = 0
|
|
38
|
+
|
|
39
|
+
private let myPid = ProcessInfo.processInfo.processIdentifier
|
|
40
|
+
|
|
41
|
+
private var prefs: Preferences { Preferences.shared }
|
|
42
|
+
|
|
43
|
+
func start(interval: TimeInterval? = nil) {
|
|
44
|
+
guard timer == nil else { return }
|
|
45
|
+
if let interval { self.interval = interval }
|
|
46
|
+
self.interval = prefs.ocrQuickInterval
|
|
47
|
+
self.enabled = prefs.ocrEnabled
|
|
48
|
+
guard enabled else {
|
|
49
|
+
DiagnosticLog.shared.info("OcrModel: disabled by user preference")
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
let deepInterval = prefs.ocrDeepInterval
|
|
53
|
+
// Defer initial scan — let the first timer tick handle it (grace period on launch)
|
|
54
|
+
DiagnosticLog.shared.info("OcrModel: starting (quick=\(self.interval)s/\(prefs.ocrQuickLimit)win, deep=\(deepInterval)s/\(prefs.ocrDeepLimit)win)")
|
|
55
|
+
timer = Timer.scheduledTimer(withTimeInterval: self.interval, repeats: true) { [weak self] _ in
|
|
56
|
+
guard let self, self.enabled else { return }
|
|
57
|
+
self.quickScan()
|
|
58
|
+
}
|
|
59
|
+
// Deep scan on a slower cadence
|
|
60
|
+
deepTimer = Timer.scheduledTimer(withTimeInterval: deepInterval, repeats: true) { [weak self] _ in
|
|
61
|
+
guard let self, self.enabled else { return }
|
|
62
|
+
self.scan()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func stop() {
|
|
67
|
+
timer?.invalidate()
|
|
68
|
+
timer = nil
|
|
69
|
+
deepTimer?.invalidate()
|
|
70
|
+
deepTimer = nil
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func setEnabled(_ on: Bool) {
|
|
74
|
+
enabled = on
|
|
75
|
+
prefs.ocrEnabled = on
|
|
76
|
+
if on && timer == nil {
|
|
77
|
+
start()
|
|
78
|
+
} else if !on {
|
|
79
|
+
stop()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// MARK: - Scan
|
|
84
|
+
|
|
85
|
+
/// Quick scan: only the topmost frontmost windows (called every 60s)
|
|
86
|
+
func quickScan() {
|
|
87
|
+
scanWithLimit(prefs.ocrQuickLimit)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Deep scan: all visible windows (called every 2h, or manually via ocr.scan)
|
|
91
|
+
func scan() {
|
|
92
|
+
scanWithLimit(prefs.ocrDeepLimit)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private func scanWithLimit(_ limit: Int) {
|
|
96
|
+
guard !isScanning else { return }
|
|
97
|
+
DispatchQueue.main.async { self.isScanning = true }
|
|
98
|
+
scanGeneration += 1
|
|
99
|
+
let generation = scanGeneration
|
|
100
|
+
|
|
101
|
+
queue.async { [weak self] in
|
|
102
|
+
guard let self else { return }
|
|
103
|
+
var windows = self.enumerateWindows()
|
|
104
|
+
|
|
105
|
+
// Cap windows — CGWindowList returns front-to-back order,
|
|
106
|
+
// so prefix gives us the topmost/frontmost windows first
|
|
107
|
+
if windows.count > limit {
|
|
108
|
+
windows = Array(windows.prefix(limit))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// For quick scans, merge new results into existing rather than replacing
|
|
112
|
+
let previousResults = self.results
|
|
113
|
+
let fresh: [UInt32: OcrWindowResult] = limit < self.prefs.ocrDeepLimit ? previousResults : [:]
|
|
114
|
+
let newHashes: [UInt32: Data] = [:]
|
|
115
|
+
let totalBlocks = 0
|
|
116
|
+
|
|
117
|
+
self.processNextWindow(
|
|
118
|
+
windows: windows,
|
|
119
|
+
index: 0,
|
|
120
|
+
generation: generation,
|
|
121
|
+
previousResults: previousResults,
|
|
122
|
+
fresh: fresh,
|
|
123
|
+
newHashes: newHashes,
|
|
124
|
+
totalBlocks: totalBlocks,
|
|
125
|
+
changedResults: []
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Process one window at a time, yielding back to the queue between each.
|
|
131
|
+
/// This lets GCD schedule higher-priority work between windows.
|
|
132
|
+
private func processNextWindow(
|
|
133
|
+
windows: [WindowEntry],
|
|
134
|
+
index: Int,
|
|
135
|
+
generation: Int,
|
|
136
|
+
previousResults: [UInt32: OcrWindowResult],
|
|
137
|
+
fresh: [UInt32: OcrWindowResult],
|
|
138
|
+
newHashes: [UInt32: Data],
|
|
139
|
+
totalBlocks: Int,
|
|
140
|
+
changedResults: [OcrWindowResult]
|
|
141
|
+
) {
|
|
142
|
+
// Stale scan — a newer one started, abandon this one
|
|
143
|
+
guard generation == scanGeneration else {
|
|
144
|
+
DispatchQueue.main.async { self.isScanning = false }
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// All windows processed — publish results & persist diffs
|
|
149
|
+
guard index < windows.count else {
|
|
150
|
+
self.imageHashes = newHashes
|
|
151
|
+
|
|
152
|
+
if !changedResults.isEmpty {
|
|
153
|
+
OcrStore.shared.insert(results: changedResults)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
DispatchQueue.main.async {
|
|
157
|
+
self.results = fresh
|
|
158
|
+
self.isScanning = false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
EventBus.shared.post(.ocrScanComplete(
|
|
162
|
+
windowCount: fresh.count,
|
|
163
|
+
totalBlocks: totalBlocks
|
|
164
|
+
))
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
var fresh = fresh
|
|
169
|
+
var newHashes = newHashes
|
|
170
|
+
var totalBlocks = totalBlocks
|
|
171
|
+
var changedResults = changedResults
|
|
172
|
+
|
|
173
|
+
let win = windows[index]
|
|
174
|
+
|
|
175
|
+
if let cgImage = CGWindowListCreateImage(
|
|
176
|
+
.null,
|
|
177
|
+
.optionIncludingWindow,
|
|
178
|
+
CGWindowID(win.wid),
|
|
179
|
+
[.boundsIgnoreFraming, .bestResolution]
|
|
180
|
+
) {
|
|
181
|
+
let hash = imageHash(cgImage)
|
|
182
|
+
newHashes[win.wid] = hash
|
|
183
|
+
|
|
184
|
+
if hash == imageHashes[win.wid], let prev = previousResults[win.wid] {
|
|
185
|
+
// Unchanged — reuse cached result
|
|
186
|
+
fresh[win.wid] = prev
|
|
187
|
+
totalBlocks += prev.texts.count
|
|
188
|
+
} else {
|
|
189
|
+
// Changed — run OCR
|
|
190
|
+
let blocks = recognizeText(in: cgImage)
|
|
191
|
+
let fullText = blocks.map(\.text).joined(separator: "\n")
|
|
192
|
+
totalBlocks += blocks.count
|
|
193
|
+
|
|
194
|
+
let result = OcrWindowResult(
|
|
195
|
+
wid: win.wid,
|
|
196
|
+
app: win.app,
|
|
197
|
+
title: win.title,
|
|
198
|
+
frame: win.frame,
|
|
199
|
+
texts: blocks,
|
|
200
|
+
fullText: fullText,
|
|
201
|
+
timestamp: Date()
|
|
202
|
+
)
|
|
203
|
+
fresh[win.wid] = result
|
|
204
|
+
changedResults.append(result)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Throttle: 100ms delay between windows to reduce CPU bursts
|
|
209
|
+
queue.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
210
|
+
self?.processNextWindow(
|
|
211
|
+
windows: windows,
|
|
212
|
+
index: index + 1,
|
|
213
|
+
generation: generation,
|
|
214
|
+
previousResults: previousResults,
|
|
215
|
+
fresh: fresh,
|
|
216
|
+
newHashes: newHashes,
|
|
217
|
+
totalBlocks: totalBlocks,
|
|
218
|
+
changedResults: changedResults
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// MARK: - Window Enumeration
|
|
224
|
+
|
|
225
|
+
private func enumerateWindows() -> [WindowEntry] {
|
|
226
|
+
guard let list = CGWindowListCopyWindowInfo(
|
|
227
|
+
[.optionOnScreenOnly, .excludeDesktopElements],
|
|
228
|
+
kCGNullWindowID
|
|
229
|
+
) as? [[String: Any]] else { return [] }
|
|
230
|
+
|
|
231
|
+
var entries: [WindowEntry] = []
|
|
232
|
+
|
|
233
|
+
for info in list {
|
|
234
|
+
guard let wid = info[kCGWindowNumber as String] as? UInt32,
|
|
235
|
+
let ownerName = info[kCGWindowOwnerName as String] as? String,
|
|
236
|
+
let pid = info[kCGWindowOwnerPID as String] as? Int32,
|
|
237
|
+
let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
|
|
238
|
+
else { continue }
|
|
239
|
+
|
|
240
|
+
// Skip own windows
|
|
241
|
+
guard pid != myPid else { continue }
|
|
242
|
+
|
|
243
|
+
var rect = CGRect.zero
|
|
244
|
+
guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
|
|
245
|
+
rect.width >= 50, rect.height >= 50 else { continue }
|
|
246
|
+
|
|
247
|
+
let title = info[kCGWindowName as String] as? String ?? ""
|
|
248
|
+
let layer = info[kCGWindowLayer as String] as? Int ?? 0
|
|
249
|
+
guard layer == 0 else { continue }
|
|
250
|
+
|
|
251
|
+
let frame = WindowFrame(
|
|
252
|
+
x: Double(rect.origin.x),
|
|
253
|
+
y: Double(rect.origin.y),
|
|
254
|
+
w: Double(rect.width),
|
|
255
|
+
h: Double(rect.height)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
entries.append(WindowEntry(
|
|
259
|
+
wid: wid,
|
|
260
|
+
app: ownerName,
|
|
261
|
+
pid: pid,
|
|
262
|
+
title: title,
|
|
263
|
+
frame: frame,
|
|
264
|
+
spaceIds: [],
|
|
265
|
+
isOnScreen: true,
|
|
266
|
+
latticesSession: nil
|
|
267
|
+
))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return entries
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// MARK: - Image Hashing
|
|
274
|
+
|
|
275
|
+
private func imageHash(_ image: CGImage) -> Data {
|
|
276
|
+
guard let dataProvider = image.dataProvider,
|
|
277
|
+
let data = dataProvider.data as Data? else {
|
|
278
|
+
return Data()
|
|
279
|
+
}
|
|
280
|
+
let digest = SHA256.hash(data: data as Data)
|
|
281
|
+
return Data(digest)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// MARK: - Vision OCR
|
|
285
|
+
|
|
286
|
+
private func recognizeText(in image: CGImage) -> [OcrTextBlock] {
|
|
287
|
+
let handler = VNImageRequestHandler(cgImage: image, options: [:])
|
|
288
|
+
let request = VNRecognizeTextRequest()
|
|
289
|
+
request.recognitionLevel = prefs.ocrAccuracy == "fast" ? .fast : .accurate
|
|
290
|
+
request.usesLanguageCorrection = true
|
|
291
|
+
|
|
292
|
+
do {
|
|
293
|
+
try handler.perform([request])
|
|
294
|
+
} catch {
|
|
295
|
+
return []
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
guard let observations = request.results else { return [] }
|
|
299
|
+
|
|
300
|
+
return observations.compactMap { obs in
|
|
301
|
+
guard let candidate = obs.topCandidates(1).first else { return nil }
|
|
302
|
+
return OcrTextBlock(
|
|
303
|
+
text: candidate.string,
|
|
304
|
+
confidence: candidate.confidence,
|
|
305
|
+
boundingBox: obs.boundingBox
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|