@velarscript/desktop 0.10.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.
@@ -0,0 +1,1258 @@
1
+ import Cocoa
2
+ import Darwin
3
+ import Foundation
4
+ import WebKit
5
+
6
+ private let bridgeScript = #"""
7
+ (() => {
8
+ const hostApply = Reflect.apply
9
+ const hostArray = Array
10
+ const hostArrayIsArray = Array.isArray
11
+ const hostAtob = atob
12
+ const hostBtoa = btoa
13
+ const hostClearTimeout = clearTimeout
14
+ const hostCrypto = globalThis.crypto
15
+ const hostCryptoGetRandomValues = hostCrypto?.getRandomValues
16
+ const hostError = Error
17
+ const hostJsonParse = JSON.parse
18
+ const hostJsonStringify = JSON.stringify
19
+ const hostMap = Map
20
+ const hostMapDelete = Map.prototype.delete
21
+ const hostMapGet = Map.prototype.get
22
+ const hostMapHas = Map.prototype.has
23
+ const hostMapSet = Map.prototype.set
24
+ const hostMapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size").get
25
+ const hostMathCeil = Math.ceil
26
+ const hostMathMin = Math.min
27
+ const hostNumberIsSafeInteger = Number.isSafeInteger
28
+ const hostNumberMaxSafeInteger = Number.MAX_SAFE_INTEGER
29
+ const hostObjectDefineProperty = Object.defineProperty
30
+ const hostPromise = Promise
31
+ const hostRangeError = RangeError
32
+ const hostSetTimeout = setTimeout
33
+ const hostStringCharCodeAt = String.prototype.charCodeAt
34
+ const hostStringFromCharCode = String.fromCharCode
35
+ const hostTextDecoder = new TextDecoder("utf-8", {fatal: true})
36
+ const hostTextDecode = TextDecoder.prototype.decode
37
+ const hostTextEncoder = new TextEncoder()
38
+ const hostTextEncode = TextEncoder.prototype.encode
39
+ const hostTypeError = TypeError
40
+ const hostUint8Array = Uint8Array
41
+ const hostUint8ArraySet = Uint8Array.prototype.set
42
+ const hostUint8ArraySubarray = Uint8Array.prototype.subarray
43
+ const hostMessageHandler = webkit.messageHandlers.velarDesktop
44
+ const hostPostMessage = hostMessageHandler.postMessage
45
+ let hostProjectDirectory = __VELAR_PROJECT_DIRECTORY__
46
+ const mapDelete = (map, key) => hostApply(hostMapDelete, map, [key])
47
+ const mapGet = (map, key) => hostApply(hostMapGet, map, [key])
48
+ const mapHas = (map, key) => hostApply(hostMapHas, map, [key])
49
+ const mapSet = (map, key, value) => hostApply(hostMapSet, map, [key, value])
50
+ const mapSize = (map) => hostApply(hostMapSize, map, [])
51
+ const key = Symbol.for("velar.desktop.bridge.v1")
52
+ if (Object.getOwnPropertyDescriptor(globalThis, key)) throw new Error("VelarScript Desktop bridge already exists")
53
+ if (typeof hostCryptoGetRandomValues !== "function") throw new Error("VelarScript Desktop requires Web Crypto")
54
+ const generationBytes = new hostUint8Array(16)
55
+ hostApply(hostCryptoGetRandomValues, hostCrypto, [generationBytes])
56
+ const hex = "0123456789abcdef"
57
+ let generation = ""
58
+ for (const byte of generationBytes) generation += hex[byte >>> 4] + hex[byte & 15]
59
+ const pending = new hostMap()
60
+ const responseChunks = new hostMap()
61
+ let pendingRequestBytes = 0
62
+ let responseBytes = 0
63
+ let nextId = 1
64
+ const dropResponseChunks = (id) => {
65
+ const state = mapGet(responseChunks, id)
66
+ if (!state) return
67
+ responseBytes -= state.bytes
68
+ mapDelete(responseChunks, id)
69
+ }
70
+ const allocateId = () => {
71
+ for (let attempt = 0; attempt <= 1024; attempt += 1) {
72
+ const id = nextId
73
+ nextId = nextId >= hostNumberMaxSafeInteger ? 1 : nextId + 1
74
+ if (!mapHas(pending, id)) return id
75
+ }
76
+ throw new hostRangeError("Desktop request identity space is exhausted")
77
+ }
78
+ const complete = (owner, message) => {
79
+ if (owner !== generation) return
80
+ if (!message || typeof message !== "object" || !hostNumberIsSafeInteger(message.id)) return
81
+ const request = mapGet(pending, message.id)
82
+ if (!request) return
83
+ mapDelete(pending, message.id)
84
+ pendingRequestBytes -= request.bytes
85
+ dropResponseChunks(message.id)
86
+ if (request.timer !== null) hostClearTimeout(request.timer)
87
+ if (message.ok === true) {
88
+ if (request.capability === "desktop" && request.operation === "selectProjectDirectory"
89
+ && typeof message.value === "string" && message.value.startsWith("/")
90
+ && message.value.length <= 4096 && !message.value.includes("\0")) hostProjectDirectory = message.value
91
+ request.resolve(message.value)
92
+ }
93
+ else if (message.error && typeof message.error === "object"
94
+ && message.error.kind === "http-transport"
95
+ && (message.error.phase === "request" || message.error.phase === "response")
96
+ && typeof message.error.message === "string" && message.error.message.length > 0 && message.error.message.length <= 65536) {
97
+ const failure = new hostError(message.error.message)
98
+ hostObjectDefineProperty(failure, "name", {value: "VelarDesktopHttpTransportError", enumerable: false, configurable: false, writable: false})
99
+ hostObjectDefineProperty(failure, "phase", {value: message.error.phase, enumerable: true, configurable: false, writable: false})
100
+ request.reject(failure)
101
+ } else request.reject(new hostError(typeof message.error === "string" ? message.error : "Desktop host request failed"))
102
+ }
103
+ Object.defineProperty(globalThis, "__velarDesktopComplete", {
104
+ value: complete, enumerable: false, configurable: false, writable: false,
105
+ })
106
+ const decodeBase64 = (value) => {
107
+ const binary = hostApply(hostAtob, globalThis, [value])
108
+ const bytes = new hostUint8Array(binary.length)
109
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = hostApply(hostStringCharCodeAt, binary, [index])
110
+ return bytes
111
+ }
112
+ const encodeBase64 = (bytes) => {
113
+ let binary = ""
114
+ for (let index = 0; index < bytes.length; index += 32768) {
115
+ binary += hostApply(hostStringFromCharCode, String, hostApply(hostUint8ArraySubarray, bytes, [index, index + 32768]))
116
+ }
117
+ return hostApply(hostBtoa, globalThis, [binary])
118
+ }
119
+ const receiveChunk = (owner, id, index, total, encoded) => {
120
+ if (owner !== generation) return
121
+ if (!hostNumberIsSafeInteger(id) || !mapHas(pending, id) || !hostNumberIsSafeInteger(index) || index < 0
122
+ || !hostNumberIsSafeInteger(total) || total < 1 || total > 1024 || index >= total
123
+ || typeof encoded !== "string" || encoded.length > 262144) return
124
+ try {
125
+ let state = mapGet(responseChunks, id)
126
+ if (!state) {
127
+ state = {total, parts: new hostArray(total), count: 0, bytes: 0}
128
+ mapSet(responseChunks, id, state)
129
+ }
130
+ if (state.total !== total || state.parts[index]) throw new hostError("Invalid Desktop response chunk sequence")
131
+ const part = decodeBase64(encoded)
132
+ state.parts[index] = part
133
+ state.count += 1
134
+ state.bytes += part.byteLength
135
+ responseBytes += part.byteLength
136
+ if (state.bytes > 65 * 1024 * 1024 || responseBytes > 128 * 1024 * 1024) throw new hostError("Desktop response exceeds its transport bound")
137
+ if (state.count !== state.total) return
138
+ const bytes = new hostUint8Array(state.bytes)
139
+ let offset = 0
140
+ for (const item of state.parts) { hostApply(hostUint8ArraySet, bytes, [item, offset]); offset += item.byteLength }
141
+ dropResponseChunks(id)
142
+ complete(owner, hostApply(hostJsonParse, JSON, [hostApply(hostTextDecode, hostTextDecoder, [bytes])]))
143
+ } catch (error) {
144
+ dropResponseChunks(id)
145
+ complete(owner, {id, ok: false, error: error instanceof hostError ? error.message : "Invalid Desktop response transport"})
146
+ }
147
+ }
148
+ Object.defineProperty(globalThis, "__velarDesktopTransportChunk", {
149
+ value: receiveChunk, enumerable: false, configurable: false, writable: false,
150
+ })
151
+ const bridge = Object.freeze({
152
+ platform: "macos",
153
+ packaged: true,
154
+ projectDirectory: hostProjectDirectory,
155
+ projectDirectoryValue: () => hostProjectDirectory,
156
+ environment: Object.freeze(__VELAR_ENVIRONMENT__),
157
+ invoke(capability, operation, args, timeoutMs = 30000) {
158
+ if (typeof capability !== "string" || typeof operation !== "string" || !hostArrayIsArray(args)) {
159
+ return hostPromise.reject(new hostTypeError("Invalid Desktop bridge request"))
160
+ }
161
+ if (!hostNumberIsSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 600000) {
162
+ return hostPromise.reject(new hostRangeError("Invalid Desktop bridge timeout"))
163
+ }
164
+ if (mapSize(pending) >= 1024) return hostPromise.reject(new hostRangeError("Too many pending Desktop requests"))
165
+ const id = allocateId()
166
+ return new hostPromise((resolve, reject) => {
167
+ const requestState = {resolve, reject, timer: null, bytes: 0, capability, operation}
168
+ const timer = timeoutMs === 0 ? null : hostSetTimeout(() => {
169
+ const current = mapGet(pending, id)
170
+ if (current !== requestState) return
171
+ mapDelete(pending, id)
172
+ pendingRequestBytes -= requestState.bytes
173
+ dropResponseChunks(id)
174
+ try {
175
+ hostApply(hostPostMessage, hostMessageHandler, [{protocolVersion: 1, transport: "cancel", generation, id}])
176
+ } catch {}
177
+ reject(new hostError("Desktop host request timed out"))
178
+ }, timeoutMs)
179
+ requestState.timer = timer
180
+ mapSet(pending, id, requestState)
181
+ try {
182
+ const request = {protocolVersion: 1, generation, id, capability, operation, args}
183
+ const bytes = hostApply(hostTextEncode, hostTextEncoder, [hostApply(hostJsonStringify, JSON, [request])])
184
+ if (bytes.byteLength > 128 * 1024 * 1024) throw new hostRangeError("Desktop request exceeds its transport bound")
185
+ if (pendingRequestBytes + bytes.byteLength > 128 * 1024 * 1024) throw new hostRangeError("Pending Desktop requests exceed their aggregate transport bound")
186
+ requestState.bytes = bytes.byteLength
187
+ pendingRequestBytes += bytes.byteLength
188
+ if (bytes.byteLength <= 512 * 1024) {
189
+ hostApply(hostPostMessage, hostMessageHandler, [request])
190
+ } else {
191
+ const chunkBytes = 192 * 1024
192
+ const total = hostMathCeil(bytes.byteLength / chunkBytes)
193
+ if (total > 1024) throw new hostRangeError("Desktop request has too many transport chunks")
194
+ for (let index = 0; index < total; index += 1) {
195
+ const part = hostApply(hostUint8ArraySubarray, bytes, [index * chunkBytes, hostMathMin(bytes.byteLength, (index + 1) * chunkBytes)])
196
+ hostApply(hostPostMessage, hostMessageHandler, [{protocolVersion: 1, transport: "chunk", generation, id, index, total, base64: encodeBase64(part)}])
197
+ }
198
+ }
199
+ } catch (error) {
200
+ if (timer !== null) hostClearTimeout(timer)
201
+ mapDelete(pending, id)
202
+ pendingRequestBytes -= requestState.bytes
203
+ reject(error)
204
+ }
205
+ })
206
+ },
207
+ })
208
+ Object.defineProperty(globalThis, key, {value: bridge, enumerable: false, configurable: false, writable: false})
209
+ })()
210
+ """#
211
+
212
+ private struct WindowConfiguration: Decodable {
213
+ let title: String
214
+ let width: Int
215
+ let height: Int
216
+ let minWidth: Int
217
+ let minHeight: Int
218
+ }
219
+
220
+ private struct HostConfiguration: Decodable {
221
+ let protocolVersion: Int
222
+ let productName: String
223
+ let identifier: String
224
+ let nodeMinimumMajor: Int
225
+ let window: WindowConfiguration
226
+ let permissions: PermissionConfiguration
227
+ }
228
+
229
+ private struct PermissionConfiguration: Decodable {
230
+ let files: [String]
231
+ let environment: [String]
232
+ let secrets: [String]
233
+ }
234
+
235
+ private struct BridgeIdentity: Hashable {
236
+ let generation: String
237
+ let id: Int
238
+ }
239
+
240
+ private func validatedBridgeGeneration(_ value: Any?) -> String? {
241
+ guard let value = value as? String, value.utf8.count == 32,
242
+ value.utf8.allSatisfy({ ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102) }) else { return nil }
243
+ return value
244
+ }
245
+
246
+ private struct BridgeRequest {
247
+ let generation: String
248
+ let id: Int
249
+ let capability: String
250
+ let operation: String
251
+ let arguments: [Any]
252
+
253
+ init?(_ body: [String: Any]) {
254
+ guard body.count <= 6,
255
+ body["protocolVersion"] as? Int == 1,
256
+ let generation = validatedBridgeGeneration(body["generation"]),
257
+ let id = body["id"] as? Int, id > 0,
258
+ let capability = body["capability"] as? String, !capability.isEmpty, capability.count <= 128,
259
+ let operation = body["operation"] as? String, !operation.isEmpty, operation.count <= 128,
260
+ let arguments = body["args"] as? [Any], arguments.count <= 1024,
261
+ let encoded = try? JSONSerialization.data(withJSONObject: body), encoded.count <= 128 * 1024 * 1024 else {
262
+ return nil
263
+ }
264
+ self.generation = generation
265
+ self.id = id
266
+ self.capability = capability
267
+ self.operation = operation
268
+ self.arguments = arguments
269
+ }
270
+ }
271
+
272
+ private struct BridgeTransportChunk {
273
+ let generation: String
274
+ let id: Int
275
+ let index: Int
276
+ let total: Int
277
+ let data: Data
278
+
279
+ init?(_ body: [String: Any]) {
280
+ guard body.count <= 8,
281
+ body["protocolVersion"] as? Int == 1,
282
+ body["transport"] as? String == "chunk",
283
+ let generation = validatedBridgeGeneration(body["generation"]),
284
+ let id = body["id"] as? Int, id > 0,
285
+ let index = body["index"] as? Int, index >= 0,
286
+ let total = body["total"] as? Int, total >= 1, total <= 1024, index < total,
287
+ let base64 = body["base64"] as? String, base64.count <= 262144,
288
+ let data = Data(base64Encoded: base64), data.count <= 192 * 1024 else { return nil }
289
+ self.generation = generation
290
+ self.id = id
291
+ self.index = index
292
+ self.total = total
293
+ self.data = data
294
+ }
295
+ }
296
+
297
+ private struct BridgeTransportCancel {
298
+ let identity: BridgeIdentity
299
+
300
+ init?(_ body: [String: Any]) {
301
+ guard body.count <= 5,
302
+ body["protocolVersion"] as? Int == 1,
303
+ body["transport"] as? String == "cancel",
304
+ let generation = validatedBridgeGeneration(body["generation"]),
305
+ let id = body["id"] as? Int, id > 0 else { return nil }
306
+ self.identity = BridgeIdentity(generation: generation, id: id)
307
+ }
308
+ }
309
+
310
+ private func deliverBridgeResponse(_ data: Data, generation: String, to webView: WKWebView?) {
311
+ guard validatedBridgeGeneration(generation) != nil else { return }
312
+ guard let id = responseIdentifier(data) else { return }
313
+ let chunkBytes = 192 * 1024
314
+ let total = max(1, (data.count + chunkBytes - 1) / chunkBytes)
315
+ guard total <= 1024 else { return }
316
+ for index in 0..<total {
317
+ let lower = index * chunkBytes
318
+ let upper = min(data.count, lower + chunkBytes)
319
+ let encoded = data.subdata(in: lower..<upper).base64EncodedString()
320
+ webView?.evaluateJavaScript("globalThis.__velarDesktopTransportChunk(\"\(generation)\",\(id),\(index),\(total),\"\(encoded)\")")
321
+ }
322
+ }
323
+
324
+ private func responseIdentifier(_ data: Data) -> Int? {
325
+ guard let value = try? JSONSerialization.jsonObject(with: data),
326
+ let object = value as? [String: Any],
327
+ let id = object["id"] as? Int, id > 0 else { return nil }
328
+ return id
329
+ }
330
+
331
+ private func resolveNodeRuntime(_ configuration: HostConfiguration) throws -> URL {
332
+ var candidates: [String] = []
333
+ if let override = ProcessInfo.processInfo.environment["VELAR_DESKTOP_NODE"], !override.isEmpty {
334
+ candidates.append(override)
335
+ }
336
+ if let path = ProcessInfo.processInfo.environment["PATH"] {
337
+ candidates.append(contentsOf: path.split(separator: ":").map { String($0) + "/node" })
338
+ }
339
+ candidates.append(contentsOf: ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"])
340
+ var visited = Set<String>()
341
+ for candidate in candidates where candidate.hasPrefix("/") && visited.insert(candidate).inserted {
342
+ let url = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
343
+ guard FileManager.default.isExecutableFile(atPath: url.path),
344
+ let major = nodeMajorVersion(url), major >= configuration.nodeMinimumMajor else { continue }
345
+ return url
346
+ }
347
+ throw NSError(
348
+ domain: "VelarDesktop",
349
+ code: 5,
350
+ userInfo: [NSLocalizedDescriptionKey: "Desktop thin runtime requires Node.js \(configuration.nodeMinimumMajor) or newer; set VELAR_DESKTOP_NODE to an absolute executable path"]
351
+ )
352
+ }
353
+
354
+ private func resolveProjectDirectory(_ fallback: URL) throws -> String {
355
+ let value = ProcessInfo.processInfo.environment["VELAR_DESKTOP_PROJECT_ROOT"] ?? fallback.path
356
+ guard value.hasPrefix("/"), !value.contains("\0"), value.utf8.count <= 4096 else {
357
+ throw NSError(
358
+ domain: "VelarDesktop",
359
+ code: 6,
360
+ userInfo: [NSLocalizedDescriptionKey: "VELAR_DESKTOP_PROJECT_ROOT must be an absolute path of at most 4096 UTF-8 bytes"]
361
+ )
362
+ }
363
+ let directory = URL(fileURLWithPath: value, isDirectory: true).resolvingSymlinksInPath().standardizedFileURL
364
+ var isDirectory: ObjCBool = false
365
+ guard FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory), isDirectory.boolValue else {
366
+ throw NSError(
367
+ domain: "VelarDesktop",
368
+ code: 6,
369
+ userInfo: [NSLocalizedDescriptionKey: "VELAR_DESKTOP_PROJECT_ROOT must identify an existing directory"]
370
+ )
371
+ }
372
+ return directory.path
373
+ }
374
+
375
+ private final class ProjectDirectoryGrant {
376
+ private let bookmark: URL
377
+ private var scopedURL: URL?
378
+ private(set) var directory: String
379
+ private(set) var selection: String?
380
+
381
+ init(defaultDirectory: URL, appData: URL, projectFilesGranted: Bool) throws {
382
+ bookmark = appData.appendingPathComponent("project-directory.bookmark", isDirectory: false)
383
+ directory = try resolveProjectDirectory(defaultDirectory)
384
+ selection = projectFilesGranted && ProcessInfo.processInfo.environment["VELAR_DESKTOP_PROJECT_ROOT"] != nil ? directory : nil
385
+ guard projectFilesGranted else {
386
+ try? FileManager.default.removeItem(at: bookmark)
387
+ return
388
+ }
389
+ guard selection == nil, FileManager.default.fileExists(atPath: bookmark.path) else { return }
390
+ do {
391
+ let data = try Data(contentsOf: bookmark)
392
+ guard data.count <= 1024 * 1024 else { throw NSError(domain: "VelarDesktop", code: 7, userInfo: [NSLocalizedDescriptionKey: "Desktop project bookmark exceeds 1 MiB"]) }
393
+ var stale = false
394
+ let restored = try URL(
395
+ resolvingBookmarkData: data,
396
+ options: [.withSecurityScope],
397
+ relativeTo: nil,
398
+ bookmarkDataIsStale: &stale
399
+ )
400
+ let validated = try Self.validated(restored)
401
+ if validated.startAccessingSecurityScopedResource() { scopedURL = validated }
402
+ directory = validated.path
403
+ selection = validated.path
404
+ if stale { try persist(validated) }
405
+ } catch {
406
+ scopedURL?.stopAccessingSecurityScopedResource()
407
+ scopedURL = nil
408
+ selection = nil
409
+ try? FileManager.default.removeItem(at: bookmark)
410
+ }
411
+ }
412
+
413
+ func select() throws -> String? {
414
+ let panel = NSOpenPanel()
415
+ panel.title = "Choose a VelarScript project"
416
+ panel.prompt = "Open"
417
+ panel.canChooseDirectories = true
418
+ panel.canChooseFiles = false
419
+ panel.allowsMultipleSelection = false
420
+ panel.canCreateDirectories = true
421
+ panel.resolvesAliases = true
422
+ panel.directoryURL = URL(fileURLWithPath: directory, isDirectory: true)
423
+ guard panel.runModal() == .OK, let value = panel.url else { return nil }
424
+ let validated = try Self.validated(value)
425
+ try persist(validated)
426
+ let acquired = validated.startAccessingSecurityScopedResource()
427
+ scopedURL?.stopAccessingSecurityScopedResource()
428
+ scopedURL = acquired ? validated : nil
429
+ directory = validated.path
430
+ selection = validated.path
431
+ return validated.path
432
+ }
433
+
434
+ func release() {
435
+ scopedURL?.stopAccessingSecurityScopedResource()
436
+ scopedURL = nil
437
+ }
438
+
439
+ private func persist(_ value: URL) throws {
440
+ let data = try value.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)
441
+ guard data.count <= 1024 * 1024 else { throw NSError(domain: "VelarDesktop", code: 7, userInfo: [NSLocalizedDescriptionKey: "Desktop project bookmark exceeds 1 MiB"]) }
442
+ try data.write(to: bookmark, options: .atomic)
443
+ }
444
+
445
+ private static func validated(_ value: URL) throws -> URL {
446
+ let directory = value.resolvingSymlinksInPath().standardizedFileURL
447
+ guard directory.isFileURL, directory.path.hasPrefix("/"), !directory.path.contains("\0"), directory.path.utf8.count <= 4096 else {
448
+ throw NSError(domain: "VelarDesktop", code: 7, userInfo: [NSLocalizedDescriptionKey: "Selected Desktop project must have a bounded absolute file path"])
449
+ }
450
+ var isDirectory: ObjCBool = false
451
+ guard FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory), isDirectory.boolValue else {
452
+ throw NSError(domain: "VelarDesktop", code: 7, userInfo: [NSLocalizedDescriptionKey: "Selected Desktop project must identify an existing directory"])
453
+ }
454
+ return directory
455
+ }
456
+ }
457
+
458
+ private func nodeMajorVersion(_ executable: URL) -> Int? {
459
+ let process = Process()
460
+ let output = Pipe()
461
+ process.executableURL = executable
462
+ process.arguments = ["--version"]
463
+ process.standardInput = FileHandle.nullDevice
464
+ process.standardOutput = output
465
+ process.standardError = FileHandle.nullDevice
466
+ do { try process.run(); process.waitUntilExit() } catch { return nil }
467
+ guard process.terminationStatus == 0 else { return nil }
468
+ let data = try? output.fileHandleForReading.readToEnd()
469
+ guard let data, data.count <= 128,
470
+ let version = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
471
+ version.first == "v" else { return nil }
472
+ return Int(version.dropFirst().split(separator: ".").first ?? "")
473
+ }
474
+
475
+ private final class NodeCapabilityHost {
476
+ private struct PendingRequest {
477
+ let identity: BridgeIdentity
478
+ let requestBytes: Int
479
+ var retired: Bool
480
+ }
481
+
482
+ private struct ProcessOwner {
483
+ let pids: [pid_t]
484
+ let generation: String
485
+ }
486
+
487
+ private struct PendingProjectRoot {
488
+ let generation: String
489
+ let completion: (String?) -> Void
490
+ }
491
+
492
+ private let process = Process()
493
+ private let input = Pipe()
494
+ private let output = Pipe()
495
+ private let errors = Pipe()
496
+ private var buffer = Data()
497
+ private var pending: [Int: PendingRequest] = [:]
498
+ private var pendingRequestBytes = 0
499
+ private var activeIdentities = Set<BridgeIdentity>()
500
+ private var activeGeneration: String?
501
+ private var nextWorkerRequestID = 1
502
+ private var nextProjectRootCommandID = 1
503
+ private var processOwners: [Int: ProcessOwner] = [:]
504
+ private var pendingProjectRoots: [Int: PendingProjectRoot] = [:]
505
+ private var failure: String?
506
+ private var reaping = false
507
+ private let queue = DispatchQueue(label: "velar.desktop.node-worker")
508
+ weak var webView: WKWebView?
509
+
510
+ init(executable: String, worker: URL, config: URL, appData: URL, launchDirectory: String) throws {
511
+ process.executableURL = URL(fileURLWithPath: executable)
512
+ process.arguments = [worker.path, config.path, appData.path, launchDirectory]
513
+ process.standardInput = input
514
+ process.standardOutput = output
515
+ process.standardError = errors
516
+ process.terminationHandler = { [weak self] process in
517
+ self?.queue.async {
518
+ self?.fail("Desktop Node capability host exited unexpectedly with status \(process.terminationStatus)")
519
+ }
520
+ }
521
+ output.fileHandleForReading.readabilityHandler = { [weak self] handle in
522
+ let data = handle.availableData
523
+ if data.isEmpty { return }
524
+ self?.queue.async { self?.consume(data) }
525
+ }
526
+ errors.fileHandleForReading.readabilityHandler = { handle in
527
+ let data = handle.availableData
528
+ if !data.isEmpty { FileHandle.standardError.write(data) }
529
+ }
530
+ try process.run()
531
+ }
532
+
533
+ func send(_ request: BridgeRequest, body: [String: Any]) throws {
534
+ let data = try JSONSerialization.data(withJSONObject: body)
535
+ guard data.count <= 128 * 1024 * 1024 else { throw NSError(domain: "VelarDesktop", code: 413, userInfo: [NSLocalizedDescriptionKey: "Desktop request exceeds its transport bound"]) }
536
+ queue.async { [weak self] in
537
+ guard let self else { return }
538
+ let identity = BridgeIdentity(generation: request.generation, id: request.id)
539
+ if self.activeIdentities.contains(identity) {
540
+ self.complete(identity: identity, error: "Desktop request identity is already pending")
541
+ return
542
+ }
543
+ if let failure = self.failure {
544
+ self.complete(identity: identity, error: failure)
545
+ return
546
+ }
547
+ guard self.process.isRunning else {
548
+ self.fail("Desktop Node capability host is not running")
549
+ self.complete(identity: identity, error: self.failure ?? "Desktop Node capability host is not running")
550
+ return
551
+ }
552
+ if self.pending.count >= 1024 {
553
+ self.complete(identity: identity, error: "Too many pending Desktop capability requests")
554
+ return
555
+ }
556
+ if self.pendingRequestBytes + data.count > 128 * 1024 * 1024 {
557
+ self.complete(identity: identity, error: "Pending Desktop capability requests exceed their aggregate transport bound")
558
+ return
559
+ }
560
+ do {
561
+ try self.activate(generation: request.generation)
562
+ guard let workerID = self.allocateWorkerRequestID() else {
563
+ self.complete(identity: identity, error: "Desktop capability request identity space is exhausted")
564
+ return
565
+ }
566
+ var forwarded = body
567
+ forwarded.removeValue(forKey: "generation")
568
+ forwarded["id"] = workerID
569
+ forwarded["owner"] = request.generation
570
+ self.pending[workerID] = PendingRequest(identity: identity, requestBytes: data.count, retired: false)
571
+ self.pendingRequestBytes += data.count
572
+ self.activeIdentities.insert(identity)
573
+ try self.write(forwarded)
574
+ } catch {
575
+ self.fail("Desktop Node capability host write failed: \(error.localizedDescription)")
576
+ }
577
+ }
578
+ }
579
+
580
+ func retire(generation: String) {
581
+ queue.async { [weak self] in self?.retireGeneration(generation) }
582
+ }
583
+
584
+ func cancel(identity: BridgeIdentity) {
585
+ queue.async { [weak self] in
586
+ guard let self,
587
+ let (workerID, request) = self.pending.first(where: { $0.value.identity == identity }),
588
+ !request.retired else { return }
589
+ self.pending[workerID]?.retired = true
590
+ self.activeIdentities.remove(identity)
591
+ guard self.failure == nil, self.process.isRunning else { return }
592
+ do {
593
+ try self.write([
594
+ "protocolVersion": 1,
595
+ "hostCommand": "request-cancel",
596
+ "owner": identity.generation,
597
+ "requestID": workerID,
598
+ ])
599
+ } catch {
600
+ self.fail("Desktop Node capability host write failed: \(error.localizedDescription)")
601
+ }
602
+ }
603
+ }
604
+
605
+ func setProjectDirectory(_ path: String, generation: String, completion: @escaping (String?) -> Void) {
606
+ queue.async { [weak self] in
607
+ guard let self else { return }
608
+ guard self.failure == nil, self.process.isRunning else {
609
+ DispatchQueue.main.async { completion(self.failure ?? "Desktop Node capability host is not running") }
610
+ return
611
+ }
612
+ do {
613
+ try self.activate(generation: generation)
614
+ let commandID = self.nextProjectRootCommandID
615
+ self.nextProjectRootCommandID = self.nextProjectRootCommandID >= 9_007_199_254_740_991 ? 1 : self.nextProjectRootCommandID + 1
616
+ guard self.pendingProjectRoots[commandID] == nil else {
617
+ DispatchQueue.main.async { completion("Desktop project-root command identity space is exhausted") }
618
+ return
619
+ }
620
+ self.pendingProjectRoots[commandID] = PendingProjectRoot(generation: generation, completion: completion)
621
+ do {
622
+ try self.write([
623
+ "protocolVersion": 1,
624
+ "hostCommand": "project-root-set",
625
+ "owner": generation,
626
+ "commandID": commandID,
627
+ "path": path,
628
+ ])
629
+ } catch {
630
+ self.pendingProjectRoots.removeValue(forKey: commandID)
631
+ throw error
632
+ }
633
+ } catch {
634
+ self.fail("Desktop Node capability host write failed: \(error.localizedDescription)")
635
+ DispatchQueue.main.async { completion(self.failure ?? error.localizedDescription) }
636
+ }
637
+ }
638
+ }
639
+
640
+ func stop() {
641
+ queue.async { [weak self] in self?.fail("Desktop Node capability host stopped") }
642
+ }
643
+
644
+ private func consume(_ data: Data) {
645
+ buffer.append(data)
646
+ while let newline = buffer.firstIndex(of: 0x0A) {
647
+ let line = buffer.prefix(upTo: newline)
648
+ buffer.removeSubrange(...newline)
649
+ guard line.count <= 65 * 1024 * 1024,
650
+ let value = try? JSONSerialization.jsonObject(with: line),
651
+ let object = value as? [String: Any] else {
652
+ fail("Desktop Node capability host returned an invalid response")
653
+ return
654
+ }
655
+ if let event = object["hostEvent"] as? String {
656
+ if event == "project-root-settled" { handleProjectRootSettled(object) }
657
+ else { handle(event: event, object: object) }
658
+ if failure != nil { return }
659
+ continue
660
+ }
661
+ guard let id = object["id"] as? Int, id > 0,
662
+ let request = pending.removeValue(forKey: id) else {
663
+ fail("Desktop Node capability host returned an unknown response")
664
+ return
665
+ }
666
+ pendingRequestBytes -= request.requestBytes
667
+ activeIdentities.remove(request.identity)
668
+ if request.retired { continue }
669
+ var response = object
670
+ response["id"] = request.identity.id
671
+ guard let encoded = try? JSONSerialization.data(withJSONObject: response),
672
+ encoded.count <= 65 * 1024 * 1024 else {
673
+ fail("Desktop Node capability host returned an invalid response")
674
+ return
675
+ }
676
+ DispatchQueue.main.async { [weak self] in
677
+ deliverBridgeResponse(encoded, generation: request.identity.generation, to: self?.webView)
678
+ }
679
+ }
680
+ if buffer.count > 65 * 1024 * 1024 { fail("Desktop Node capability host response exceeded its transport bound") }
681
+ }
682
+
683
+ private func handle(event: String, object: [String: Any]) {
684
+ guard object["protocolVersion"] as? Int == 1,
685
+ let handle = object["handle"] as? Int, handle > 0,
686
+ let generation = validatedBridgeGeneration(object["owner"]) else {
687
+ fail("Desktop Node capability host returned an invalid lifecycle event")
688
+ return
689
+ }
690
+ switch event {
691
+ case "process-owned":
692
+ fallthrough
693
+ case "language-server-owned":
694
+ guard let pid = object["pid"] as? Int, pid > 0, pid <= Int(Int32.max),
695
+ processOwners[handle] == nil,
696
+ generation == activeGeneration || pending.values.contains(where: { $0.identity.generation == generation }) else {
697
+ fail("Desktop Node capability host returned an invalid process owner")
698
+ return
699
+ }
700
+ processOwners[handle] = ProcessOwner(pids: [pid_t(pid)], generation: generation)
701
+ case "terminal-owned":
702
+ guard let values = object["pids"] as? [Int], values.count == 2,
703
+ Set(values).count == values.count,
704
+ values.allSatisfy({ $0 > 0 && $0 <= Int(Int32.max) }),
705
+ processOwners[handle] == nil,
706
+ generation == activeGeneration || pending.values.contains(where: { $0.identity.generation == generation }) else {
707
+ fail("Desktop Node capability host returned an invalid terminal owner")
708
+ return
709
+ }
710
+ processOwners[handle] = ProcessOwner(pids: values.map(pid_t.init), generation: generation)
711
+ case "process-settled":
712
+ fallthrough
713
+ case "language-server-settled":
714
+ fallthrough
715
+ case "terminal-settled":
716
+ guard let owner = processOwners[handle], owner.generation == generation else {
717
+ fail("Desktop Node capability host settled an unknown process owner")
718
+ return
719
+ }
720
+ processOwners.removeValue(forKey: handle)
721
+ default:
722
+ fail("Desktop Node capability host returned an unknown lifecycle event")
723
+ }
724
+ }
725
+
726
+ private func handleProjectRootSettled(_ object: [String: Any]) {
727
+ guard object["protocolVersion"] as? Int == 1,
728
+ let commandID = object["commandID"] as? Int, commandID > 0,
729
+ let generation = validatedBridgeGeneration(object["owner"]),
730
+ let pending = pendingProjectRoots.removeValue(forKey: commandID),
731
+ pending.generation == generation,
732
+ let ok = object["ok"] as? Bool else {
733
+ fail("Desktop Node capability host returned an invalid project-root result")
734
+ return
735
+ }
736
+ let error: String?
737
+ if ok {
738
+ guard object.keys.allSatisfy({ ["protocolVersion", "hostEvent", "owner", "commandID", "ok"].contains($0) }) else {
739
+ fail("Desktop Node capability host returned an invalid project-root result")
740
+ return
741
+ }
742
+ error = nil
743
+ } else {
744
+ guard let message = object["error"] as? String, !message.isEmpty, message.utf8.count <= 65536 else {
745
+ fail("Desktop Node capability host returned an invalid project-root failure")
746
+ return
747
+ }
748
+ error = message
749
+ }
750
+ DispatchQueue.main.async { pending.completion(error) }
751
+ }
752
+
753
+ private func fail(_ message: String) {
754
+ guard failure == nil else { return }
755
+ failure = message
756
+ FileHandle.standardError.write(Data(("Velar Desktop capability host: \(message)\n").utf8))
757
+ output.fileHandleForReading.readabilityHandler = nil
758
+ errors.fileHandleForReading.readabilityHandler = nil
759
+ try? input.fileHandleForWriting.close()
760
+ if process.isRunning { process.terminate() }
761
+ let requests = Array(pending.values)
762
+ let projectRoots = Array(pendingProjectRoots.values)
763
+ pending.removeAll(keepingCapacity: false)
764
+ pendingProjectRoots.removeAll(keepingCapacity: false)
765
+ pendingRequestBytes = 0
766
+ activeIdentities.removeAll(keepingCapacity: false)
767
+ activeGeneration = nil
768
+ for request in requests where !request.retired { complete(identity: request.identity, error: message) }
769
+ for projectRoot in projectRoots { DispatchQueue.main.async { projectRoot.completion(message) } }
770
+ reapProcessOwners()
771
+ }
772
+
773
+ private func complete(identity: BridgeIdentity, error: String) {
774
+ guard let data = try? JSONSerialization.data(withJSONObject: ["id": identity.id, "ok": false, "error": error]) else { return }
775
+ DispatchQueue.main.async { [weak self] in
776
+ deliverBridgeResponse(data, generation: identity.generation, to: self?.webView)
777
+ }
778
+ }
779
+
780
+ private func allocateWorkerRequestID() -> Int? {
781
+ for _ in 0...1024 {
782
+ let candidate = nextWorkerRequestID
783
+ nextWorkerRequestID = nextWorkerRequestID >= 9_007_199_254_740_991 ? 1 : nextWorkerRequestID + 1
784
+ if pending[candidate] == nil { return candidate }
785
+ }
786
+ return nil
787
+ }
788
+
789
+ private func write(_ object: [String: Any]) throws {
790
+ var data = try JSONSerialization.data(withJSONObject: object)
791
+ guard data.count <= 128 * 1024 * 1024 else {
792
+ throw NSError(domain: "VelarDesktop", code: 413, userInfo: [NSLocalizedDescriptionKey: "Desktop request exceeds its transport bound"])
793
+ }
794
+ data.append(0x0A)
795
+ try input.fileHandleForWriting.write(contentsOf: data)
796
+ }
797
+
798
+ private func activate(generation: String) throws {
799
+ if activeGeneration == generation { return }
800
+ if let previous = activeGeneration { retireGeneration(previous) }
801
+ guard failure == nil else { throw NSError(domain: "VelarDesktop", code: 500, userInfo: [NSLocalizedDescriptionKey: failure!]) }
802
+ try write(["protocolVersion": 1, "hostCommand": "owner-activate", "owner": generation])
803
+ activeGeneration = generation
804
+ }
805
+
806
+ private func retireGeneration(_ generation: String) {
807
+ for (id, request) in pending where request.identity.generation == generation {
808
+ pending[id]?.retired = true
809
+ activeIdentities.remove(request.identity)
810
+ }
811
+ if activeGeneration == generation { activeGeneration = nil }
812
+ for owner in processOwners.values where owner.generation == generation {
813
+ for pid in owner.pids { _ = Darwin.kill(-pid, SIGKILL) }
814
+ }
815
+ guard failure == nil, process.isRunning else { return }
816
+ do {
817
+ try write(["protocolVersion": 1, "hostCommand": "owner-retire", "owner": generation])
818
+ } catch {
819
+ fail("Desktop Node capability host write failed: \(error.localizedDescription)")
820
+ }
821
+ }
822
+
823
+ private func reapProcessOwners() {
824
+ guard !reaping, !processOwners.isEmpty else { return }
825
+ reaping = true
826
+ let reap = { [weak self] in
827
+ guard let self else { return }
828
+ var settled: [Int] = []
829
+ for (handle, owner) in self.processOwners {
830
+ for pid in owner.pids { _ = Darwin.kill(-pid, SIGKILL) }
831
+ if owner.pids.allSatisfy({ Darwin.kill(-$0, 0) == -1 && errno == ESRCH }) { settled.append(handle) }
832
+ }
833
+ for handle in settled { self.processOwners.removeValue(forKey: handle) }
834
+ if self.processOwners.isEmpty {
835
+ self.reaping = false
836
+ } else {
837
+ self.queue.asyncAfter(deadline: .now() + .milliseconds(50), execute: self.reapClosure())
838
+ }
839
+ }
840
+ reap()
841
+ }
842
+
843
+ private func reapClosure() -> @Sendable () -> Void {
844
+ return { [weak self] in
845
+ guard let self else { return }
846
+ self.reaping = false
847
+ self.reapProcessOwners()
848
+ }
849
+ }
850
+ }
851
+
852
+ private final class AssetSchemeHandler: NSObject, WKURLSchemeHandler {
853
+ private let root: URL
854
+ private let rootPath: String
855
+
856
+ init(root: URL) {
857
+ self.root = root.standardizedFileURL
858
+ self.rootPath = self.root.path.hasSuffix("/") ? self.root.path : self.root.path + "/"
859
+ }
860
+
861
+ func webView(_ webView: WKWebView, start task: WKURLSchemeTask) {
862
+ guard let url = task.request.url, url.scheme == "velar-app", url.host == "app" else {
863
+ fail(task, 400, "Invalid Velar application URL")
864
+ return
865
+ }
866
+ let rawPath = url.path == "/" ? "index.html" : String(url.path.drop(while: { $0 == "/" }))
867
+ guard let decoded = rawPath.removingPercentEncoding,
868
+ !decoded.isEmpty, !decoded.contains("\0"),
869
+ !decoded.split(separator: "/", omittingEmptySubsequences: false).contains("..") else {
870
+ fail(task, 400, "Invalid Velar application path")
871
+ return
872
+ }
873
+ var target = root.appendingPathComponent(decoded).standardizedFileURL
874
+ var isDirectory: ObjCBool = false
875
+ if FileManager.default.fileExists(atPath: target.path, isDirectory: &isDirectory), isDirectory.boolValue {
876
+ target = target.appendingPathComponent("index.html").standardizedFileURL
877
+ }
878
+ guard target.path.hasPrefix(rootPath) else {
879
+ fail(task, 403, "Velar application path escaped its bundle")
880
+ return
881
+ }
882
+ do {
883
+ let data = try Data(contentsOf: target, options: [.mappedIfSafe])
884
+ guard let response = HTTPURLResponse(
885
+ url: url,
886
+ statusCode: 200,
887
+ httpVersion: "HTTP/1.1",
888
+ headerFields: [
889
+ "Content-Type": mimeType(target.pathExtension),
890
+ "Content-Length": String(data.count),
891
+ "Cache-Control": "no-store",
892
+ "X-Content-Type-Options": "nosniff",
893
+ ]
894
+ ) else { throw NSError(domain: "VelarDesktop", code: 500) }
895
+ task.didReceive(response)
896
+ task.didReceive(data)
897
+ task.didFinish()
898
+ } catch {
899
+ fail(task, 404, "Velar application resource was not found")
900
+ }
901
+ }
902
+
903
+ func webView(_ webView: WKWebView, stop task: WKURLSchemeTask) {}
904
+
905
+ private func fail(_ task: WKURLSchemeTask, _ status: Int, _ message: String) {
906
+ let error = NSError(domain: "VelarDesktop", code: status, userInfo: [NSLocalizedDescriptionKey: message])
907
+ task.didFailWithError(error)
908
+ }
909
+
910
+ private func mimeType(_ extensionName: String) -> String {
911
+ switch extensionName.lowercased() {
912
+ case "html": return "text/html; charset=utf-8"
913
+ case "js", "mjs": return "text/javascript; charset=utf-8"
914
+ case "css": return "text/css; charset=utf-8"
915
+ case "json": return "application/json; charset=utf-8"
916
+ case "svg": return "image/svg+xml"
917
+ case "png": return "image/png"
918
+ case "jpg", "jpeg": return "image/jpeg"
919
+ case "webp": return "image/webp"
920
+ case "gif": return "image/gif"
921
+ case "woff": return "font/woff"
922
+ case "woff2": return "font/woff2"
923
+ default: return "application/octet-stream"
924
+ }
925
+ }
926
+ }
927
+
928
+ private final class DesktopBridge: NSObject, WKScriptMessageHandler {
929
+ private struct IncomingChunks {
930
+ let total: Int
931
+ var nextIndex: Int
932
+ var data: Data
933
+ }
934
+ private let identifier: String
935
+ private let projectGrant: ProjectDirectoryGrant
936
+ private let projectFilesGranted: Bool
937
+ private let worker: NodeCapabilityHost
938
+ private var incomingChunks: [BridgeIdentity: IncomingChunks] = [:]
939
+ private var incomingBytes = 0
940
+ private var activeGeneration: String?
941
+ private var retiredGenerations = Set<String>()
942
+ private var retiredGenerationOrder: [String] = []
943
+ weak var webView: WKWebView?
944
+
945
+ init(identifier: String, projectGrant: ProjectDirectoryGrant, projectFilesGranted: Bool, worker: NodeCapabilityHost) {
946
+ self.identifier = identifier
947
+ self.projectGrant = projectGrant
948
+ self.projectFilesGranted = projectFilesGranted
949
+ self.worker = worker
950
+ }
951
+
952
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
953
+ guard message.frameInfo.isMainFrame,
954
+ let body = message.body as? [String: Any] else { return }
955
+ if body["transport"] as? String == "cancel" {
956
+ receiveCancel(body)
957
+ return
958
+ }
959
+ if body["transport"] as? String == "chunk" {
960
+ receiveChunk(body)
961
+ return
962
+ }
963
+ guard let request = BridgeRequest(body) else { return }
964
+ guard accept(generation: request.generation) else {
965
+ complete(identity: BridgeIdentity(generation: request.generation, id: request.id), value: nil, error: "Desktop document generation is no longer active")
966
+ return
967
+ }
968
+ handle(request, body: body)
969
+ }
970
+
971
+ func retireDocument() {
972
+ guard let generation = activeGeneration else { return }
973
+ activeGeneration = nil
974
+ if retiredGenerations.insert(generation).inserted {
975
+ retiredGenerationOrder.append(generation)
976
+ if retiredGenerationOrder.count > 1024 {
977
+ retiredGenerations.remove(retiredGenerationOrder.removeFirst())
978
+ }
979
+ }
980
+ let discarded = incomingChunks.filter { $0.key.generation == generation }
981
+ for (identity, _) in discarded { discardIncoming(identity: identity) }
982
+ worker.retire(generation: generation)
983
+ }
984
+
985
+ private func receiveCancel(_ body: [String: Any]) {
986
+ guard let cancellation = BridgeTransportCancel(body),
987
+ activeGeneration == cancellation.identity.generation else { return }
988
+ discardIncoming(identity: cancellation.identity)
989
+ worker.cancel(identity: cancellation.identity)
990
+ }
991
+
992
+ private func discardIncoming(identity: BridgeIdentity) {
993
+ guard let state = incomingChunks.removeValue(forKey: identity) else { return }
994
+ incomingBytes -= state.data.count
995
+ }
996
+
997
+ private func receiveChunk(_ body: [String: Any]) {
998
+ guard let chunk = BridgeTransportChunk(body) else { return }
999
+ let identity = BridgeIdentity(generation: chunk.generation, id: chunk.id)
1000
+ guard accept(generation: chunk.generation), incomingChunks.count < 16 || incomingChunks[identity] != nil else { return }
1001
+ var state = incomingChunks[identity] ?? IncomingChunks(total: chunk.total, nextIndex: 0, data: Data())
1002
+ guard state.total == chunk.total, state.nextIndex == chunk.index,
1003
+ incomingBytes + chunk.data.count <= 128 * 1024 * 1024 else {
1004
+ discardIncoming(identity: identity)
1005
+ complete(identity: identity, value: nil, error: "Invalid Desktop request chunk sequence")
1006
+ return
1007
+ }
1008
+ state.data.append(chunk.data)
1009
+ state.nextIndex += 1
1010
+ incomingBytes += chunk.data.count
1011
+ if state.nextIndex < state.total {
1012
+ incomingChunks[identity] = state
1013
+ return
1014
+ }
1015
+ incomingChunks.removeValue(forKey: identity)
1016
+ incomingBytes -= state.data.count
1017
+ guard let value = try? JSONSerialization.jsonObject(with: state.data),
1018
+ let decoded = value as? [String: Any],
1019
+ let request = BridgeRequest(decoded), request.id == chunk.id,
1020
+ request.generation == chunk.generation else {
1021
+ complete(identity: identity, value: nil, error: "Invalid Desktop request transport")
1022
+ return
1023
+ }
1024
+ handle(request, body: decoded)
1025
+ }
1026
+
1027
+ private func handle(_ request: BridgeRequest, body: [String: Any]) {
1028
+ do {
1029
+ if request.capability != "desktop" {
1030
+ try worker.send(request, body: body)
1031
+ return
1032
+ }
1033
+ let value: Any
1034
+ guard request.arguments.isEmpty else {
1035
+ throw NSError(domain: "VelarDesktop", code: 400, userInfo: [NSLocalizedDescriptionKey: "Desktop path operations do not accept arguments"])
1036
+ }
1037
+ switch request.operation {
1038
+ case "homeDirectory":
1039
+ value = FileManager.default.homeDirectoryForCurrentUser.path
1040
+ case "appDataDirectory":
1041
+ let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
1042
+ let directory = base.appendingPathComponent(identifier, isDirectory: true).appendingPathComponent("data", isDirectory: true)
1043
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
1044
+ value = directory.path
1045
+ case "projectDirectory":
1046
+ value = projectGrant.directory
1047
+ case "selectedProjectDirectory":
1048
+ value = projectGrant.selection ?? NSNull()
1049
+ case "selectProjectDirectory":
1050
+ guard projectFilesGranted else {
1051
+ throw NSError(domain: "VelarDesktop", code: 403, userInfo: [NSLocalizedDescriptionKey: "Desktop project selection requires the 'project' file grant"])
1052
+ }
1053
+ guard let selected = try projectGrant.select() else {
1054
+ complete(identity: BridgeIdentity(generation: request.generation, id: request.id), value: NSNull(), error: nil)
1055
+ return
1056
+ }
1057
+ worker.setProjectDirectory(selected, generation: request.generation) { [weak self] error in
1058
+ self?.complete(identity: BridgeIdentity(generation: request.generation, id: request.id), value: selected, error: error)
1059
+ }
1060
+ return
1061
+ default:
1062
+ throw NSError(domain: "VelarDesktop", code: 404, userInfo: [NSLocalizedDescriptionKey: "Unknown Desktop operation '\(request.operation)'"])
1063
+ }
1064
+ complete(identity: BridgeIdentity(generation: request.generation, id: request.id), value: value, error: nil)
1065
+ } catch {
1066
+ complete(identity: BridgeIdentity(generation: request.generation, id: request.id), value: nil, error: error.localizedDescription)
1067
+ }
1068
+ }
1069
+
1070
+ private func accept(generation: String) -> Bool {
1071
+ if retiredGenerations.contains(generation) { return false }
1072
+ if let activeGeneration { return activeGeneration == generation }
1073
+ activeGeneration = generation
1074
+ return true
1075
+ }
1076
+
1077
+ private func complete(identity: BridgeIdentity, value: Any?, error: String?) {
1078
+ var payload: [String: Any] = ["id": identity.id, "ok": error == nil]
1079
+ if let value { payload["value"] = value }
1080
+ if let error { payload["error"] = error }
1081
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
1082
+ data.count <= 65 * 1024 * 1024 else { return }
1083
+ deliverBridgeResponse(data, generation: identity.generation, to: webView)
1084
+ }
1085
+ }
1086
+
1087
+ private final class NavigationPolicy: NSObject, WKNavigationDelegate {
1088
+ private weak var bridge: DesktopBridge?
1089
+
1090
+ init(bridge: DesktopBridge) { self.bridge = bridge }
1091
+
1092
+ func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
1093
+ bridge?.retireDocument()
1094
+ }
1095
+
1096
+ func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
1097
+ guard let url = action.request.url else { decisionHandler(.cancel); return }
1098
+ if url.scheme == "velar-app" && url.host == "app" {
1099
+ decisionHandler(.allow)
1100
+ } else if url.scheme == "https" {
1101
+ NSWorkspace.shared.open(url)
1102
+ decisionHandler(.cancel)
1103
+ } else {
1104
+ decisionHandler(.cancel)
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
1110
+ private let headless: Bool
1111
+ private var window: NSWindow?
1112
+ private var schemeHandler: AssetSchemeHandler?
1113
+ private var bridge: DesktopBridge?
1114
+ private var navigationPolicy: NavigationPolicy?
1115
+ private var nodeHost: NodeCapabilityHost?
1116
+ private var projectGrant: ProjectDirectoryGrant?
1117
+
1118
+ init(headless: Bool) {
1119
+ self.headless = headless
1120
+ }
1121
+
1122
+ func applicationDidFinishLaunching(_ notification: Notification) {
1123
+ do {
1124
+ guard let resources = Bundle.main.resourceURL else { throw NSError(domain: "VelarDesktop", code: 1) }
1125
+ let configData = try Data(contentsOf: resources.appendingPathComponent("desktop.json"))
1126
+ let host = try JSONDecoder().decode(HostConfiguration.self, from: configData)
1127
+ guard host.protocolVersion == 1 else { throw NSError(domain: "VelarDesktop", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unsupported Desktop host protocol"])}
1128
+ let schemeHandler = AssetSchemeHandler(root: resources.appendingPathComponent("renderer", isDirectory: true))
1129
+ let appDataBase = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
1130
+ let appData = appDataBase.appendingPathComponent(host.identifier, isDirectory: true)
1131
+ try FileManager.default.createDirectory(at: appData, withIntermediateDirectories: true)
1132
+ let dataDirectory = appData.appendingPathComponent("data", isDirectory: true)
1133
+ try FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true)
1134
+ let defaultProject = appData.appendingPathComponent("project", isDirectory: true)
1135
+ try FileManager.default.createDirectory(at: defaultProject, withIntermediateDirectories: true)
1136
+ let projectFilesGranted = host.permissions.files.contains("project")
1137
+ let projectGrant = try ProjectDirectoryGrant(defaultDirectory: defaultProject, appData: appData, projectFilesGranted: projectFilesGranted)
1138
+ let launchDirectory = projectGrant.directory
1139
+ let nodeRuntime = try resolveNodeRuntime(host)
1140
+ let nodeHost = try NodeCapabilityHost(
1141
+ executable: nodeRuntime.path,
1142
+ worker: resources.appendingPathComponent("host/worker.js"),
1143
+ config: resources.appendingPathComponent("desktop.json"),
1144
+ appData: appData,
1145
+ launchDirectory: launchDirectory
1146
+ )
1147
+ let bridge = DesktopBridge(
1148
+ identifier: host.identifier,
1149
+ projectGrant: projectGrant,
1150
+ projectFilesGranted: projectFilesGranted,
1151
+ worker: nodeHost
1152
+ )
1153
+ let navigationPolicy = NavigationPolicy(bridge: bridge)
1154
+ let webConfiguration = WKWebViewConfiguration()
1155
+ webConfiguration.setURLSchemeHandler(schemeHandler, forURLScheme: "velar-app")
1156
+ let projectDirectoryData = try JSONSerialization.data(withJSONObject: launchDirectory, options: [.fragmentsAllowed])
1157
+ let projectDirectoryJSON = String(data: projectDirectoryData, encoding: .utf8)!
1158
+ var environment: [String: String] = [:]
1159
+ var environmentBytes = 0
1160
+ for name in host.permissions.environment {
1161
+ guard let value = ProcessInfo.processInfo.environment[name] else { continue }
1162
+ let valueBytes = value.utf8.count
1163
+ let entryBytes = name.utf8.count + valueBytes
1164
+ guard valueBytes <= 64 * 1024, environmentBytes + entryBytes <= 1024 * 1024 else {
1165
+ throw NSError(domain: "VelarDesktop", code: 4, userInfo: [NSLocalizedDescriptionKey: "Granted Desktop environment snapshot exceeds its size boundary"])
1166
+ }
1167
+ environment[name] = value
1168
+ environmentBytes += entryBytes
1169
+ }
1170
+ let environmentData = try JSONSerialization.data(withJSONObject: environment)
1171
+ let environmentJSON = String(data: environmentData, encoding: .utf8)!
1172
+ let injectedBridge = bridgeScript
1173
+ .replacingOccurrences(of: "__VELAR_PROJECT_DIRECTORY__", with: projectDirectoryJSON)
1174
+ .replacingOccurrences(of: "__VELAR_ENVIRONMENT__", with: environmentJSON)
1175
+ webConfiguration.userContentController.addUserScript(WKUserScript(source: injectedBridge, injectionTime: .atDocumentStart, forMainFrameOnly: true))
1176
+ webConfiguration.userContentController.add(bridge, name: "velarDesktop")
1177
+ let webView = WKWebView(frame: .zero, configuration: webConfiguration)
1178
+ webView.navigationDelegate = navigationPolicy
1179
+ bridge.webView = webView
1180
+ nodeHost.webView = webView
1181
+
1182
+ let window = NSWindow(
1183
+ contentRect: NSRect(x: 0, y: 0, width: host.window.width, height: host.window.height),
1184
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
1185
+ backing: .buffered,
1186
+ defer: false
1187
+ )
1188
+ window.title = host.window.title
1189
+ window.minSize = NSSize(width: host.window.minWidth, height: host.window.minHeight)
1190
+ window.contentView = webView
1191
+ window.center()
1192
+ if !headless {
1193
+ window.makeKeyAndOrderFront(nil)
1194
+ NSApp.activate(ignoringOtherApps: true)
1195
+ }
1196
+ webView.load(URLRequest(url: URL(string: "velar-app://app/index.html")!))
1197
+ self.window = window
1198
+ self.schemeHandler = schemeHandler
1199
+ self.bridge = bridge
1200
+ self.navigationPolicy = navigationPolicy
1201
+ self.nodeHost = nodeHost
1202
+ self.projectGrant = projectGrant
1203
+ } catch {
1204
+ let alert = NSAlert(error: error)
1205
+ alert.messageText = "VelarScript Desktop could not start"
1206
+ alert.runModal()
1207
+ NSApp.terminate(nil)
1208
+ }
1209
+ }
1210
+
1211
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
1212
+ func applicationWillTerminate(_ notification: Notification) {
1213
+ projectGrant?.release()
1214
+ nodeHost?.stop()
1215
+ }
1216
+ }
1217
+
1218
+ @main
1219
+ private enum VelarDesktopHost {
1220
+ static func main() {
1221
+ if CommandLine.arguments.dropFirst() == ["--smoke"] {
1222
+ do {
1223
+ guard let resources = Bundle.main.resourceURL else { throw NSError(domain: "VelarDesktop", code: 1) }
1224
+ let configData = try Data(contentsOf: resources.appendingPathComponent("desktop.json"))
1225
+ let host = try JSONDecoder().decode(HostConfiguration.self, from: configData)
1226
+ _ = try resolveNodeRuntime(host)
1227
+ _ = try resolveProjectDirectory(resources)
1228
+ guard host.protocolVersion == 1,
1229
+ FileManager.default.fileExists(atPath: resources.appendingPathComponent("renderer/index.html").path),
1230
+ FileManager.default.fileExists(atPath: resources.appendingPathComponent("host/worker.js").path) else {
1231
+ throw NSError(domain: "VelarDesktop", code: 2, userInfo: [NSLocalizedDescriptionKey: "Desktop bundle is incomplete"])
1232
+ }
1233
+ guard let request = BridgeRequest([
1234
+ "protocolVersion": 1,
1235
+ "generation": "00000000000000000000000000000001",
1236
+ "id": 1,
1237
+ "capability": "fs",
1238
+ "operation": "list",
1239
+ "args": ["."],
1240
+ ]), request.arguments.count == 1 else {
1241
+ throw NSError(domain: "VelarDesktop", code: 3, userInfo: [NSLocalizedDescriptionKey: "Desktop bridge rejected a bounded request with arguments"])
1242
+ }
1243
+ print("{\"kind\":\"velar-desktop-smoke\",\"protocolVersion\":1,\"identifier\":\"\(host.identifier)\"}")
1244
+ return
1245
+ } catch {
1246
+ FileHandle.standardError.write(Data("VelarScript Desktop smoke failed: \(error.localizedDescription)\n".utf8))
1247
+ exit(1)
1248
+ }
1249
+ }
1250
+ let headlessSmoke = CommandLine.arguments.dropFirst() == ["--headless-smoke"]
1251
+ let application = NSApplication.shared
1252
+ let delegate = ApplicationDelegate(headless: headlessSmoke)
1253
+ application.setActivationPolicy(headlessSmoke ? .prohibited : .regular)
1254
+ application.delegate = delegate
1255
+ application.run()
1256
+ _ = delegate
1257
+ }
1258
+ }