@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.
- package/LICENSE +201 -0
- package/README.md +247 -0
- package/dist/build.d.ts +53 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +351 -0
- package/dist/build.js.map +1 -0
- package/dist/compiler.d.ts +4 -0
- package/dist/compiler.d.ts.map +1 -0
- package/dist/compiler.js +1967 -0
- package/dist/compiler.js.map +1 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +140 -0
- package/dist/config.js.map +1 -0
- package/dist/host.d.ts +3 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +71 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/package-host.d.ts +3 -0
- package/dist/package-host.d.ts.map +1 -0
- package/dist/package-host.js +20 -0
- package/dist/package-host.js.map +1 -0
- package/dist/test-runtime.d.ts +16 -0
- package/dist/test-runtime.d.ts.map +1 -0
- package/dist/test-runtime.js +475 -0
- package/dist/test-runtime.js.map +1 -0
- package/dist/worker.js +546 -0
- package/native/macos/VelarDesktopHost.swift +1258 -0
- package/native/macos/VelarScript.icns +0 -0
- package/native/macos/VelarTerminalHost.swift +203 -0
- package/native/node/project-transactions.js +157 -0
- package/native/node/worker.js +2220 -0
- package/package.json +63 -0
|
@@ -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
|
+
}
|