@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,101 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - Wire Format
|
|
4
|
+
|
|
5
|
+
struct DaemonRequest: Codable {
|
|
6
|
+
let id: String
|
|
7
|
+
let method: String
|
|
8
|
+
let params: JSON?
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
struct DaemonResponse: Codable {
|
|
12
|
+
let id: String
|
|
13
|
+
let result: JSON?
|
|
14
|
+
let error: String?
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
struct DaemonEvent: Codable {
|
|
18
|
+
let event: String
|
|
19
|
+
let data: JSON
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// MARK: - Dynamic JSON
|
|
23
|
+
|
|
24
|
+
enum JSON: Codable, Equatable {
|
|
25
|
+
case string(String)
|
|
26
|
+
case int(Int)
|
|
27
|
+
case double(Double)
|
|
28
|
+
case bool(Bool)
|
|
29
|
+
case array([JSON])
|
|
30
|
+
case object([String: JSON])
|
|
31
|
+
case null
|
|
32
|
+
|
|
33
|
+
// MARK: Codable
|
|
34
|
+
|
|
35
|
+
init(from decoder: Decoder) throws {
|
|
36
|
+
let container = try decoder.singleValueContainer()
|
|
37
|
+
|
|
38
|
+
if container.decodeNil() {
|
|
39
|
+
self = .null
|
|
40
|
+
} else if let b = try? container.decode(Bool.self) {
|
|
41
|
+
self = .bool(b)
|
|
42
|
+
} else if let i = try? container.decode(Int.self) {
|
|
43
|
+
self = .int(i)
|
|
44
|
+
} else if let d = try? container.decode(Double.self) {
|
|
45
|
+
self = .double(d)
|
|
46
|
+
} else if let s = try? container.decode(String.self) {
|
|
47
|
+
self = .string(s)
|
|
48
|
+
} else if let arr = try? container.decode([JSON].self) {
|
|
49
|
+
self = .array(arr)
|
|
50
|
+
} else if let obj = try? container.decode([String: JSON].self) {
|
|
51
|
+
self = .object(obj)
|
|
52
|
+
} else {
|
|
53
|
+
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode JSON value")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func encode(to encoder: Encoder) throws {
|
|
58
|
+
var container = encoder.singleValueContainer()
|
|
59
|
+
switch self {
|
|
60
|
+
case .string(let s): try container.encode(s)
|
|
61
|
+
case .int(let i): try container.encode(i)
|
|
62
|
+
case .double(let d): try container.encode(d)
|
|
63
|
+
case .bool(let b): try container.encode(b)
|
|
64
|
+
case .array(let a): try container.encode(a)
|
|
65
|
+
case .object(let o): try container.encode(o)
|
|
66
|
+
case .null: try container.encodeNil()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// MARK: Subscript helpers
|
|
71
|
+
|
|
72
|
+
subscript(key: String) -> JSON? {
|
|
73
|
+
guard case .object(let dict) = self else { return nil }
|
|
74
|
+
return dict[key]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
subscript(index: Int) -> JSON? {
|
|
78
|
+
guard case .array(let arr) = self, index >= 0, index < arr.count else { return nil }
|
|
79
|
+
return arr[index]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
var stringValue: String? {
|
|
83
|
+
guard case .string(let s) = self else { return nil }
|
|
84
|
+
return s
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
var intValue: Int? {
|
|
88
|
+
guard case .int(let i) = self else { return nil }
|
|
89
|
+
return i
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
var uint32Value: UInt32? {
|
|
93
|
+
guard case .int(let i) = self else { return nil }
|
|
94
|
+
return UInt32(i)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
var boolValue: Bool? {
|
|
98
|
+
guard case .bool(let b) = self else { return nil }
|
|
99
|
+
return b
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CommonCrypto
|
|
3
|
+
|
|
4
|
+
// MARK: - POSIX WebSocket Server
|
|
5
|
+
// NWListener is broken on macOS 26 (Tahoe) — EINVAL on any listener creation.
|
|
6
|
+
// This is a minimal POSIX-socket WebSocket server on 127.0.0.1:9399.
|
|
7
|
+
|
|
8
|
+
final class DaemonServer: ObservableObject {
|
|
9
|
+
static let shared = DaemonServer()
|
|
10
|
+
|
|
11
|
+
@Published var clientCount: Int = 0
|
|
12
|
+
@Published var isListening: Bool = false
|
|
13
|
+
|
|
14
|
+
private var serverFd: Int32 = -1
|
|
15
|
+
private var clients: [UUID: WebSocketClient] = [:]
|
|
16
|
+
private let lock = NSLock()
|
|
17
|
+
private let queue = DispatchQueue(label: "lattices.daemon", qos: .userInitiated)
|
|
18
|
+
private let encoder = JSONEncoder()
|
|
19
|
+
private let decoder = JSONDecoder()
|
|
20
|
+
private var acceptSource: DispatchSourceRead?
|
|
21
|
+
|
|
22
|
+
func start() {
|
|
23
|
+
let diag = DiagnosticLog.shared
|
|
24
|
+
|
|
25
|
+
// 1. Create TCP socket
|
|
26
|
+
serverFd = socket(AF_INET, SOCK_STREAM, 0)
|
|
27
|
+
guard serverFd >= 0 else {
|
|
28
|
+
diag.error("DaemonServer: socket() failed — errno \(errno)")
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// SO_REUSEADDR so we can restart quickly
|
|
33
|
+
var yes: Int32 = 1
|
|
34
|
+
setsockopt(serverFd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
|
|
35
|
+
|
|
36
|
+
// 2. Bind to 127.0.0.1:9399
|
|
37
|
+
var addr = sockaddr_in()
|
|
38
|
+
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
|
39
|
+
addr.sin_family = sa_family_t(AF_INET)
|
|
40
|
+
addr.sin_port = UInt16(9399).bigEndian
|
|
41
|
+
addr.sin_addr.s_addr = UInt32(0x7f000001).bigEndian // 127.0.0.1
|
|
42
|
+
|
|
43
|
+
let bindResult = withUnsafePointer(to: &addr) {
|
|
44
|
+
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
|
45
|
+
bind(serverFd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
guard bindResult == 0 else {
|
|
49
|
+
diag.error("DaemonServer: bind() failed — errno \(errno)")
|
|
50
|
+
close(serverFd)
|
|
51
|
+
serverFd = -1
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. Listen
|
|
56
|
+
guard listen(serverFd, 8) == 0 else {
|
|
57
|
+
diag.error("DaemonServer: listen() failed — errno \(errno)")
|
|
58
|
+
close(serverFd)
|
|
59
|
+
serverFd = -1
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Non-blocking
|
|
64
|
+
let flags = fcntl(serverFd, F_GETFL)
|
|
65
|
+
_ = fcntl(serverFd, F_SETFL, flags | O_NONBLOCK)
|
|
66
|
+
|
|
67
|
+
// 4. GCD dispatch source for accepting connections
|
|
68
|
+
let source = DispatchSource.makeReadSource(fileDescriptor: serverFd, queue: queue)
|
|
69
|
+
source.setEventHandler { [weak self] in self?.acceptConnection() }
|
|
70
|
+
source.setCancelHandler { [weak self] in
|
|
71
|
+
if let fd = self?.serverFd, fd >= 0 { close(fd) }
|
|
72
|
+
self?.serverFd = -1
|
|
73
|
+
}
|
|
74
|
+
source.resume()
|
|
75
|
+
acceptSource = source
|
|
76
|
+
|
|
77
|
+
DispatchQueue.main.async { self.isListening = true }
|
|
78
|
+
diag.success("DaemonServer: listening on ws://127.0.0.1:9399")
|
|
79
|
+
|
|
80
|
+
// Subscribe to EventBus for broadcasting
|
|
81
|
+
EventBus.shared.subscribe { [weak self] event in
|
|
82
|
+
self?.broadcastEvent(event)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func stop() {
|
|
87
|
+
acceptSource?.cancel()
|
|
88
|
+
acceptSource = nil
|
|
89
|
+
lock.lock()
|
|
90
|
+
for (_, client) in clients {
|
|
91
|
+
close(client.fd)
|
|
92
|
+
}
|
|
93
|
+
clients.removeAll()
|
|
94
|
+
lock.unlock()
|
|
95
|
+
DispatchQueue.main.async {
|
|
96
|
+
self.clientCount = 0
|
|
97
|
+
self.isListening = false
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func broadcast(_ event: DaemonEvent) {
|
|
102
|
+
guard let data = try? encoder.encode(event),
|
|
103
|
+
let text = String(data: data, encoding: .utf8) else { return }
|
|
104
|
+
lock.lock()
|
|
105
|
+
let snapshot = clients
|
|
106
|
+
lock.unlock()
|
|
107
|
+
for (_, client) in snapshot {
|
|
108
|
+
sendWebSocketText(text, to: client)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// MARK: - Accept
|
|
113
|
+
|
|
114
|
+
private func acceptConnection() {
|
|
115
|
+
var clientAddr = sockaddr_in()
|
|
116
|
+
var addrLen = socklen_t(MemoryLayout<sockaddr_in>.size)
|
|
117
|
+
let clientFd = withUnsafeMutablePointer(to: &clientAddr) {
|
|
118
|
+
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
|
119
|
+
accept(serverFd, $0, &addrLen)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
guard clientFd >= 0 else { return }
|
|
123
|
+
|
|
124
|
+
let id = UUID()
|
|
125
|
+
let client = WebSocketClient(id: id, fd: clientFd)
|
|
126
|
+
|
|
127
|
+
// Read the HTTP upgrade request
|
|
128
|
+
queue.async { [weak self] in
|
|
129
|
+
self?.performHandshake(client: client)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// MARK: - WebSocket Handshake
|
|
134
|
+
|
|
135
|
+
private func performHandshake(client: WebSocketClient) {
|
|
136
|
+
let diag = DiagnosticLog.shared
|
|
137
|
+
|
|
138
|
+
// Ensure blocking mode for handshake read
|
|
139
|
+
let curFlags = fcntl(client.fd, F_GETFL)
|
|
140
|
+
if curFlags & O_NONBLOCK != 0 {
|
|
141
|
+
_ = fcntl(client.fd, F_SETFL, curFlags & ~O_NONBLOCK)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Read HTTP request (up to 4KB)
|
|
145
|
+
var buf = [UInt8](repeating: 0, count: 4096)
|
|
146
|
+
let n = read(client.fd, &buf, buf.count)
|
|
147
|
+
guard n > 0 else {
|
|
148
|
+
close(client.fd)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let request = String(bytes: buf[..<n], encoding: .utf8) ?? ""
|
|
153
|
+
|
|
154
|
+
// Extract Sec-WebSocket-Key
|
|
155
|
+
guard let keyLine = request.split(separator: "\r\n").first(where: {
|
|
156
|
+
$0.lowercased().hasPrefix("sec-websocket-key:")
|
|
157
|
+
}) else {
|
|
158
|
+
close(client.fd)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
let key = keyLine.split(separator: ":", maxSplits: 1)[1].trimmingCharacters(in: .whitespaces)
|
|
162
|
+
|
|
163
|
+
// Compute accept key: Base64(SHA1(key + magic))
|
|
164
|
+
let magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
165
|
+
let combined = key + magic
|
|
166
|
+
let acceptKey = sha1Base64(combined)
|
|
167
|
+
|
|
168
|
+
// Send HTTP 101 response
|
|
169
|
+
let response = "HTTP/1.1 101 Switching Protocols\r\n" +
|
|
170
|
+
"Upgrade: websocket\r\n" +
|
|
171
|
+
"Connection: Upgrade\r\n" +
|
|
172
|
+
"Sec-WebSocket-Accept: \(acceptKey)\r\n\r\n"
|
|
173
|
+
let responseBytes = Array(response.utf8)
|
|
174
|
+
responseBytes.withUnsafeBufferPointer { ptr in
|
|
175
|
+
_ = write(client.fd, ptr.baseAddress!, ptr.count)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Register client
|
|
179
|
+
lock.lock()
|
|
180
|
+
clients[client.id] = client
|
|
181
|
+
let count = clients.count
|
|
182
|
+
lock.unlock()
|
|
183
|
+
DispatchQueue.main.async { self.clientCount = count }
|
|
184
|
+
diag.info("DaemonServer: client connected (\(count) total)")
|
|
185
|
+
|
|
186
|
+
// Start read loop
|
|
187
|
+
readLoop(client: client)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// MARK: - WebSocket Frame I/O
|
|
191
|
+
|
|
192
|
+
private func readLoop(client: WebSocketClient) {
|
|
193
|
+
// Make non-blocking and use a dispatch source
|
|
194
|
+
let flags = fcntl(client.fd, F_GETFL)
|
|
195
|
+
_ = fcntl(client.fd, F_SETFL, flags | O_NONBLOCK)
|
|
196
|
+
|
|
197
|
+
let source = DispatchSource.makeReadSource(fileDescriptor: client.fd, queue: queue)
|
|
198
|
+
client.readSource = source
|
|
199
|
+
source.setEventHandler { [weak self] in
|
|
200
|
+
self?.readFrame(client: client)
|
|
201
|
+
}
|
|
202
|
+
source.setCancelHandler { [weak self] in
|
|
203
|
+
self?.removeClient(client)
|
|
204
|
+
}
|
|
205
|
+
source.resume()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private func readFrame(client: WebSocketClient) {
|
|
209
|
+
// Read available data into client buffer
|
|
210
|
+
var buf = [UInt8](repeating: 0, count: 65536)
|
|
211
|
+
let n = read(client.fd, &buf, buf.count)
|
|
212
|
+
if n <= 0 {
|
|
213
|
+
client.readSource?.cancel()
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
client.buffer.append(contentsOf: buf[..<n])
|
|
217
|
+
|
|
218
|
+
// Process complete frames
|
|
219
|
+
while let frame = parseFrame(&client.buffer) {
|
|
220
|
+
switch frame.opcode {
|
|
221
|
+
case 0x1: // Text
|
|
222
|
+
if let text = String(bytes: frame.payload, encoding: .utf8),
|
|
223
|
+
let data = text.data(using: .utf8) {
|
|
224
|
+
handleMessage(data, client: client)
|
|
225
|
+
}
|
|
226
|
+
case 0x8: // Close
|
|
227
|
+
// Send close frame back
|
|
228
|
+
sendFrame(opcode: 0x8, payload: [], to: client)
|
|
229
|
+
client.readSource?.cancel()
|
|
230
|
+
return
|
|
231
|
+
case 0x9: // Ping → Pong
|
|
232
|
+
sendFrame(opcode: 0xA, payload: frame.payload, to: client)
|
|
233
|
+
case 0xA: // Pong — ignore
|
|
234
|
+
break
|
|
235
|
+
default:
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private struct WSFrame {
|
|
242
|
+
let opcode: UInt8
|
|
243
|
+
let payload: [UInt8]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private func parseFrame(_ buffer: inout [UInt8]) -> WSFrame? {
|
|
247
|
+
guard buffer.count >= 2 else { return nil }
|
|
248
|
+
|
|
249
|
+
let byte0 = buffer[0]
|
|
250
|
+
let byte1 = buffer[1]
|
|
251
|
+
let opcode = byte0 & 0x0F
|
|
252
|
+
let masked = (byte1 & 0x80) != 0
|
|
253
|
+
var payloadLen = UInt64(byte1 & 0x7F)
|
|
254
|
+
var offset = 2
|
|
255
|
+
|
|
256
|
+
if payloadLen == 126 {
|
|
257
|
+
guard buffer.count >= 4 else { return nil }
|
|
258
|
+
payloadLen = UInt64(buffer[2]) << 8 | UInt64(buffer[3])
|
|
259
|
+
offset = 4
|
|
260
|
+
} else if payloadLen == 127 {
|
|
261
|
+
guard buffer.count >= 10 else { return nil }
|
|
262
|
+
payloadLen = 0
|
|
263
|
+
for i in 0..<8 { payloadLen = payloadLen << 8 | UInt64(buffer[2 + i]) }
|
|
264
|
+
offset = 10
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let maskSize = masked ? 4 : 0
|
|
268
|
+
let totalNeeded = offset + maskSize + Int(payloadLen)
|
|
269
|
+
guard buffer.count >= totalNeeded else { return nil }
|
|
270
|
+
|
|
271
|
+
var payload: [UInt8]
|
|
272
|
+
if masked {
|
|
273
|
+
let mask = Array(buffer[offset..<(offset + 4)])
|
|
274
|
+
let dataStart = offset + 4
|
|
275
|
+
payload = Array(buffer[dataStart..<(dataStart + Int(payloadLen))])
|
|
276
|
+
for i in 0..<payload.count {
|
|
277
|
+
payload[i] ^= mask[i % 4]
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
payload = Array(buffer[offset..<(offset + Int(payloadLen))])
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
buffer.removeFirst(totalNeeded)
|
|
284
|
+
return WSFrame(opcode: opcode, payload: payload)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private func sendFrame(opcode: UInt8, payload: [UInt8], to client: WebSocketClient) {
|
|
288
|
+
var frame: [UInt8] = [0x80 | opcode] // FIN + opcode
|
|
289
|
+
|
|
290
|
+
if payload.count < 126 {
|
|
291
|
+
frame.append(UInt8(payload.count))
|
|
292
|
+
} else if payload.count < 65536 {
|
|
293
|
+
frame.append(126)
|
|
294
|
+
frame.append(UInt8((payload.count >> 8) & 0xFF))
|
|
295
|
+
frame.append(UInt8(payload.count & 0xFF))
|
|
296
|
+
} else {
|
|
297
|
+
frame.append(127)
|
|
298
|
+
for i in (0..<8).reversed() {
|
|
299
|
+
frame.append(UInt8((payload.count >> (i * 8)) & 0xFF))
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
frame.append(contentsOf: payload)
|
|
303
|
+
|
|
304
|
+
frame.withUnsafeBufferPointer { ptr in
|
|
305
|
+
_ = write(client.fd, ptr.baseAddress!, ptr.count)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private func sendWebSocketText(_ text: String, to client: WebSocketClient) {
|
|
310
|
+
let payload = Array(text.utf8)
|
|
311
|
+
sendFrame(opcode: 0x1, payload: payload, to: client)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// MARK: - Message Handling
|
|
315
|
+
|
|
316
|
+
private func handleMessage(_ data: Data, client: WebSocketClient) {
|
|
317
|
+
guard let request = try? decoder.decode(DaemonRequest.self, from: data) else {
|
|
318
|
+
let errResponse = DaemonResponse(id: "?", result: nil, error: "Invalid request JSON")
|
|
319
|
+
sendResponse(errResponse, to: client)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let response = LatticesApi.shared.handle(request)
|
|
324
|
+
sendResponse(response, to: client)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private func sendResponse(_ response: DaemonResponse, to client: WebSocketClient) {
|
|
328
|
+
guard let data = try? encoder.encode(response),
|
|
329
|
+
let text = String(data: data, encoding: .utf8) else { return }
|
|
330
|
+
sendWebSocketText(text, to: client)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// MARK: - Client Management
|
|
334
|
+
|
|
335
|
+
private func removeClient(_ client: WebSocketClient) {
|
|
336
|
+
close(client.fd)
|
|
337
|
+
lock.lock()
|
|
338
|
+
clients.removeValue(forKey: client.id)
|
|
339
|
+
let count = clients.count
|
|
340
|
+
lock.unlock()
|
|
341
|
+
DispatchQueue.main.async { self.clientCount = count }
|
|
342
|
+
DiagnosticLog.shared.info("DaemonServer: client disconnected (\(count) total)")
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// MARK: - Crypto Helper
|
|
346
|
+
|
|
347
|
+
private func sha1Base64(_ string: String) -> String {
|
|
348
|
+
let data = Array(string.utf8)
|
|
349
|
+
var hash = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
|
|
350
|
+
CC_SHA1(data, CC_LONG(data.count), &hash)
|
|
351
|
+
return Data(hash).base64EncodedString()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// MARK: - Event Broadcasting
|
|
355
|
+
|
|
356
|
+
private func broadcastEvent(_ event: ModelEvent) {
|
|
357
|
+
let daemonEvent: DaemonEvent
|
|
358
|
+
switch event {
|
|
359
|
+
case .windowsChanged(let windows, let added, let removed):
|
|
360
|
+
daemonEvent = DaemonEvent(
|
|
361
|
+
event: "windows.changed",
|
|
362
|
+
data: .object([
|
|
363
|
+
"windowCount": .int(windows.count),
|
|
364
|
+
"added": .array(added.map { .int(Int($0)) }),
|
|
365
|
+
"removed": .array(removed.map { .int(Int($0)) })
|
|
366
|
+
])
|
|
367
|
+
)
|
|
368
|
+
case .tmuxChanged(let sessions):
|
|
369
|
+
daemonEvent = DaemonEvent(
|
|
370
|
+
event: "tmux.changed",
|
|
371
|
+
data: .object([
|
|
372
|
+
"sessionCount": .int(sessions.count),
|
|
373
|
+
"sessions": .array(sessions.map { .string($0.name) })
|
|
374
|
+
])
|
|
375
|
+
)
|
|
376
|
+
case .layerSwitched(let index):
|
|
377
|
+
daemonEvent = DaemonEvent(
|
|
378
|
+
event: "layer.switched",
|
|
379
|
+
data: .object(["index": .int(index)])
|
|
380
|
+
)
|
|
381
|
+
case .processesChanged(let interesting):
|
|
382
|
+
daemonEvent = DaemonEvent(
|
|
383
|
+
event: "processes.changed",
|
|
384
|
+
data: .object([
|
|
385
|
+
"interestingCount": .int(interesting.count),
|
|
386
|
+
"pids": .array(interesting.map { .int($0) })
|
|
387
|
+
])
|
|
388
|
+
)
|
|
389
|
+
case .ocrScanComplete(let windowCount, let totalBlocks):
|
|
390
|
+
daemonEvent = DaemonEvent(
|
|
391
|
+
event: "ocr.scanComplete",
|
|
392
|
+
data: .object([
|
|
393
|
+
"windowCount": .int(windowCount),
|
|
394
|
+
"totalBlocks": .int(totalBlocks)
|
|
395
|
+
])
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
broadcast(daemonEvent)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// MARK: - Client State
|
|
403
|
+
|
|
404
|
+
final class WebSocketClient {
|
|
405
|
+
let id: UUID
|
|
406
|
+
let fd: Int32
|
|
407
|
+
var buffer: [UInt8] = []
|
|
408
|
+
var readSource: DispatchSourceRead?
|
|
409
|
+
|
|
410
|
+
init(id: UUID, fd: Int32) {
|
|
411
|
+
self.id = id
|
|
412
|
+
self.fd = fd
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
|
|
4
|
+
final class DesktopModel: ObservableObject {
|
|
5
|
+
static let shared = DesktopModel()
|
|
6
|
+
|
|
7
|
+
@Published private(set) var windows: [UInt32: WindowEntry] = [:]
|
|
8
|
+
/// In-memory layer tags: wid → layer id (e.g. "lattices", "talkie", "hudson")
|
|
9
|
+
private(set) var windowLayerTags: [UInt32: String] = [:]
|
|
10
|
+
private var timer: Timer?
|
|
11
|
+
|
|
12
|
+
func start(interval: TimeInterval = 1.5) {
|
|
13
|
+
guard timer == nil else { return }
|
|
14
|
+
DiagnosticLog.shared.info("DesktopModel: starting (interval=\(interval)s)")
|
|
15
|
+
poll()
|
|
16
|
+
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
17
|
+
self?.poll()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func stop() {
|
|
22
|
+
timer?.invalidate()
|
|
23
|
+
timer = nil
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func allWindows() -> [WindowEntry] {
|
|
27
|
+
Array(windows.values).sorted { $0.wid < $1.wid }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func windowForSession(_ session: String) -> WindowEntry? {
|
|
31
|
+
let tag = Terminal.windowTag(for: session)
|
|
32
|
+
return windows.values.first { $0.title.contains(tag) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Assign a layer tag to a window (in-memory only)
|
|
36
|
+
func assignLayer(wid: UInt32, layerId: String) {
|
|
37
|
+
windowLayerTags[wid] = layerId
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Remove layer tag from a window
|
|
41
|
+
func removeLayerTag(wid: UInt32) {
|
|
42
|
+
windowLayerTags.removeValue(forKey: wid)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Clear all layer tags
|
|
46
|
+
func clearLayerTags() {
|
|
47
|
+
windowLayerTags.removeAll()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Find a window by app name and optional title substring (case-insensitive)
|
|
51
|
+
func windowForApp(app: String, title: String?) -> WindowEntry? {
|
|
52
|
+
let matches = windows.values.filter {
|
|
53
|
+
$0.app.localizedCaseInsensitiveContains(app)
|
|
54
|
+
}
|
|
55
|
+
if let title {
|
|
56
|
+
return matches.first { $0.title.localizedCaseInsensitiveContains(title) }
|
|
57
|
+
}
|
|
58
|
+
return matches.first
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// MARK: - Polling
|
|
62
|
+
|
|
63
|
+
func poll() {
|
|
64
|
+
guard let list = CGWindowListCopyWindowInfo(
|
|
65
|
+
[.optionOnScreenOnly, .excludeDesktopElements],
|
|
66
|
+
kCGNullWindowID
|
|
67
|
+
) as? [[String: Any]] else { return }
|
|
68
|
+
|
|
69
|
+
var fresh: [UInt32: WindowEntry] = [:]
|
|
70
|
+
|
|
71
|
+
for info in list {
|
|
72
|
+
guard let wid = info[kCGWindowNumber as String] as? UInt32,
|
|
73
|
+
let ownerName = info[kCGWindowOwnerName as String] as? String,
|
|
74
|
+
let pid = info[kCGWindowOwnerPID as String] as? Int32,
|
|
75
|
+
let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
|
|
76
|
+
else { continue }
|
|
77
|
+
|
|
78
|
+
// Skip tiny windows (menu extras, status items)
|
|
79
|
+
var rect = CGRect.zero
|
|
80
|
+
guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
|
|
81
|
+
rect.width >= 50, rect.height >= 50 else { continue }
|
|
82
|
+
|
|
83
|
+
let title = info[kCGWindowName as String] as? String ?? ""
|
|
84
|
+
let layer = info[kCGWindowLayer as String] as? Int ?? 0
|
|
85
|
+
let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? true
|
|
86
|
+
|
|
87
|
+
// Skip non-standard layers (menus, overlays)
|
|
88
|
+
guard layer == 0 else { continue }
|
|
89
|
+
|
|
90
|
+
let frame = WindowFrame(
|
|
91
|
+
x: Double(rect.origin.x),
|
|
92
|
+
y: Double(rect.origin.y),
|
|
93
|
+
w: Double(rect.width),
|
|
94
|
+
h: Double(rect.height)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
let spaceIds = WindowTiler.getSpacesForWindow(wid)
|
|
98
|
+
|
|
99
|
+
// Extract lattices session tag from title: [lattices:session-name]
|
|
100
|
+
var latticesSession: String?
|
|
101
|
+
if let range = title.range(of: #"\[lattices:([^\]]+)\]"#, options: .regularExpression) {
|
|
102
|
+
let match = String(title[range])
|
|
103
|
+
latticesSession = String(match.dropFirst(9).dropLast(1)) // drop "[lattices:" and "]"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fresh[wid] = WindowEntry(
|
|
107
|
+
wid: wid,
|
|
108
|
+
app: ownerName,
|
|
109
|
+
pid: pid,
|
|
110
|
+
title: title,
|
|
111
|
+
frame: frame,
|
|
112
|
+
spaceIds: spaceIds,
|
|
113
|
+
isOnScreen: isOnScreen,
|
|
114
|
+
latticesSession: latticesSession
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Diff
|
|
119
|
+
let oldKeys = Set(windows.keys)
|
|
120
|
+
let newKeys = Set(fresh.keys)
|
|
121
|
+
let added = Array(newKeys.subtracting(oldKeys))
|
|
122
|
+
let removed = Array(oldKeys.subtracting(newKeys))
|
|
123
|
+
|
|
124
|
+
let changed = added.count > 0 || removed.count > 0 || windowsContentChanged(old: windows, new: fresh)
|
|
125
|
+
|
|
126
|
+
DispatchQueue.main.async {
|
|
127
|
+
self.windows = fresh
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if changed {
|
|
131
|
+
EventBus.shared.post(.windowsChanged(
|
|
132
|
+
windows: Array(fresh.values),
|
|
133
|
+
added: added,
|
|
134
|
+
removed: removed
|
|
135
|
+
))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func windowsContentChanged(old: [UInt32: WindowEntry], new: [UInt32: WindowEntry]) -> Bool {
|
|
140
|
+
// Quick check: if titles or frames changed for any existing window
|
|
141
|
+
for (wid, newEntry) in new {
|
|
142
|
+
guard let oldEntry = old[wid] else { continue }
|
|
143
|
+
if oldEntry.title != newEntry.title || oldEntry.frame != newEntry.frame {
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
}
|