@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,1234 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - Registry Types
|
|
4
|
+
|
|
5
|
+
enum Access: String, Codable {
|
|
6
|
+
case read, mutate
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
struct Param {
|
|
10
|
+
let name: String
|
|
11
|
+
let type: String // "string", "int", "uint32", "bool"
|
|
12
|
+
let required: Bool
|
|
13
|
+
let description: String
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
enum ReturnShape {
|
|
17
|
+
case array(model: String)
|
|
18
|
+
case object(model: String)
|
|
19
|
+
case ok
|
|
20
|
+
case custom(String)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
struct Endpoint {
|
|
24
|
+
let method: String
|
|
25
|
+
let description: String
|
|
26
|
+
let access: Access
|
|
27
|
+
let params: [Param]
|
|
28
|
+
let returns: ReturnShape
|
|
29
|
+
let handler: (JSON?) throws -> JSON
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
struct Field {
|
|
33
|
+
let name: String
|
|
34
|
+
let type: String // "string", "int", "double", "bool", "[Model]", "Model?"
|
|
35
|
+
let required: Bool
|
|
36
|
+
let description: String
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
struct ApiModel {
|
|
40
|
+
let name: String
|
|
41
|
+
let fields: [Field]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// MARK: - Central Registry
|
|
45
|
+
|
|
46
|
+
final class LatticesApi {
|
|
47
|
+
static let shared = LatticesApi()
|
|
48
|
+
|
|
49
|
+
private(set) var endpoints: [String: Endpoint] = [:]
|
|
50
|
+
private(set) var models: [String: ApiModel] = [:]
|
|
51
|
+
private var endpointOrder: [String] = []
|
|
52
|
+
private var modelOrder: [String] = []
|
|
53
|
+
|
|
54
|
+
private let startTime = Date()
|
|
55
|
+
|
|
56
|
+
func register(_ endpoint: Endpoint) {
|
|
57
|
+
endpoints[endpoint.method] = endpoint
|
|
58
|
+
if !endpointOrder.contains(endpoint.method) {
|
|
59
|
+
endpointOrder.append(endpoint.method)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func model(_ model: ApiModel) {
|
|
64
|
+
models[model.name] = model
|
|
65
|
+
if !modelOrder.contains(model.name) {
|
|
66
|
+
modelOrder.append(model.name)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func dispatch(method: String, params: JSON?) throws -> JSON {
|
|
71
|
+
guard let endpoint = endpoints[method] else {
|
|
72
|
+
throw RouterError.unknownMethod(method)
|
|
73
|
+
}
|
|
74
|
+
return try endpoint.handler(params)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func handle(_ request: DaemonRequest) -> DaemonResponse {
|
|
78
|
+
do {
|
|
79
|
+
let result = try dispatch(method: request.method, params: request.params)
|
|
80
|
+
return DaemonResponse(id: request.id, result: result, error: nil)
|
|
81
|
+
} catch {
|
|
82
|
+
return DaemonResponse(id: request.id, result: nil, error: error.localizedDescription)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func schema() -> JSON {
|
|
87
|
+
let modelsList: [JSON] = modelOrder.compactMap { name in
|
|
88
|
+
guard let m = models[name] else { return nil }
|
|
89
|
+
return .object([
|
|
90
|
+
"name": .string(m.name),
|
|
91
|
+
"fields": .array(m.fields.map { f in
|
|
92
|
+
.object([
|
|
93
|
+
"name": .string(f.name),
|
|
94
|
+
"type": .string(f.type),
|
|
95
|
+
"required": .bool(f.required),
|
|
96
|
+
"description": .string(f.description)
|
|
97
|
+
])
|
|
98
|
+
})
|
|
99
|
+
])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let methodsList: [JSON] = endpointOrder.compactMap { name in
|
|
103
|
+
guard let ep = endpoints[name] else { return nil }
|
|
104
|
+
|
|
105
|
+
let returnsJson: JSON
|
|
106
|
+
switch ep.returns {
|
|
107
|
+
case .array(let model):
|
|
108
|
+
returnsJson = .object(["type": .string("array"), "model": .string(model)])
|
|
109
|
+
case .object(let model):
|
|
110
|
+
returnsJson = .object(["type": .string("object"), "model": .string(model)])
|
|
111
|
+
case .ok:
|
|
112
|
+
returnsJson = .object(["type": .string("ok")])
|
|
113
|
+
case .custom(let desc):
|
|
114
|
+
returnsJson = .object(["type": .string("custom"), "description": .string(desc)])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return .object([
|
|
118
|
+
"method": .string(ep.method),
|
|
119
|
+
"description": .string(ep.description),
|
|
120
|
+
"access": .string(ep.access.rawValue),
|
|
121
|
+
"params": .array(ep.params.map { p in
|
|
122
|
+
.object([
|
|
123
|
+
"name": .string(p.name),
|
|
124
|
+
"type": .string(p.type),
|
|
125
|
+
"required": .bool(p.required),
|
|
126
|
+
"description": .string(p.description)
|
|
127
|
+
])
|
|
128
|
+
}),
|
|
129
|
+
"returns": returnsJson
|
|
130
|
+
])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return .object([
|
|
134
|
+
"version": .string("1.0"),
|
|
135
|
+
"models": .array(modelsList),
|
|
136
|
+
"methods": .array(methodsList)
|
|
137
|
+
])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// MARK: - Setup
|
|
141
|
+
|
|
142
|
+
static func setup() {
|
|
143
|
+
let api = LatticesApi.shared
|
|
144
|
+
|
|
145
|
+
// ── Models ──────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
api.model(ApiModel(name: "Window", fields: [
|
|
148
|
+
Field(name: "wid", type: "int", required: true, description: "CGWindowID"),
|
|
149
|
+
Field(name: "app", type: "string", required: true, description: "Application name"),
|
|
150
|
+
Field(name: "pid", type: "int", required: true, description: "Process ID"),
|
|
151
|
+
Field(name: "title", type: "string", required: true, description: "Window title"),
|
|
152
|
+
Field(name: "frame", type: "Frame", required: true, description: "Window frame {x, y, w, h}"),
|
|
153
|
+
Field(name: "spaceIds", type: "[int]", required: true, description: "Space IDs the window is on"),
|
|
154
|
+
Field(name: "isOnScreen", type: "bool", required: true, description: "Whether window is currently visible"),
|
|
155
|
+
Field(name: "latticesSession", type: "string", required: false, description: "Associated lattices session name"),
|
|
156
|
+
]))
|
|
157
|
+
|
|
158
|
+
api.model(ApiModel(name: "TmuxSession", fields: [
|
|
159
|
+
Field(name: "name", type: "string", required: true, description: "Session name"),
|
|
160
|
+
Field(name: "windowCount", type: "int", required: true, description: "Number of tmux windows"),
|
|
161
|
+
Field(name: "attached", type: "bool", required: true, description: "Whether a client is attached"),
|
|
162
|
+
Field(name: "panes", type: "[TmuxPane]", required: true, description: "Panes in this session"),
|
|
163
|
+
]))
|
|
164
|
+
|
|
165
|
+
api.model(ApiModel(name: "TmuxPane", fields: [
|
|
166
|
+
Field(name: "id", type: "string", required: true, description: "Pane ID (e.g. %0)"),
|
|
167
|
+
Field(name: "windowIndex", type: "int", required: true, description: "Tmux window index"),
|
|
168
|
+
Field(name: "windowName", type: "string", required: true, description: "Tmux window name"),
|
|
169
|
+
Field(name: "title", type: "string", required: true, description: "Pane title"),
|
|
170
|
+
Field(name: "currentCommand", type: "string", required: true, description: "Currently running command"),
|
|
171
|
+
Field(name: "pid", type: "int", required: true, description: "Process ID of the pane"),
|
|
172
|
+
Field(name: "isActive", type: "bool", required: true, description: "Whether this pane is active"),
|
|
173
|
+
Field(name: "children", type: "[PaneChild]", required: false, description: "Interesting child processes in this pane"),
|
|
174
|
+
]))
|
|
175
|
+
|
|
176
|
+
api.model(ApiModel(name: "Project", fields: [
|
|
177
|
+
Field(name: "path", type: "string", required: true, description: "Absolute path to project"),
|
|
178
|
+
Field(name: "name", type: "string", required: true, description: "Project display name"),
|
|
179
|
+
Field(name: "sessionName", type: "string", required: true, description: "Tmux session name"),
|
|
180
|
+
Field(name: "isRunning", type: "bool", required: true, description: "Whether the session is active"),
|
|
181
|
+
Field(name: "hasConfig", type: "bool", required: true, description: "Whether .lattices.json exists"),
|
|
182
|
+
Field(name: "paneCount", type: "int", required: true, description: "Number of configured panes"),
|
|
183
|
+
Field(name: "paneNames", type: "[string]", required: true, description: "Names of configured panes"),
|
|
184
|
+
Field(name: "devCommand", type: "string", required: false, description: "Dev command if detected"),
|
|
185
|
+
Field(name: "packageManager", type: "string", required: false, description: "Detected package manager"),
|
|
186
|
+
]))
|
|
187
|
+
|
|
188
|
+
api.model(ApiModel(name: "Display", fields: [
|
|
189
|
+
Field(name: "displayIndex", type: "int", required: true, description: "Display index"),
|
|
190
|
+
Field(name: "displayId", type: "string", required: true, description: "Display identifier"),
|
|
191
|
+
Field(name: "currentSpaceId", type: "int", required: true, description: "Currently active space ID"),
|
|
192
|
+
Field(name: "spaces", type: "[Space]", required: true, description: "Spaces on this display"),
|
|
193
|
+
]))
|
|
194
|
+
|
|
195
|
+
api.model(ApiModel(name: "Space", fields: [
|
|
196
|
+
Field(name: "id", type: "int", required: true, description: "Space ID"),
|
|
197
|
+
Field(name: "index", type: "int", required: true, description: "Space index"),
|
|
198
|
+
Field(name: "display", type: "int", required: true, description: "Display index"),
|
|
199
|
+
Field(name: "isCurrent", type: "bool", required: true, description: "Whether this is the active space"),
|
|
200
|
+
]))
|
|
201
|
+
|
|
202
|
+
api.model(ApiModel(name: "Layer", fields: [
|
|
203
|
+
Field(name: "id", type: "string", required: true, description: "Layer identifier"),
|
|
204
|
+
Field(name: "label", type: "string", required: true, description: "Layer display label"),
|
|
205
|
+
Field(name: "index", type: "int", required: true, description: "Layer index"),
|
|
206
|
+
Field(name: "projectCount", type: "int", required: true, description: "Number of projects in layer"),
|
|
207
|
+
]))
|
|
208
|
+
|
|
209
|
+
api.model(ApiModel(name: "Process", fields: [
|
|
210
|
+
Field(name: "pid", type: "int", required: true, description: "Process ID"),
|
|
211
|
+
Field(name: "ppid", type: "int", required: true, description: "Parent process ID"),
|
|
212
|
+
Field(name: "command", type: "string", required: true, description: "Command basename (e.g. node, claude)"),
|
|
213
|
+
Field(name: "args", type: "string", required: true, description: "Full command line"),
|
|
214
|
+
Field(name: "cwd", type: "string", required: false, description: "Working directory"),
|
|
215
|
+
Field(name: "tty", type: "string", required: true, description: "Controlling TTY"),
|
|
216
|
+
Field(name: "tmuxSession", type: "string", required: false, description: "Linked tmux session name"),
|
|
217
|
+
Field(name: "tmuxPaneId", type: "string", required: false, description: "Linked tmux pane ID"),
|
|
218
|
+
Field(name: "windowId", type: "int", required: false, description: "Linked macOS window ID"),
|
|
219
|
+
]))
|
|
220
|
+
|
|
221
|
+
api.model(ApiModel(name: "PaneChild", fields: [
|
|
222
|
+
Field(name: "pid", type: "int", required: true, description: "Process ID"),
|
|
223
|
+
Field(name: "command", type: "string", required: true, description: "Command basename"),
|
|
224
|
+
Field(name: "args", type: "string", required: true, description: "Full command line"),
|
|
225
|
+
Field(name: "cwd", type: "string", required: false, description: "Working directory"),
|
|
226
|
+
]))
|
|
227
|
+
|
|
228
|
+
api.model(ApiModel(name: "TerminalInstance", fields: [
|
|
229
|
+
Field(name: "tty", type: "string", required: true, description: "Controlling TTY (universal join key)"),
|
|
230
|
+
Field(name: "app", type: "string", required: false, description: "Terminal emulator name (iTerm2, Terminal, etc.)"),
|
|
231
|
+
Field(name: "windowIndex", type: "int", required: false, description: "Terminal window index"),
|
|
232
|
+
Field(name: "tabIndex", type: "int", required: false, description: "Tab index within the window"),
|
|
233
|
+
Field(name: "isActiveTab", type: "bool", required: true, description: "Whether this is the selected tab"),
|
|
234
|
+
Field(name: "tabTitle", type: "string", required: false, description: "Tab title from the terminal emulator"),
|
|
235
|
+
Field(name: "terminalSessionId", type: "string", required: false, description: "Terminal-specific session ID (iTerm2 unique ID)"),
|
|
236
|
+
Field(name: "processes", type: "[Process]", required: true, description: "Interesting processes on this TTY"),
|
|
237
|
+
Field(name: "shellPid", type: "int", required: false, description: "Root shell PID for this TTY"),
|
|
238
|
+
Field(name: "cwd", type: "string", required: false, description: "Working directory (from deepest interesting process)"),
|
|
239
|
+
Field(name: "tmuxSession", type: "string", required: false, description: "Linked tmux session name"),
|
|
240
|
+
Field(name: "tmuxPaneId", type: "string", required: false, description: "Linked tmux pane ID"),
|
|
241
|
+
Field(name: "windowId", type: "int", required: false, description: "Linked macOS window ID (CGWindowID)"),
|
|
242
|
+
Field(name: "windowTitle", type: "string", required: false, description: "macOS window title"),
|
|
243
|
+
Field(name: "hasClaude", type: "bool", required: true, description: "Whether a claude process is running on this TTY"),
|
|
244
|
+
Field(name: "displayName", type: "string", required: true, description: "Best display name (session > tab title > tty)"),
|
|
245
|
+
]))
|
|
246
|
+
|
|
247
|
+
api.model(ApiModel(name: "OcrResult", fields: [
|
|
248
|
+
Field(name: "wid", type: "int", required: true, description: "Window ID"),
|
|
249
|
+
Field(name: "app", type: "string", required: true, description: "Application name"),
|
|
250
|
+
Field(name: "title", type: "string", required: true, description: "Window title"),
|
|
251
|
+
Field(name: "frame", type: "Frame", required: true, description: "Window frame"),
|
|
252
|
+
Field(name: "fullText", type: "string", required: true, description: "All recognized text"),
|
|
253
|
+
Field(name: "blocks", type: "[OcrBlock]", required: true, description: "Individual text blocks with position/confidence"),
|
|
254
|
+
Field(name: "timestamp", type: "double", required: true, description: "Scan timestamp (Unix)"),
|
|
255
|
+
]))
|
|
256
|
+
|
|
257
|
+
api.model(ApiModel(name: "OcrBlock", fields: [
|
|
258
|
+
Field(name: "text", type: "string", required: true, description: "Recognized text"),
|
|
259
|
+
Field(name: "confidence", type: "double", required: true, description: "Recognition confidence 0-1"),
|
|
260
|
+
Field(name: "x", type: "double", required: true, description: "Normalized bounding box x"),
|
|
261
|
+
Field(name: "y", type: "double", required: true, description: "Normalized bounding box y"),
|
|
262
|
+
Field(name: "w", type: "double", required: true, description: "Normalized bounding box width"),
|
|
263
|
+
Field(name: "h", type: "double", required: true, description: "Normalized bounding box height"),
|
|
264
|
+
]))
|
|
265
|
+
|
|
266
|
+
api.model(ApiModel(name: "OcrSearchResult", fields: [
|
|
267
|
+
Field(name: "id", type: "int", required: true, description: "Database row ID"),
|
|
268
|
+
Field(name: "wid", type: "int", required: true, description: "Window ID"),
|
|
269
|
+
Field(name: "app", type: "string", required: true, description: "Application name"),
|
|
270
|
+
Field(name: "title", type: "string", required: true, description: "Window title"),
|
|
271
|
+
Field(name: "frame", type: "Frame", required: true, description: "Window frame at scan time"),
|
|
272
|
+
Field(name: "fullText", type: "string", required: true, description: "Full recognized text"),
|
|
273
|
+
Field(name: "snippet", type: "string", required: true, description: "Highlighted snippet (FTS5)"),
|
|
274
|
+
Field(name: "timestamp", type: "double", required: true, description: "Scan timestamp (Unix)"),
|
|
275
|
+
Field(name: "source", type: "string", required: true, description: "Text source: 'accessibility' or 'ocr'"),
|
|
276
|
+
]))
|
|
277
|
+
|
|
278
|
+
api.model(ApiModel(name: "DaemonStatus", fields: [
|
|
279
|
+
Field(name: "uptime", type: "double", required: true, description: "Seconds since daemon started"),
|
|
280
|
+
Field(name: "clientCount", type: "int", required: true, description: "Connected WebSocket clients"),
|
|
281
|
+
Field(name: "version", type: "string", required: true, description: "Daemon version"),
|
|
282
|
+
Field(name: "windowCount", type: "int", required: true, description: "Tracked window count"),
|
|
283
|
+
Field(name: "tmuxSessionCount", type: "int", required: true, description: "Active tmux session count"),
|
|
284
|
+
]))
|
|
285
|
+
|
|
286
|
+
// ── Endpoints: Read ─────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
api.register(Endpoint(
|
|
289
|
+
method: "windows.list",
|
|
290
|
+
description: "List all windows known to the system",
|
|
291
|
+
access: .read,
|
|
292
|
+
params: [],
|
|
293
|
+
returns: .array(model: "Window"),
|
|
294
|
+
handler: { _ in
|
|
295
|
+
let entries = DesktopModel.shared.allWindows()
|
|
296
|
+
return .array(entries.map { Encoders.window($0) })
|
|
297
|
+
}
|
|
298
|
+
))
|
|
299
|
+
|
|
300
|
+
api.register(Endpoint(
|
|
301
|
+
method: "windows.get",
|
|
302
|
+
description: "Get a single window by ID",
|
|
303
|
+
access: .read,
|
|
304
|
+
params: [Param(name: "wid", type: "uint32", required: true, description: "Window ID")],
|
|
305
|
+
returns: .object(model: "Window"),
|
|
306
|
+
handler: { params in
|
|
307
|
+
guard let wid = params?["wid"]?.uint32Value else {
|
|
308
|
+
throw RouterError.missingParam("wid")
|
|
309
|
+
}
|
|
310
|
+
guard let entry = DesktopModel.shared.windows[wid] else {
|
|
311
|
+
throw RouterError.notFound("window \(wid)")
|
|
312
|
+
}
|
|
313
|
+
return Encoders.window(entry)
|
|
314
|
+
}
|
|
315
|
+
))
|
|
316
|
+
|
|
317
|
+
api.register(Endpoint(
|
|
318
|
+
method: "windows.search",
|
|
319
|
+
description: "Search windows by title, app, and OCR content",
|
|
320
|
+
access: .read,
|
|
321
|
+
params: [
|
|
322
|
+
Param(name: "query", type: "string", required: true, description: "Search text"),
|
|
323
|
+
Param(name: "ocr", type: "bool", required: false, description: "Include OCR content (default true)"),
|
|
324
|
+
Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
|
|
325
|
+
],
|
|
326
|
+
returns: .array(model: "Window"),
|
|
327
|
+
handler: { params in
|
|
328
|
+
guard let query = params?["query"]?.stringValue?.lowercased(), !query.isEmpty else {
|
|
329
|
+
throw RouterError.missingParam("query")
|
|
330
|
+
}
|
|
331
|
+
let includeOcr = params?["ocr"]?.boolValue ?? true
|
|
332
|
+
let limit = params?["limit"]?.intValue ?? 50
|
|
333
|
+
let ocrResults = OcrModel.shared.results
|
|
334
|
+
|
|
335
|
+
var matches: [JSON] = []
|
|
336
|
+
for entry in DesktopModel.shared.allWindows() {
|
|
337
|
+
let matchesApp = entry.app.lowercased().contains(query)
|
|
338
|
+
let matchesTitle = entry.title.lowercased().contains(query)
|
|
339
|
+
let matchesSession = entry.latticesSession?.lowercased().contains(query) ?? false
|
|
340
|
+
let ocrText = includeOcr ? ocrResults[entry.wid]?.fullText : nil
|
|
341
|
+
let matchesOcrContent = ocrText?.lowercased().contains(query) ?? false
|
|
342
|
+
|
|
343
|
+
if matchesApp || matchesTitle || matchesSession || matchesOcrContent {
|
|
344
|
+
var obj = Encoders.window(entry)
|
|
345
|
+
if matchesOcrContent, let text = ocrText,
|
|
346
|
+
let range = text.lowercased().range(of: query) {
|
|
347
|
+
// Extract snippet around match
|
|
348
|
+
let half = max(0, (80 - text.distance(from: range.lowerBound, to: range.upperBound)) / 2)
|
|
349
|
+
let start = text.index(range.lowerBound, offsetBy: -half, limitedBy: text.startIndex) ?? text.startIndex
|
|
350
|
+
let end = text.index(range.upperBound, offsetBy: half, limitedBy: text.endIndex) ?? text.endIndex
|
|
351
|
+
var snippet = String(text[start..<end])
|
|
352
|
+
.replacingOccurrences(of: "\n", with: " ")
|
|
353
|
+
.trimmingCharacters(in: .whitespaces)
|
|
354
|
+
if start > text.startIndex { snippet = "…" + snippet }
|
|
355
|
+
if end < text.endIndex { snippet += "…" }
|
|
356
|
+
if case .object(var dict) = obj {
|
|
357
|
+
dict["ocrSnippet"] = .string(snippet)
|
|
358
|
+
dict["matchSource"] = .string("ocr")
|
|
359
|
+
obj = .object(dict)
|
|
360
|
+
}
|
|
361
|
+
} else if case .object(var dict) = obj {
|
|
362
|
+
let source = matchesTitle ? "title" : matchesApp ? "app" : "session"
|
|
363
|
+
dict["matchSource"] = .string(source)
|
|
364
|
+
obj = .object(dict)
|
|
365
|
+
}
|
|
366
|
+
matches.append(obj)
|
|
367
|
+
if matches.count >= limit { break }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return .array(matches)
|
|
371
|
+
}
|
|
372
|
+
))
|
|
373
|
+
|
|
374
|
+
// MARK: - Window Layer Tags
|
|
375
|
+
|
|
376
|
+
api.register(Endpoint(
|
|
377
|
+
method: "window.assignLayer",
|
|
378
|
+
description: "Tag a window with a layer id (in-memory only)",
|
|
379
|
+
access: .mutate,
|
|
380
|
+
params: [
|
|
381
|
+
Param(name: "wid", type: "uint32", required: true, description: "Window ID"),
|
|
382
|
+
Param(name: "layer", type: "string", required: true, description: "Layer id (e.g. 'lattices', 'talkie')")
|
|
383
|
+
],
|
|
384
|
+
returns: .ok,
|
|
385
|
+
handler: { params in
|
|
386
|
+
guard let wid = params?["wid"]?.uint32Value else {
|
|
387
|
+
throw RouterError.missingParam("wid")
|
|
388
|
+
}
|
|
389
|
+
guard let layerId = params?["layer"]?.stringValue, !layerId.isEmpty else {
|
|
390
|
+
throw RouterError.missingParam("layer")
|
|
391
|
+
}
|
|
392
|
+
DesktopModel.shared.assignLayer(wid: wid, layerId: layerId)
|
|
393
|
+
return .object(["ok": .bool(true), "wid": .int(Int(wid)), "layer": .string(layerId)])
|
|
394
|
+
}
|
|
395
|
+
))
|
|
396
|
+
|
|
397
|
+
api.register(Endpoint(
|
|
398
|
+
method: "window.removeLayer",
|
|
399
|
+
description: "Remove layer tag from a window",
|
|
400
|
+
access: .mutate,
|
|
401
|
+
params: [
|
|
402
|
+
Param(name: "wid", type: "uint32", required: true, description: "Window ID")
|
|
403
|
+
],
|
|
404
|
+
returns: .ok,
|
|
405
|
+
handler: { params in
|
|
406
|
+
guard let wid = params?["wid"]?.uint32Value else {
|
|
407
|
+
throw RouterError.missingParam("wid")
|
|
408
|
+
}
|
|
409
|
+
DesktopModel.shared.removeLayerTag(wid: wid)
|
|
410
|
+
return .object(["ok": .bool(true)])
|
|
411
|
+
}
|
|
412
|
+
))
|
|
413
|
+
|
|
414
|
+
api.register(Endpoint(
|
|
415
|
+
method: "window.layerMap",
|
|
416
|
+
description: "Get all window-to-layer assignments",
|
|
417
|
+
access: .read,
|
|
418
|
+
params: [],
|
|
419
|
+
returns: .custom("{ [wid]: layerId }"),
|
|
420
|
+
handler: { _ in
|
|
421
|
+
let tags = DesktopModel.shared.windowLayerTags
|
|
422
|
+
var obj: [String: JSON] = [:]
|
|
423
|
+
for (wid, layerId) in tags {
|
|
424
|
+
obj[String(wid)] = .string(layerId)
|
|
425
|
+
}
|
|
426
|
+
return .object(obj)
|
|
427
|
+
}
|
|
428
|
+
))
|
|
429
|
+
|
|
430
|
+
api.register(Endpoint(
|
|
431
|
+
method: "tmux.sessions",
|
|
432
|
+
description: "List all tmux sessions with child process enrichment",
|
|
433
|
+
access: .read,
|
|
434
|
+
params: [],
|
|
435
|
+
returns: .array(model: "TmuxSession"),
|
|
436
|
+
handler: { _ in
|
|
437
|
+
let sessions = TmuxModel.shared.sessions
|
|
438
|
+
return .array(sessions.map { Encoders.enrichedSession($0) })
|
|
439
|
+
}
|
|
440
|
+
))
|
|
441
|
+
|
|
442
|
+
api.register(Endpoint(
|
|
443
|
+
method: "tmux.inventory",
|
|
444
|
+
description: "Get full tmux inventory including orphaned sessions",
|
|
445
|
+
access: .read,
|
|
446
|
+
params: [],
|
|
447
|
+
returns: .custom("Object with 'all' and 'orphans' arrays of TmuxSession"),
|
|
448
|
+
handler: { _ in
|
|
449
|
+
let inv = InventoryManager.shared
|
|
450
|
+
return .object([
|
|
451
|
+
"all": .array(inv.allSessions.map { Encoders.session($0) }),
|
|
452
|
+
"orphans": .array(inv.orphans.map { Encoders.session($0) })
|
|
453
|
+
])
|
|
454
|
+
}
|
|
455
|
+
))
|
|
456
|
+
|
|
457
|
+
api.register(Endpoint(
|
|
458
|
+
method: "projects.list",
|
|
459
|
+
description: "List all discovered projects",
|
|
460
|
+
access: .read,
|
|
461
|
+
params: [],
|
|
462
|
+
returns: .array(model: "Project"),
|
|
463
|
+
handler: { _ in
|
|
464
|
+
let projects = ProjectScanner.shared.projects
|
|
465
|
+
return .array(projects.map { Encoders.project($0) })
|
|
466
|
+
}
|
|
467
|
+
))
|
|
468
|
+
|
|
469
|
+
api.register(Endpoint(
|
|
470
|
+
method: "spaces.list",
|
|
471
|
+
description: "List all displays and their spaces",
|
|
472
|
+
access: .read,
|
|
473
|
+
params: [],
|
|
474
|
+
returns: .array(model: "Display"),
|
|
475
|
+
handler: { _ in
|
|
476
|
+
let displays = WindowTiler.getDisplaySpaces()
|
|
477
|
+
return .array(displays.map { display in
|
|
478
|
+
.object([
|
|
479
|
+
"displayIndex": .int(display.displayIndex),
|
|
480
|
+
"displayId": .string(display.displayId),
|
|
481
|
+
"currentSpaceId": .int(display.currentSpaceId),
|
|
482
|
+
"spaces": .array(display.spaces.map { space in
|
|
483
|
+
.object([
|
|
484
|
+
"id": .int(space.id),
|
|
485
|
+
"index": .int(space.index),
|
|
486
|
+
"display": .int(space.display),
|
|
487
|
+
"isCurrent": .bool(space.isCurrent)
|
|
488
|
+
])
|
|
489
|
+
})
|
|
490
|
+
])
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
))
|
|
494
|
+
|
|
495
|
+
api.register(Endpoint(
|
|
496
|
+
method: "layers.list",
|
|
497
|
+
description: "List all workspace layers and the active index",
|
|
498
|
+
access: .read,
|
|
499
|
+
params: [],
|
|
500
|
+
returns: .custom("Object with 'layers' array of Layer and 'active' index"),
|
|
501
|
+
handler: { _ in
|
|
502
|
+
let wm = WorkspaceManager.shared
|
|
503
|
+
guard let config = wm.config, let layers = config.layers else {
|
|
504
|
+
return .object([
|
|
505
|
+
"layers": .array([]),
|
|
506
|
+
"active": .int(0)
|
|
507
|
+
])
|
|
508
|
+
}
|
|
509
|
+
return .object([
|
|
510
|
+
"layers": .array(layers.enumerated().map { i, layer in
|
|
511
|
+
.object([
|
|
512
|
+
"id": .string(layer.id),
|
|
513
|
+
"label": .string(layer.label),
|
|
514
|
+
"index": .int(i),
|
|
515
|
+
"projectCount": .int(layer.projects.count)
|
|
516
|
+
])
|
|
517
|
+
}),
|
|
518
|
+
"active": .int(wm.activeLayerIndex)
|
|
519
|
+
])
|
|
520
|
+
}
|
|
521
|
+
))
|
|
522
|
+
|
|
523
|
+
api.register(Endpoint(
|
|
524
|
+
method: "daemon.status",
|
|
525
|
+
description: "Get daemon status including uptime and counts",
|
|
526
|
+
access: .read,
|
|
527
|
+
params: [],
|
|
528
|
+
returns: .object(model: "DaemonStatus"),
|
|
529
|
+
handler: { _ in
|
|
530
|
+
let uptime = Date().timeIntervalSince(api.startTime)
|
|
531
|
+
return .object([
|
|
532
|
+
"uptime": .double(uptime),
|
|
533
|
+
"clientCount": .int(DaemonServer.shared.clientCount),
|
|
534
|
+
"version": .string("1.0.0"),
|
|
535
|
+
"windowCount": .int(DesktopModel.shared.windows.count),
|
|
536
|
+
"tmuxSessionCount": .int(TmuxModel.shared.sessions.count)
|
|
537
|
+
])
|
|
538
|
+
}
|
|
539
|
+
))
|
|
540
|
+
|
|
541
|
+
api.register(Endpoint(
|
|
542
|
+
method: "diagnostics.list",
|
|
543
|
+
description: "Get recent diagnostic log entries",
|
|
544
|
+
access: .read,
|
|
545
|
+
params: [Param(name: "limit", type: "int", required: false, description: "Max entries to return (default 40)")],
|
|
546
|
+
returns: .custom("Array of log entries with time, level, message"),
|
|
547
|
+
handler: { params in
|
|
548
|
+
let limit = params?["limit"]?.intValue ?? 40
|
|
549
|
+
let entries = DiagnosticLog.shared.entries.suffix(limit)
|
|
550
|
+
let fmt = DateFormatter()
|
|
551
|
+
fmt.dateFormat = "HH:mm:ss.SSS"
|
|
552
|
+
return .object([
|
|
553
|
+
"entries": .array(entries.map { entry in
|
|
554
|
+
.object([
|
|
555
|
+
"time": .string(fmt.string(from: entry.time)),
|
|
556
|
+
"level": .string("\(entry.level)"),
|
|
557
|
+
"message": .string(entry.message)
|
|
558
|
+
])
|
|
559
|
+
})
|
|
560
|
+
])
|
|
561
|
+
}
|
|
562
|
+
))
|
|
563
|
+
|
|
564
|
+
api.register(Endpoint(
|
|
565
|
+
method: "processes.list",
|
|
566
|
+
description: "List interesting developer processes with tmux/window linkage",
|
|
567
|
+
access: .read,
|
|
568
|
+
params: [Param(name: "command", type: "string", required: false, description: "Filter by command name (e.g. claude)")],
|
|
569
|
+
returns: .array(model: "Process"),
|
|
570
|
+
handler: { params in
|
|
571
|
+
let pm = ProcessModel.shared
|
|
572
|
+
var enriched = pm.enrichedProcesses()
|
|
573
|
+
if let cmd = params?["command"]?.stringValue {
|
|
574
|
+
enriched = enriched.filter { $0.process.comm == cmd }
|
|
575
|
+
}
|
|
576
|
+
return .array(enriched.map { Encoders.process($0) })
|
|
577
|
+
}
|
|
578
|
+
))
|
|
579
|
+
|
|
580
|
+
api.register(Endpoint(
|
|
581
|
+
method: "processes.tree",
|
|
582
|
+
description: "Get all descendant processes of a given PID",
|
|
583
|
+
access: .read,
|
|
584
|
+
params: [Param(name: "pid", type: "int", required: true, description: "Parent process ID")],
|
|
585
|
+
returns: .array(model: "Process"),
|
|
586
|
+
handler: { params in
|
|
587
|
+
guard let pid = params?["pid"]?.intValue else {
|
|
588
|
+
throw RouterError.missingParam("pid")
|
|
589
|
+
}
|
|
590
|
+
let pm = ProcessModel.shared
|
|
591
|
+
let descendants = pm.descendants(of: pid)
|
|
592
|
+
return .array(descendants.map { entry in
|
|
593
|
+
let enrichment = pm.enrich(entry)
|
|
594
|
+
return Encoders.process(enrichment)
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
))
|
|
598
|
+
|
|
599
|
+
api.register(Endpoint(
|
|
600
|
+
method: "terminals.list",
|
|
601
|
+
description: "List all synthesized terminal instances (unified TTY view)",
|
|
602
|
+
access: .read,
|
|
603
|
+
params: [
|
|
604
|
+
Param(name: "refresh", type: "bool", required: false, description: "Force-refresh terminal tab cache before synthesizing"),
|
|
605
|
+
],
|
|
606
|
+
returns: .array(model: "TerminalInstance"),
|
|
607
|
+
handler: { params in
|
|
608
|
+
let pm = ProcessModel.shared
|
|
609
|
+
if params?["refresh"]?.boolValue == true {
|
|
610
|
+
pm.refreshTerminalTabs()
|
|
611
|
+
}
|
|
612
|
+
let instances = pm.synthesizeTerminals()
|
|
613
|
+
return .array(instances.map { Encoders.terminalInstance($0) })
|
|
614
|
+
}
|
|
615
|
+
))
|
|
616
|
+
|
|
617
|
+
api.register(Endpoint(
|
|
618
|
+
method: "terminals.search",
|
|
619
|
+
description: "Search terminal instances by command, cwd, app, session, or hasClaude",
|
|
620
|
+
access: .read,
|
|
621
|
+
params: [
|
|
622
|
+
Param(name: "command", type: "string", required: false, description: "Filter by command name substring"),
|
|
623
|
+
Param(name: "cwd", type: "string", required: false, description: "Filter by working directory substring"),
|
|
624
|
+
Param(name: "app", type: "string", required: false, description: "Filter by terminal app name"),
|
|
625
|
+
Param(name: "session", type: "string", required: false, description: "Filter by tmux session name"),
|
|
626
|
+
Param(name: "hasClaude", type: "bool", required: false, description: "Filter to only Claude-running TTYs"),
|
|
627
|
+
],
|
|
628
|
+
returns: .array(model: "TerminalInstance"),
|
|
629
|
+
handler: { params in
|
|
630
|
+
var instances = ProcessModel.shared.synthesizeTerminals()
|
|
631
|
+
|
|
632
|
+
if let cmd = params?["command"]?.stringValue {
|
|
633
|
+
instances = instances.filter { inst in
|
|
634
|
+
inst.processes.contains { $0.comm.contains(cmd) || $0.args.contains(cmd) }
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if let cwd = params?["cwd"]?.stringValue {
|
|
638
|
+
instances = instances.filter { inst in
|
|
639
|
+
inst.cwd?.contains(cwd) == true
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if let app = params?["app"]?.stringValue {
|
|
643
|
+
instances = instances.filter { $0.app?.rawValue == app }
|
|
644
|
+
}
|
|
645
|
+
if let session = params?["session"]?.stringValue {
|
|
646
|
+
instances = instances.filter { $0.tmuxSession == session }
|
|
647
|
+
}
|
|
648
|
+
if let hasClaude = params?["hasClaude"]?.boolValue {
|
|
649
|
+
instances = instances.filter { $0.hasClaude == hasClaude }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return .array(instances.map { Encoders.terminalInstance($0) })
|
|
653
|
+
}
|
|
654
|
+
))
|
|
655
|
+
|
|
656
|
+
// ── Endpoints: OCR ─────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
api.register(Endpoint(
|
|
659
|
+
method: "ocr.snapshot",
|
|
660
|
+
description: "Get the latest OCR scan results for all on-screen windows",
|
|
661
|
+
access: .read,
|
|
662
|
+
params: [],
|
|
663
|
+
returns: .array(model: "OcrResult"),
|
|
664
|
+
handler: { _ in
|
|
665
|
+
let results = OcrModel.shared.results
|
|
666
|
+
return .array(results.values.map { Encoders.ocrResult($0) })
|
|
667
|
+
}
|
|
668
|
+
))
|
|
669
|
+
|
|
670
|
+
api.register(Endpoint(
|
|
671
|
+
method: "ocr.search",
|
|
672
|
+
description: "Search OCR text across all windows (queries persistent SQLite FTS5 index by default)",
|
|
673
|
+
access: .read,
|
|
674
|
+
params: [
|
|
675
|
+
Param(name: "query", type: "string", required: true, description: "Search text (FTS5 query syntax)"),
|
|
676
|
+
Param(name: "app", type: "string", required: false, description: "Filter by app name"),
|
|
677
|
+
Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
|
|
678
|
+
Param(name: "live", type: "bool", required: false, description: "Search in-memory snapshot instead of history (default false)"),
|
|
679
|
+
],
|
|
680
|
+
returns: .array(model: "OcrSearchResult"),
|
|
681
|
+
handler: { params in
|
|
682
|
+
guard let query = params?["query"]?.stringValue else {
|
|
683
|
+
throw RouterError.missingParam("query")
|
|
684
|
+
}
|
|
685
|
+
let app = params?["app"]?.stringValue
|
|
686
|
+
let limit = params?["limit"]?.intValue ?? 50
|
|
687
|
+
let live = params?["live"]?.boolValue ?? false
|
|
688
|
+
|
|
689
|
+
if live {
|
|
690
|
+
// In-memory snapshot search (original behavior)
|
|
691
|
+
var results = Array(OcrModel.shared.results.values)
|
|
692
|
+
let q = query.lowercased()
|
|
693
|
+
results = results.filter { $0.fullText.lowercased().contains(q) }
|
|
694
|
+
if let app { results = results.filter { $0.app == app } }
|
|
695
|
+
return .array(results.prefix(limit).map { Encoders.ocrResult($0) })
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Persistent FTS5 search
|
|
699
|
+
let results = OcrStore.shared.search(query: query, app: app, limit: limit)
|
|
700
|
+
return .array(results.map { Encoders.ocrSearchResult($0) })
|
|
701
|
+
}
|
|
702
|
+
))
|
|
703
|
+
|
|
704
|
+
api.register(Endpoint(
|
|
705
|
+
method: "ocr.history",
|
|
706
|
+
description: "Get OCR content timeline for a specific window",
|
|
707
|
+
access: .read,
|
|
708
|
+
params: [
|
|
709
|
+
Param(name: "wid", type: "uint32", required: true, description: "Window ID"),
|
|
710
|
+
Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
|
|
711
|
+
],
|
|
712
|
+
returns: .array(model: "OcrSearchResult"),
|
|
713
|
+
handler: { params in
|
|
714
|
+
guard let wid = params?["wid"]?.uint32Value else {
|
|
715
|
+
throw RouterError.missingParam("wid")
|
|
716
|
+
}
|
|
717
|
+
let limit = params?["limit"]?.intValue ?? 50
|
|
718
|
+
let results = OcrStore.shared.history(wid: wid, limit: limit)
|
|
719
|
+
return .array(results.map { Encoders.ocrSearchResult($0) })
|
|
720
|
+
}
|
|
721
|
+
))
|
|
722
|
+
|
|
723
|
+
api.register(Endpoint(
|
|
724
|
+
method: "ocr.recent",
|
|
725
|
+
description: "Get recent OCR entries across all windows (chronological, from persistent store)",
|
|
726
|
+
access: .read,
|
|
727
|
+
params: [
|
|
728
|
+
Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
|
|
729
|
+
],
|
|
730
|
+
returns: .array(model: "OcrSearchResult"),
|
|
731
|
+
handler: { params in
|
|
732
|
+
let limit = params?["limit"]?.intValue ?? 50
|
|
733
|
+
let results = OcrStore.shared.recent(limit: limit)
|
|
734
|
+
return .array(results.map { Encoders.ocrSearchResult($0) })
|
|
735
|
+
}
|
|
736
|
+
))
|
|
737
|
+
|
|
738
|
+
api.register(Endpoint(
|
|
739
|
+
method: "ocr.scan",
|
|
740
|
+
description: "Trigger an immediate OCR scan",
|
|
741
|
+
access: .mutate,
|
|
742
|
+
params: [],
|
|
743
|
+
returns: .ok,
|
|
744
|
+
handler: { _ in
|
|
745
|
+
OcrModel.shared.scan()
|
|
746
|
+
return .object(["ok": .bool(true)])
|
|
747
|
+
}
|
|
748
|
+
))
|
|
749
|
+
|
|
750
|
+
// ── Endpoints: Mutations ────────────────────────────────
|
|
751
|
+
|
|
752
|
+
api.register(Endpoint(
|
|
753
|
+
method: "window.tile",
|
|
754
|
+
description: "Tile a session's terminal window to a position",
|
|
755
|
+
access: .mutate,
|
|
756
|
+
params: [
|
|
757
|
+
Param(name: "session", type: "string", required: true, description: "Tmux session name"),
|
|
758
|
+
Param(name: "position", type: "string", required: true,
|
|
759
|
+
description: "Tile position (\(TilePosition.allCases.map(\.rawValue).joined(separator: ", ")))"),
|
|
760
|
+
],
|
|
761
|
+
returns: .ok,
|
|
762
|
+
handler: { params in
|
|
763
|
+
guard let session = params?["session"]?.stringValue else {
|
|
764
|
+
throw RouterError.missingParam("session")
|
|
765
|
+
}
|
|
766
|
+
guard let posStr = params?["position"]?.stringValue,
|
|
767
|
+
let position = TilePosition(rawValue: posStr) else {
|
|
768
|
+
throw RouterError.missingParam("position (valid: \(TilePosition.allCases.map(\.rawValue).joined(separator: ", ")))")
|
|
769
|
+
}
|
|
770
|
+
let terminal = Preferences.shared.terminal
|
|
771
|
+
DispatchQueue.main.async {
|
|
772
|
+
WindowTiler.tile(session: session, terminal: terminal, to: position)
|
|
773
|
+
}
|
|
774
|
+
return .object(["ok": .bool(true)])
|
|
775
|
+
}
|
|
776
|
+
))
|
|
777
|
+
|
|
778
|
+
api.register(Endpoint(
|
|
779
|
+
method: "window.focus",
|
|
780
|
+
description: "Focus a window by wid or session name",
|
|
781
|
+
access: .mutate,
|
|
782
|
+
params: [
|
|
783
|
+
Param(name: "wid", type: "uint32", required: false, description: "Window ID (takes priority)"),
|
|
784
|
+
Param(name: "session", type: "string", required: false, description: "Tmux session name (fallback)"),
|
|
785
|
+
],
|
|
786
|
+
returns: .ok,
|
|
787
|
+
handler: { params in
|
|
788
|
+
if let wid = params?["wid"]?.uint32Value {
|
|
789
|
+
guard let entry = DesktopModel.shared.windows[wid] else {
|
|
790
|
+
throw RouterError.notFound("window \(wid)")
|
|
791
|
+
}
|
|
792
|
+
DispatchQueue.main.async {
|
|
793
|
+
WindowTiler.focusWindow(wid: wid, pid: entry.pid)
|
|
794
|
+
}
|
|
795
|
+
return .object(["ok": .bool(true), "wid": .int(Int(wid)), "app": .string(entry.app)])
|
|
796
|
+
}
|
|
797
|
+
guard let session = params?["session"]?.stringValue else {
|
|
798
|
+
throw RouterError.missingParam("session or wid")
|
|
799
|
+
}
|
|
800
|
+
let terminal = Preferences.shared.terminal
|
|
801
|
+
DispatchQueue.main.async {
|
|
802
|
+
WindowTiler.navigateToWindow(session: session, terminal: terminal)
|
|
803
|
+
}
|
|
804
|
+
return .object(["ok": .bool(true)])
|
|
805
|
+
}
|
|
806
|
+
))
|
|
807
|
+
|
|
808
|
+
api.register(Endpoint(
|
|
809
|
+
method: "window.move",
|
|
810
|
+
description: "Move a session's window to a different space",
|
|
811
|
+
access: .mutate,
|
|
812
|
+
params: [
|
|
813
|
+
Param(name: "session", type: "string", required: true, description: "Tmux session name"),
|
|
814
|
+
Param(name: "spaceId", type: "int", required: true, description: "Target space ID"),
|
|
815
|
+
],
|
|
816
|
+
returns: .ok,
|
|
817
|
+
handler: { params in
|
|
818
|
+
guard let session = params?["session"]?.stringValue else {
|
|
819
|
+
throw RouterError.missingParam("session")
|
|
820
|
+
}
|
|
821
|
+
guard let spaceId = params?["spaceId"]?.intValue else {
|
|
822
|
+
throw RouterError.missingParam("spaceId")
|
|
823
|
+
}
|
|
824
|
+
let terminal = Preferences.shared.terminal
|
|
825
|
+
DispatchQueue.main.async {
|
|
826
|
+
_ = WindowTiler.moveWindowToSpace(session: session, terminal: terminal, spaceId: spaceId)
|
|
827
|
+
}
|
|
828
|
+
return .object(["ok": .bool(true)])
|
|
829
|
+
}
|
|
830
|
+
))
|
|
831
|
+
|
|
832
|
+
api.register(Endpoint(
|
|
833
|
+
method: "session.launch",
|
|
834
|
+
description: "Launch a project's tmux session",
|
|
835
|
+
access: .mutate,
|
|
836
|
+
params: [Param(name: "path", type: "string", required: true, description: "Absolute project path")],
|
|
837
|
+
returns: .ok,
|
|
838
|
+
handler: { params in
|
|
839
|
+
guard let path = params?["path"]?.stringValue else {
|
|
840
|
+
throw RouterError.missingParam("path")
|
|
841
|
+
}
|
|
842
|
+
guard let project = ProjectScanner.shared.projects.first(where: { $0.path == path }) else {
|
|
843
|
+
throw RouterError.notFound("project at \(path)")
|
|
844
|
+
}
|
|
845
|
+
DispatchQueue.main.async {
|
|
846
|
+
SessionManager.launch(project: project)
|
|
847
|
+
}
|
|
848
|
+
return .object(["ok": .bool(true)])
|
|
849
|
+
}
|
|
850
|
+
))
|
|
851
|
+
|
|
852
|
+
api.register(Endpoint(
|
|
853
|
+
method: "session.kill",
|
|
854
|
+
description: "Kill a tmux session by name",
|
|
855
|
+
access: .mutate,
|
|
856
|
+
params: [Param(name: "name", type: "string", required: true, description: "Session name")],
|
|
857
|
+
returns: .ok,
|
|
858
|
+
handler: { params in
|
|
859
|
+
guard let name = params?["name"]?.stringValue else {
|
|
860
|
+
throw RouterError.missingParam("name")
|
|
861
|
+
}
|
|
862
|
+
SessionManager.killByName(name)
|
|
863
|
+
return .object(["ok": .bool(true)])
|
|
864
|
+
}
|
|
865
|
+
))
|
|
866
|
+
|
|
867
|
+
api.register(Endpoint(
|
|
868
|
+
method: "session.detach",
|
|
869
|
+
description: "Detach all clients from a tmux session",
|
|
870
|
+
access: .mutate,
|
|
871
|
+
params: [Param(name: "name", type: "string", required: true, description: "Session name")],
|
|
872
|
+
returns: .ok,
|
|
873
|
+
handler: { params in
|
|
874
|
+
guard let name = params?["name"]?.stringValue else {
|
|
875
|
+
throw RouterError.missingParam("name")
|
|
876
|
+
}
|
|
877
|
+
SessionManager.detachByName(name)
|
|
878
|
+
return .object(["ok": .bool(true)])
|
|
879
|
+
}
|
|
880
|
+
))
|
|
881
|
+
|
|
882
|
+
api.register(Endpoint(
|
|
883
|
+
method: "session.sync",
|
|
884
|
+
description: "Sync a project's tmux session panes to match config",
|
|
885
|
+
access: .mutate,
|
|
886
|
+
params: [Param(name: "path", type: "string", required: true, description: "Absolute project path")],
|
|
887
|
+
returns: .ok,
|
|
888
|
+
handler: { params in
|
|
889
|
+
guard let path = params?["path"]?.stringValue else {
|
|
890
|
+
throw RouterError.missingParam("path")
|
|
891
|
+
}
|
|
892
|
+
guard let project = ProjectScanner.shared.projects.first(where: { $0.path == path }) else {
|
|
893
|
+
throw RouterError.notFound("project at \(path)")
|
|
894
|
+
}
|
|
895
|
+
SessionManager.sync(project: project)
|
|
896
|
+
return .object(["ok": .bool(true)])
|
|
897
|
+
}
|
|
898
|
+
))
|
|
899
|
+
|
|
900
|
+
api.register(Endpoint(
|
|
901
|
+
method: "session.restart",
|
|
902
|
+
description: "Restart a project session or specific pane",
|
|
903
|
+
access: .mutate,
|
|
904
|
+
params: [
|
|
905
|
+
Param(name: "path", type: "string", required: true, description: "Absolute project path"),
|
|
906
|
+
Param(name: "pane", type: "string", required: false, description: "Specific pane name to restart"),
|
|
907
|
+
],
|
|
908
|
+
returns: .ok,
|
|
909
|
+
handler: { params in
|
|
910
|
+
guard let path = params?["path"]?.stringValue else {
|
|
911
|
+
throw RouterError.missingParam("path")
|
|
912
|
+
}
|
|
913
|
+
guard let project = ProjectScanner.shared.projects.first(where: { $0.path == path }) else {
|
|
914
|
+
throw RouterError.notFound("project at \(path)")
|
|
915
|
+
}
|
|
916
|
+
let paneName = params?["pane"]?.stringValue
|
|
917
|
+
SessionManager.restart(project: project, paneName: paneName)
|
|
918
|
+
return .object(["ok": .bool(true)])
|
|
919
|
+
}
|
|
920
|
+
))
|
|
921
|
+
|
|
922
|
+
api.register(Endpoint(
|
|
923
|
+
method: "layer.switch",
|
|
924
|
+
description: "Switch to a workspace layer by index or name",
|
|
925
|
+
access: .mutate,
|
|
926
|
+
params: [
|
|
927
|
+
Param(name: "index", type: "int", required: false, description: "Layer index"),
|
|
928
|
+
Param(name: "name", type: "string", required: false, description: "Layer id or label (case-insensitive)")
|
|
929
|
+
],
|
|
930
|
+
returns: .ok,
|
|
931
|
+
handler: { params in
|
|
932
|
+
let wm = WorkspaceManager.shared
|
|
933
|
+
let index: Int
|
|
934
|
+
if let i = params?["index"]?.intValue {
|
|
935
|
+
index = i
|
|
936
|
+
} else if let n = params?["name"]?.stringValue, let i = wm.layerIndex(named: n) {
|
|
937
|
+
index = i
|
|
938
|
+
} else {
|
|
939
|
+
throw RouterError.missingParam("index or name")
|
|
940
|
+
}
|
|
941
|
+
DispatchQueue.main.async {
|
|
942
|
+
wm.tileLayer(index: index, launch: true, force: true)
|
|
943
|
+
EventBus.shared.post(.layerSwitched(index: index))
|
|
944
|
+
}
|
|
945
|
+
return .object(["ok": .bool(true)])
|
|
946
|
+
}
|
|
947
|
+
))
|
|
948
|
+
|
|
949
|
+
api.register(Endpoint(
|
|
950
|
+
method: "group.launch",
|
|
951
|
+
description: "Launch all sessions in a project group",
|
|
952
|
+
access: .mutate,
|
|
953
|
+
params: [Param(name: "id", type: "string", required: true, description: "Group identifier")],
|
|
954
|
+
returns: .ok,
|
|
955
|
+
handler: { params in
|
|
956
|
+
guard let groupId = params?["id"]?.stringValue else {
|
|
957
|
+
throw RouterError.missingParam("id")
|
|
958
|
+
}
|
|
959
|
+
guard let group = WorkspaceManager.shared.group(byId: groupId) else {
|
|
960
|
+
throw RouterError.notFound("group \(groupId)")
|
|
961
|
+
}
|
|
962
|
+
DispatchQueue.main.async {
|
|
963
|
+
WorkspaceManager.shared.launchGroup(group)
|
|
964
|
+
}
|
|
965
|
+
return .object(["ok": .bool(true)])
|
|
966
|
+
}
|
|
967
|
+
))
|
|
968
|
+
|
|
969
|
+
api.register(Endpoint(
|
|
970
|
+
method: "group.kill",
|
|
971
|
+
description: "Kill all sessions in a project group",
|
|
972
|
+
access: .mutate,
|
|
973
|
+
params: [Param(name: "id", type: "string", required: true, description: "Group identifier")],
|
|
974
|
+
returns: .ok,
|
|
975
|
+
handler: { params in
|
|
976
|
+
guard let groupId = params?["id"]?.stringValue else {
|
|
977
|
+
throw RouterError.missingParam("id")
|
|
978
|
+
}
|
|
979
|
+
guard let group = WorkspaceManager.shared.group(byId: groupId) else {
|
|
980
|
+
throw RouterError.notFound("group \(groupId)")
|
|
981
|
+
}
|
|
982
|
+
WorkspaceManager.shared.killGroup(group)
|
|
983
|
+
return .object(["ok": .bool(true)])
|
|
984
|
+
}
|
|
985
|
+
))
|
|
986
|
+
|
|
987
|
+
api.register(Endpoint(
|
|
988
|
+
method: "projects.scan",
|
|
989
|
+
description: "Trigger a rescan of project directories",
|
|
990
|
+
access: .mutate,
|
|
991
|
+
params: [],
|
|
992
|
+
returns: .ok,
|
|
993
|
+
handler: { _ in
|
|
994
|
+
DispatchQueue.main.async {
|
|
995
|
+
ProjectScanner.shared.scan()
|
|
996
|
+
}
|
|
997
|
+
return .object(["ok": .bool(true)])
|
|
998
|
+
}
|
|
999
|
+
))
|
|
1000
|
+
|
|
1001
|
+
api.register(Endpoint(
|
|
1002
|
+
method: "layout.distribute",
|
|
1003
|
+
description: "Distribute visible windows evenly across the screen",
|
|
1004
|
+
access: .mutate,
|
|
1005
|
+
params: [],
|
|
1006
|
+
returns: .ok,
|
|
1007
|
+
handler: { _ in
|
|
1008
|
+
DispatchQueue.main.async {
|
|
1009
|
+
WindowTiler.distributeVisible()
|
|
1010
|
+
}
|
|
1011
|
+
return .object(["ok": .bool(true)])
|
|
1012
|
+
}
|
|
1013
|
+
))
|
|
1014
|
+
|
|
1015
|
+
// ── Meta endpoint ───────────────────────────────────────
|
|
1016
|
+
|
|
1017
|
+
api.register(Endpoint(
|
|
1018
|
+
method: "api.schema",
|
|
1019
|
+
description: "Get the full API schema including all methods and models",
|
|
1020
|
+
access: .read,
|
|
1021
|
+
params: [],
|
|
1022
|
+
returns: .custom("Full API schema with version, models, and methods"),
|
|
1023
|
+
handler: { _ in
|
|
1024
|
+
api.schema()
|
|
1025
|
+
}
|
|
1026
|
+
))
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// MARK: - Encoders
|
|
1031
|
+
|
|
1032
|
+
enum Encoders {
|
|
1033
|
+
static func window(_ w: WindowEntry) -> JSON {
|
|
1034
|
+
var obj: [String: JSON] = [
|
|
1035
|
+
"wid": .int(Int(w.wid)),
|
|
1036
|
+
"app": .string(w.app),
|
|
1037
|
+
"pid": .int(Int(w.pid)),
|
|
1038
|
+
"title": .string(w.title),
|
|
1039
|
+
"frame": .object([
|
|
1040
|
+
"x": .double(w.frame.x),
|
|
1041
|
+
"y": .double(w.frame.y),
|
|
1042
|
+
"w": .double(w.frame.w),
|
|
1043
|
+
"h": .double(w.frame.h)
|
|
1044
|
+
]),
|
|
1045
|
+
"spaceIds": .array(w.spaceIds.map { .int($0) }),
|
|
1046
|
+
"isOnScreen": .bool(w.isOnScreen)
|
|
1047
|
+
]
|
|
1048
|
+
if let session = w.latticesSession {
|
|
1049
|
+
obj["latticesSession"] = .string(session)
|
|
1050
|
+
}
|
|
1051
|
+
if let layerTag = DesktopModel.shared.windowLayerTags[w.wid] {
|
|
1052
|
+
obj["layerTag"] = .string(layerTag)
|
|
1053
|
+
}
|
|
1054
|
+
return .object(obj)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
static func session(_ s: TmuxSession) -> JSON {
|
|
1058
|
+
.object([
|
|
1059
|
+
"name": .string(s.name),
|
|
1060
|
+
"windowCount": .int(s.windowCount),
|
|
1061
|
+
"attached": .bool(s.attached),
|
|
1062
|
+
"panes": .array(s.panes.map { pane in
|
|
1063
|
+
.object([
|
|
1064
|
+
"id": .string(pane.id),
|
|
1065
|
+
"windowIndex": .int(pane.windowIndex),
|
|
1066
|
+
"windowName": .string(pane.windowName),
|
|
1067
|
+
"title": .string(pane.title),
|
|
1068
|
+
"currentCommand": .string(pane.currentCommand),
|
|
1069
|
+
"pid": .int(pane.pid),
|
|
1070
|
+
"isActive": .bool(pane.isActive)
|
|
1071
|
+
])
|
|
1072
|
+
})
|
|
1073
|
+
])
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
static func process(_ e: ProcessModel.Enrichment) -> JSON {
|
|
1077
|
+
var obj: [String: JSON] = [
|
|
1078
|
+
"pid": .int(e.process.pid),
|
|
1079
|
+
"ppid": .int(e.process.ppid),
|
|
1080
|
+
"command": .string(e.process.comm),
|
|
1081
|
+
"args": .string(e.process.args),
|
|
1082
|
+
"tty": .string(e.process.tty),
|
|
1083
|
+
]
|
|
1084
|
+
if let cwd = e.process.cwd { obj["cwd"] = .string(cwd) }
|
|
1085
|
+
if let s = e.tmuxSession { obj["tmuxSession"] = .string(s) }
|
|
1086
|
+
if let p = e.tmuxPaneId { obj["tmuxPaneId"] = .string(p) }
|
|
1087
|
+
if let w = e.windowId { obj["windowId"] = .int(Int(w)) }
|
|
1088
|
+
return .object(obj)
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
static func paneChild(_ entry: ProcessEntry) -> JSON {
|
|
1092
|
+
var obj: [String: JSON] = [
|
|
1093
|
+
"pid": .int(entry.pid),
|
|
1094
|
+
"command": .string(entry.comm),
|
|
1095
|
+
"args": .string(entry.args),
|
|
1096
|
+
]
|
|
1097
|
+
if let cwd = entry.cwd { obj["cwd"] = .string(cwd) }
|
|
1098
|
+
return .object(obj)
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
static func terminalInstance(_ inst: TerminalInstance) -> JSON {
|
|
1102
|
+
var obj: [String: JSON] = [
|
|
1103
|
+
"tty": .string(inst.tty),
|
|
1104
|
+
"isActiveTab": .bool(inst.isActiveTab),
|
|
1105
|
+
"hasClaude": .bool(inst.hasClaude),
|
|
1106
|
+
"displayName": .string(inst.displayName),
|
|
1107
|
+
"processes": .array(inst.processes.map { entry in
|
|
1108
|
+
var p: [String: JSON] = [
|
|
1109
|
+
"pid": .int(entry.pid),
|
|
1110
|
+
"ppid": .int(entry.ppid),
|
|
1111
|
+
"command": .string(entry.comm),
|
|
1112
|
+
"args": .string(entry.args),
|
|
1113
|
+
"tty": .string(entry.tty),
|
|
1114
|
+
]
|
|
1115
|
+
if let cwd = entry.cwd { p["cwd"] = .string(cwd) }
|
|
1116
|
+
return .object(p)
|
|
1117
|
+
}),
|
|
1118
|
+
]
|
|
1119
|
+
if let app = inst.app { obj["app"] = .string(app.rawValue) }
|
|
1120
|
+
if let wi = inst.windowIndex { obj["windowIndex"] = .int(wi) }
|
|
1121
|
+
if let ti = inst.tabIndex { obj["tabIndex"] = .int(ti) }
|
|
1122
|
+
if let title = inst.tabTitle { obj["tabTitle"] = .string(title) }
|
|
1123
|
+
if let sid = inst.terminalSessionId { obj["terminalSessionId"] = .string(sid) }
|
|
1124
|
+
if let pid = inst.shellPid { obj["shellPid"] = .int(pid) }
|
|
1125
|
+
if let cwd = inst.cwd { obj["cwd"] = .string(cwd) }
|
|
1126
|
+
if let s = inst.tmuxSession { obj["tmuxSession"] = .string(s) }
|
|
1127
|
+
if let p = inst.tmuxPaneId { obj["tmuxPaneId"] = .string(p) }
|
|
1128
|
+
if let w = inst.windowId { obj["windowId"] = .int(Int(w)) }
|
|
1129
|
+
if let t = inst.windowTitle { obj["windowTitle"] = .string(t) }
|
|
1130
|
+
return .object(obj)
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
static func ocrResult(_ r: OcrWindowResult) -> JSON {
|
|
1134
|
+
.object([
|
|
1135
|
+
"wid": .int(Int(r.wid)),
|
|
1136
|
+
"app": .string(r.app),
|
|
1137
|
+
"title": .string(r.title),
|
|
1138
|
+
"frame": .object([
|
|
1139
|
+
"x": .double(r.frame.x),
|
|
1140
|
+
"y": .double(r.frame.y),
|
|
1141
|
+
"w": .double(r.frame.w),
|
|
1142
|
+
"h": .double(r.frame.h)
|
|
1143
|
+
]),
|
|
1144
|
+
"fullText": .string(r.fullText),
|
|
1145
|
+
"blocks": .array(r.texts.map { block in
|
|
1146
|
+
.object([
|
|
1147
|
+
"text": .string(block.text),
|
|
1148
|
+
"confidence": .double(Double(block.confidence)),
|
|
1149
|
+
"x": .double(block.boundingBox.origin.x),
|
|
1150
|
+
"y": .double(block.boundingBox.origin.y),
|
|
1151
|
+
"w": .double(block.boundingBox.size.width),
|
|
1152
|
+
"h": .double(block.boundingBox.size.height)
|
|
1153
|
+
])
|
|
1154
|
+
}),
|
|
1155
|
+
"timestamp": .double(r.timestamp.timeIntervalSince1970),
|
|
1156
|
+
"source": .string(r.source.rawValue)
|
|
1157
|
+
])
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
static func ocrSearchResult(_ r: OcrSearchResult) -> JSON {
|
|
1161
|
+
.object([
|
|
1162
|
+
"id": .int(Int(r.id)),
|
|
1163
|
+
"wid": .int(Int(r.wid)),
|
|
1164
|
+
"app": .string(r.app),
|
|
1165
|
+
"title": .string(r.title),
|
|
1166
|
+
"frame": .object([
|
|
1167
|
+
"x": .double(r.frame.x),
|
|
1168
|
+
"y": .double(r.frame.y),
|
|
1169
|
+
"w": .double(r.frame.w),
|
|
1170
|
+
"h": .double(r.frame.h)
|
|
1171
|
+
]),
|
|
1172
|
+
"fullText": .string(r.fullText),
|
|
1173
|
+
"snippet": .string(r.snippet),
|
|
1174
|
+
"timestamp": .double(r.timestamp.timeIntervalSince1970),
|
|
1175
|
+
"source": .string(r.source.rawValue)
|
|
1176
|
+
])
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
static func enrichedSession(_ s: TmuxSession) -> JSON {
|
|
1180
|
+
let pm = ProcessModel.shared
|
|
1181
|
+
return .object([
|
|
1182
|
+
"name": .string(s.name),
|
|
1183
|
+
"windowCount": .int(s.windowCount),
|
|
1184
|
+
"attached": .bool(s.attached),
|
|
1185
|
+
"panes": .array(s.panes.map { pane in
|
|
1186
|
+
let children = pm.interestingDescendants(of: pane.pid)
|
|
1187
|
+
var obj: [String: JSON] = [
|
|
1188
|
+
"id": .string(pane.id),
|
|
1189
|
+
"windowIndex": .int(pane.windowIndex),
|
|
1190
|
+
"windowName": .string(pane.windowName),
|
|
1191
|
+
"title": .string(pane.title),
|
|
1192
|
+
"currentCommand": .string(pane.currentCommand),
|
|
1193
|
+
"pid": .int(pane.pid),
|
|
1194
|
+
"isActive": .bool(pane.isActive),
|
|
1195
|
+
]
|
|
1196
|
+
if !children.isEmpty {
|
|
1197
|
+
obj["children"] = .array(children.map { Encoders.paneChild($0) })
|
|
1198
|
+
}
|
|
1199
|
+
return .object(obj)
|
|
1200
|
+
})
|
|
1201
|
+
])
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
static func project(_ p: Project) -> JSON {
|
|
1205
|
+
var obj: [String: JSON] = [
|
|
1206
|
+
"path": .string(p.path),
|
|
1207
|
+
"name": .string(p.name),
|
|
1208
|
+
"sessionName": .string(p.sessionName),
|
|
1209
|
+
"isRunning": .bool(p.isRunning),
|
|
1210
|
+
"hasConfig": .bool(p.hasConfig),
|
|
1211
|
+
"paneCount": .int(p.paneCount),
|
|
1212
|
+
"paneNames": .array(p.paneNames.map { .string($0) })
|
|
1213
|
+
]
|
|
1214
|
+
if let cmd = p.devCommand { obj["devCommand"] = .string(cmd) }
|
|
1215
|
+
if let pm = p.packageManager { obj["packageManager"] = .string(pm) }
|
|
1216
|
+
return .object(obj)
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// MARK: - Errors
|
|
1221
|
+
|
|
1222
|
+
enum RouterError: LocalizedError {
|
|
1223
|
+
case unknownMethod(String)
|
|
1224
|
+
case missingParam(String)
|
|
1225
|
+
case notFound(String)
|
|
1226
|
+
|
|
1227
|
+
var errorDescription: String? {
|
|
1228
|
+
switch self {
|
|
1229
|
+
case .unknownMethod(let m): return "Unknown method: \(m)"
|
|
1230
|
+
case .missingParam(let p): return "Missing parameter: \(p)"
|
|
1231
|
+
case .notFound(let what): return "Not found: \(what)"
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|