craft-native 0.0.436 → 0.0.443
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.md +30 -0
- package/README.md +35 -0
- package/dist/android/src/index.d.ts +44 -0
- package/dist/android/src/index.js +82 -3
- package/dist/android/templates/AndroidManifest.xml.template +2 -1
- package/dist/android/templates/CraftHealthServices.kt.template +2 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +42 -1
- package/dist/android/templates/CraftNativeMotion.kt.template +269 -0
- package/dist/android/templates/CraftNativeServices.kt.template +6 -1
- package/dist/android/templates/CraftPassiveSteps.kt.template +159 -0
- package/dist/android/templates/CraftRecordingPressure.kt.template +156 -0
- package/dist/android/templates/LocationRecordingService.kt.template +77 -9
- package/dist/android/templates/MainActivity.kt.template +3 -0
- package/dist/android/templates/MainActivityNative.kt.template +132 -1
- package/dist/cli.js +289 -108
- package/dist/components/index.d.ts +25 -0
- package/dist/index.cjs +207 -27
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +321 -140
- package/dist/installer-background.d.ts +2 -0
- package/dist/installer-layout.d.ts +1 -0
- package/dist/ios/src/index.d.ts +23 -1
- package/dist/ios/src/index.js +49 -9
- package/dist/ios/templates/CraftApp.swift +14 -0
- package/dist/ios/templates/CraftHealth.swift +2 -1
- package/dist/ios/templates/CraftHealthBackground.swift +308 -0
- package/dist/ios/templates/CraftNativeActions.swift +153 -0
- package/dist/ios/templates/CraftNativePedometer.swift +181 -0
- package/dist/ios/templates/CraftNativeScreen.swift +76 -0
- package/dist/macos/src/index.js +1 -1
- package/dist/macos/src/toolchain.d.ts +1 -1
- package/dist/macos/templates/CraftMacApplication.swift +124 -39
- package/dist/macos/templates/CraftMacRenderer.swift +111 -33
- package/dist/macos/templates/CraftMacRuntime.swift +270 -81
- package/dist/package.d.ts +40 -0
- package/dist/types.d.ts +26 -0
- package/package.json +1 -1
|
@@ -3,6 +3,34 @@ import ZigJS
|
|
|
3
3
|
import Foundation
|
|
4
4
|
import Security
|
|
5
5
|
|
|
6
|
+
/// `CRAFT_TRACE=1`: launch and render phases on stderr, as `[craft trace] <ms since the process
|
|
7
|
+
/// started> <phase>`, with a span's own duration after it. Off, every call is one boolean test.
|
|
8
|
+
enum CraftTrace {
|
|
9
|
+
static let enabled = ProcessInfo.processInfo.environment["CRAFT_TRACE"].map { !$0.isEmpty && $0 != "0" } ?? false
|
|
10
|
+
/// When the kernel started this process, so the first mark already counts dyld and Swift start-up.
|
|
11
|
+
private static let processStart: Double = {
|
|
12
|
+
var info = kinfo_proc(), size = MemoryLayout<kinfo_proc>.stride
|
|
13
|
+
var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
|
|
14
|
+
guard sysctl(&name, 4, &info, &size, nil, 0) == 0 else { return Date().timeIntervalSince1970 }
|
|
15
|
+
return Double(info.kp_proc.p_starttime.tv_sec) + Double(info.kp_proc.p_starttime.tv_usec) / 1_000_000
|
|
16
|
+
}()
|
|
17
|
+
private static func since() -> Double { (Date().timeIntervalSince1970 - processStart) * 1000 }
|
|
18
|
+
static func mark(_ phase: @autoclosure () -> String) {
|
|
19
|
+
guard enabled else { return }
|
|
20
|
+
fputs(String(format: "[craft trace] %8.1f %@\n", since(), phase()), stderr)
|
|
21
|
+
}
|
|
22
|
+
/// Times `work`; spans under `atLeast` milliseconds are left out, so per-message spans stay readable.
|
|
23
|
+
static func span<T>(_ phase: @autoclosure () -> String, atLeast: Double = 0, _ work: () throws -> T) rethrows -> T {
|
|
24
|
+
guard enabled else { return try work() }
|
|
25
|
+
let start = since()
|
|
26
|
+
defer {
|
|
27
|
+
let end = since()
|
|
28
|
+
if end - start >= atLeast { fputs(String(format: "[craft trace] %8.1f %@ (%.1f ms)\n", end, phase(), end - start), stderr) }
|
|
29
|
+
}
|
|
30
|
+
return try work()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
6
34
|
/// The macOS Keychain behind `craft.secureStorage`: one generic password per
|
|
7
35
|
/// key, under a service named for the app. The data-protection keychain is
|
|
8
36
|
/// used when the app is signed for it (a Mac App Store build, which carries
|
|
@@ -58,7 +86,43 @@ enum CraftMacKeychain {
|
|
|
58
86
|
}
|
|
59
87
|
}
|
|
60
88
|
|
|
61
|
-
///
|
|
89
|
+
/// The thread a window's JavaScript runs on, with a run loop for its timers and messages. A zig-js
|
|
90
|
+
/// context belongs to the thread that made it, so the context is made here and every call into it
|
|
91
|
+
/// runs here. AppKit keeps the main thread: a screen evaluates its bundle while the app is still
|
|
92
|
+
/// connecting to the window server, and a long render in JavaScript no longer stalls scrolling.
|
|
93
|
+
final class CraftMacScriptThread: Thread {
|
|
94
|
+
private var loop: CFRunLoop!
|
|
95
|
+
private let started = DispatchSemaphore(value: 0)
|
|
96
|
+
override init() {
|
|
97
|
+
super.init()
|
|
98
|
+
name = "craft.script"; qualityOfService = .userInteractive
|
|
99
|
+
// The main thread's size: a parser or a deep render recurses as far on this one.
|
|
100
|
+
stackSize = 16 << 20
|
|
101
|
+
}
|
|
102
|
+
override func main() {
|
|
103
|
+
loop = CFRunLoopGetCurrent()
|
|
104
|
+
// A run loop with nothing to wait on returns at once; this source keeps it running until `stop`.
|
|
105
|
+
var context = CFRunLoopSourceContext()
|
|
106
|
+
CFRunLoopAddSource(loop, CFRunLoopSourceCreate(nil, 0, &context), .defaultMode)
|
|
107
|
+
started.signal()
|
|
108
|
+
CFRunLoopRun()
|
|
109
|
+
}
|
|
110
|
+
/// Starts the thread and returns once its run loop can take work.
|
|
111
|
+
func begin() { start(); started.wait() }
|
|
112
|
+
/// Runs `work` on this thread, after the work already asked for.
|
|
113
|
+
func run(_ work: @escaping () -> Void) {
|
|
114
|
+
CFRunLoopPerformBlock(loop, CFRunLoopMode.defaultMode.rawValue) { autoreleasepool(invoking: work) }
|
|
115
|
+
CFRunLoopWakeUp(loop)
|
|
116
|
+
}
|
|
117
|
+
/// Runs `work` on this thread `seconds` from now, unless the returned timer is invalidated first.
|
|
118
|
+
func after(_ seconds: Double, _ work: @escaping () -> Void) -> CFRunLoopTimer {
|
|
119
|
+
let timer = CFRunLoopTimerCreateWithHandler(nil, CFAbsoluteTimeGetCurrent() + seconds, 0, 0, 0) { _ in autoreleasepool(invoking: work) }!
|
|
120
|
+
CFRunLoopAddTimer(loop, timer, .defaultMode)
|
|
121
|
+
return timer
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// One zig-js context per STX window, on its own thread. No DOM and no browser view.
|
|
62
126
|
final class CraftMacRuntime {
|
|
63
127
|
/// What this host offers a screen. `web` (tabs in a `<WebView>`) only in a build made with `webkit: true`.
|
|
64
128
|
static var capabilities: [String] {
|
|
@@ -68,49 +132,68 @@ final class CraftMacRuntime {
|
|
|
68
132
|
#endif
|
|
69
133
|
return list
|
|
70
134
|
}
|
|
71
|
-
/// zig-js's precise collector, not the default arena: an arena frees nothing until the
|
|
72
|
-
/// context goes, so a window open all day kept every object its screen ever made.
|
|
73
|
-
let context: ZigJSContext = {
|
|
74
|
-
let created = ZJSGlobalContextCreateGarbageCollected(true)!
|
|
75
|
-
defer { JSGlobalContextRelease(created) }
|
|
76
|
-
return ZigJSContext(jsGlobalContextRef: created)!
|
|
77
|
-
}()
|
|
78
135
|
let route: String
|
|
79
136
|
let network: CraftMacNetwork
|
|
80
|
-
|
|
81
|
-
|
|
137
|
+
private let thread = CraftMacScriptThread()
|
|
138
|
+
// Script thread only: the context and what the screen asked of it.
|
|
139
|
+
private var context: ZigJSContext?
|
|
140
|
+
private var timers: [Int: CFRunLoopTimer] = [:]
|
|
82
141
|
private var tasks: [String: URLSessionTask] = [:]
|
|
142
|
+
private var stopped = false
|
|
143
|
+
// Main thread only.
|
|
144
|
+
private(set) weak var scene: CraftMacScene?
|
|
145
|
+
/// What the screen drew or asked of AppKit before its window existed, done when it attaches.
|
|
146
|
+
private var held: [() -> Void] = []
|
|
147
|
+
/// The screen's first tree is in its views; `whenDrawn` work waits for it.
|
|
148
|
+
private(set) var hasDrawn = false
|
|
149
|
+
private var drawn: [() -> Void] = []
|
|
83
150
|
private var disposed = false
|
|
84
151
|
var errors: [String] = []
|
|
85
152
|
let document = CraftNativeMutationDocument()
|
|
86
153
|
|
|
87
|
-
|
|
154
|
+
/// Starts the screen's thread and makes its context there. `colorScheme` is the appearance the screen
|
|
155
|
+
/// starts with, read by the caller: AppKit's appearance can only be asked on the main thread.
|
|
156
|
+
init(route: String, params: [String: Any], network: CraftMacNetwork, colorScheme: String) {
|
|
88
157
|
self.route = route
|
|
89
158
|
self.network = network
|
|
159
|
+
thread.begin()
|
|
160
|
+
thread.run { self.setUp(params: params, colorScheme: colorScheme) }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private func setUp(params: [String: Any], colorScheme: String) {
|
|
164
|
+
// zig-js's precise collector, not the default arena: an arena frees nothing until the
|
|
165
|
+
// context goes, so a window open all day kept every object its screen ever made.
|
|
166
|
+
// Without the JIT: its optimizer turns a screen's object and call code into runtime calls that
|
|
167
|
+
// often exit back to the interpreter, which made the browser's first rows render ~15% slower.
|
|
168
|
+
let created = ZJSGlobalContextCreateGarbageCollected(false)!
|
|
169
|
+
defer { JSGlobalContextRelease(created) }
|
|
170
|
+
let context = ZigJSContext(jsGlobalContextRef: created)!
|
|
171
|
+
self.context = context
|
|
90
172
|
context.exceptionHandler = { [weak self] _, value in
|
|
91
173
|
let message = value?.objectForKeyedSubscript("message")?.toString() ?? "Native script failed"
|
|
92
174
|
let stack = value?.objectForKeyedSubscript("stack")?.toString() ?? ""
|
|
93
|
-
self?.
|
|
175
|
+
self?.report(message)
|
|
94
176
|
fputs("[craft native] \(message)\n\(stack)\n", stderr)
|
|
95
177
|
}
|
|
96
178
|
let post: @convention(block) (String) -> Void = { [weak self] text in self?.receive(text) }
|
|
97
179
|
let schedule: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
|
|
98
|
-
guard let self, !self.
|
|
99
|
-
|
|
100
|
-
guard let self, self.timers.removeValue(forKey: id) != nil, !self.
|
|
101
|
-
|
|
102
|
-
|
|
180
|
+
guard let self, !self.stopped else { return }
|
|
181
|
+
self.timers[id] = self.thread.after(max(0, min(delay.isFinite ? delay : 0, 2_147_483_647)) / 1000) { [weak self] in
|
|
182
|
+
guard let self, self.timers.removeValue(forKey: id) != nil, !self.stopped else { return }
|
|
183
|
+
CraftTrace.span("js timer", atLeast: 1) {
|
|
184
|
+
self.context?.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
|
|
185
|
+
self.context.map { ZJSContextRunMicrotasks($0) }
|
|
186
|
+
}
|
|
103
187
|
}
|
|
104
|
-
self.timers[id] = work
|
|
105
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + max(0, min(delay.isFinite ? delay : 0, 2_147_483_647)) / 1000, execute: work)
|
|
106
188
|
}
|
|
107
|
-
let cancel: @convention(block) (Int) -> Void = { [weak self] id in self?.timers.removeValue(forKey: id)
|
|
189
|
+
let cancel: @convention(block) (Int) -> Void = { [weak self] id in self?.timers.removeValue(forKey: id).map(CFRunLoopTimerInvalidate) }
|
|
108
190
|
let log: @convention(block) (String, String) -> Void = { [weak self] level, text in
|
|
109
|
-
if level == "error" { self?.
|
|
191
|
+
if level == "error" { self?.report(text) }
|
|
110
192
|
fputs("[stx \(level)] \(text)\n", stderr)
|
|
111
193
|
}
|
|
112
194
|
// The synchronous halves of craft.storage and craft.secureStorage
|
|
113
195
|
// (getSync/setSync), which the stx runtime keeps when the host has them.
|
|
196
|
+
// UserDefaults and the Keychain answer on any thread.
|
|
114
197
|
let storageGet: @convention(block) (String) -> String? = { key in
|
|
115
198
|
guard let value = UserDefaults.standard.object(forKey: "craft.native." + key) else { return nil }
|
|
116
199
|
if let text = value as? String { return text }
|
|
@@ -132,7 +215,7 @@ final class CraftMacRuntime {
|
|
|
132
215
|
"platform": "macos", "mutationProtocolVersion": 1, "capabilityProtocolVersion": 1,
|
|
133
216
|
"capabilityTimeoutMs": 30_000, "capabilities": Self.capabilities,
|
|
134
217
|
"sessionTokenKey": "auth.token",
|
|
135
|
-
"initialAppState": "active", "colorScheme":
|
|
218
|
+
"initialAppState": "active", "colorScheme": colorScheme,
|
|
136
219
|
"appURL": network.base.absoluteString, "appVersion": info["CFBundleShortVersionString"] as? String ?? "", "appBuild": info["CFBundleVersion"] as? String ?? ""
|
|
137
220
|
]
|
|
138
221
|
let bridgeJSON = String(data: try! JSONSerialization.data(withJSONObject: bridge), encoding: .utf8)!
|
|
@@ -173,9 +256,35 @@ final class CraftMacRuntime {
|
|
|
173
256
|
""")
|
|
174
257
|
}
|
|
175
258
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
259
|
+
/// Evaluates the screen's bundle on its thread, knowing the window's content size first: `md:`/`lg:`
|
|
260
|
+
/// classes and `matchMedia` are right on the first frame, before the window exists.
|
|
261
|
+
func load(_ bundle: String, viewport: CGSize) {
|
|
262
|
+
thread.run {
|
|
263
|
+
guard let context = self.context else { return }
|
|
264
|
+
context.evaluateScript("__stxNativeBridge.viewportWidth = \(Double(viewport.width)); __stxNativeBridge.viewportHeight = \(Double(viewport.height));")
|
|
265
|
+
self.startProfileIfAsked()
|
|
266
|
+
CraftTrace.span("bundle evaluated") { context.evaluateScript(bundle); ZJSContextRunMicrotasks(context) }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// Evaluates `script` in the screen's context, after the messages already sent to it.
|
|
271
|
+
func evaluate(_ script: String) { thread.run { _ = self.context?.evaluateScript(script) } }
|
|
272
|
+
|
|
273
|
+
/// A script error, kept for the test driver's snapshot, which reads it on the main thread.
|
|
274
|
+
private func report(_ message: String) { DispatchQueue.main.async { self.errors.append(message) } }
|
|
275
|
+
|
|
276
|
+
/// The screen's window exists: what the screen drew before it is drawn now, into its views.
|
|
277
|
+
func attach(_ scene: CraftMacScene) {
|
|
278
|
+
self.scene = scene
|
|
279
|
+
let waiting = held; held.removeAll()
|
|
280
|
+
waiting.forEach { $0() }
|
|
281
|
+
}
|
|
282
|
+
/// On the main thread: `work` once the screen's first tree is drawn (now, if it is).
|
|
283
|
+
func whenDrawn(_ work: @escaping () -> Void) { if hasDrawn { work() } else { drawn.append(work) } }
|
|
284
|
+
/// On the main thread: `work` now if the window exists, else when it attaches.
|
|
285
|
+
private func whenAttached(_ work: @escaping () -> Void) {
|
|
286
|
+
guard !disposed else { return }
|
|
287
|
+
if scene != nil { work() } else { held.append(work) }
|
|
179
288
|
}
|
|
180
289
|
|
|
181
290
|
/// `CRAFT_JS_PROFILE=<file>`: sample this screen's JavaScript from the moment its bundle loads,
|
|
@@ -184,14 +293,14 @@ final class CraftMacRuntime {
|
|
|
184
293
|
/// than Main writes beside it, its route in the name.
|
|
185
294
|
private func startProfileIfAsked() {
|
|
186
295
|
let env = ProcessInfo.processInfo.environment
|
|
187
|
-
guard let path = env["CRAFT_JS_PROFILE"], !path.isEmpty, let ref = context
|
|
296
|
+
guard let path = env["CRAFT_JS_PROFILE"], !path.isEmpty, let ref = context?.jsGlobalContextRef else { return }
|
|
188
297
|
guard ZJSContextStartCPUProfile(ref, UInt32(env["CRAFT_JS_PROFILE_INTERVAL_US"] ?? "") ?? 0) else { return }
|
|
189
298
|
let seconds = (Double(env["CRAFT_JS_PROFILE_MS"] ?? "") ?? 5000) / 1000
|
|
190
|
-
|
|
299
|
+
_ = thread.after(seconds) { [weak self] in self?.writeProfile(to: path) }
|
|
191
300
|
}
|
|
192
301
|
|
|
193
302
|
private func writeProfile(to path: String) {
|
|
194
|
-
guard !
|
|
303
|
+
guard !stopped, let ref = context?.jsGlobalContextRef, let json = ZJSContextStopCPUProfile(ref) else { return }
|
|
195
304
|
defer { JSStringRelease(json) }
|
|
196
305
|
let size = JSStringGetMaximumUTF8CStringSize(json)
|
|
197
306
|
var bytes = [CChar](repeating: 0, count: size)
|
|
@@ -205,27 +314,39 @@ final class CraftMacRuntime {
|
|
|
205
314
|
catch { fputs("[craft] could not write the JavaScript profile to \(target.path): \(error)\n", stderr) }
|
|
206
315
|
}
|
|
207
316
|
|
|
317
|
+
/// Unmounts the screen and stops its thread. Waits (a little) for the unmount, as a quit would.
|
|
208
318
|
func dispose() {
|
|
209
319
|
guard !disposed else { return }
|
|
320
|
+
disposed = true
|
|
210
321
|
#if CRAFT_WEBKIT
|
|
211
322
|
CraftMacWebTabs.shared.closeAll(owner: self)
|
|
212
323
|
#endif
|
|
213
|
-
context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
|
|
214
|
-
disposed = true
|
|
215
|
-
timers.values.forEach { $0.cancel() }; timers.removeAll()
|
|
216
|
-
tasks.values.forEach { $0.cancel() }; tasks.removeAll()
|
|
217
324
|
network.cancelStreams(owner: self)
|
|
325
|
+
let done = DispatchSemaphore(value: 0)
|
|
326
|
+
thread.run {
|
|
327
|
+
self.context?.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
|
|
328
|
+
self.stopped = true
|
|
329
|
+
self.timers.values.forEach(CFRunLoopTimerInvalidate); self.timers.removeAll()
|
|
330
|
+
self.tasks.values.forEach { $0.cancel() }; self.tasks.removeAll()
|
|
331
|
+
// Released on the thread that made it.
|
|
332
|
+
self.context = nil
|
|
333
|
+
done.signal()
|
|
334
|
+
CFRunLoopStop(CFRunLoopGetCurrent())
|
|
335
|
+
}
|
|
336
|
+
_ = done.wait(timeout: .now() + 2)
|
|
218
337
|
}
|
|
219
338
|
|
|
339
|
+
/// A message for the screen, from any thread: delivered on its thread, in the order sent.
|
|
220
340
|
func send(_ type: String, _ payload: [String: Any], correlation: String? = nil) {
|
|
221
|
-
guard !disposed else { return }
|
|
222
341
|
var message: [String: Any] = ["id": UUID().uuidString, "type": type, "timestamp": Date().timeIntervalSince1970 * 1000, "source": "native", "payload": payload]
|
|
223
342
|
if let correlation { message["correlationId"] = correlation }
|
|
224
343
|
guard let bytes = try? JSONSerialization.data(withJSONObject: message), let text = String(data: bytes, encoding: .utf8) else { return }
|
|
225
|
-
|
|
226
|
-
guard let self, !self.
|
|
227
|
-
|
|
228
|
-
|
|
344
|
+
thread.run { [weak self] in
|
|
345
|
+
guard let self, !self.stopped, let context = self.context else { return }
|
|
346
|
+
CraftTrace.span("js \(type)", atLeast: 1) {
|
|
347
|
+
context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [text])
|
|
348
|
+
ZJSContextRunMicrotasks(context)
|
|
349
|
+
}
|
|
229
350
|
}
|
|
230
351
|
}
|
|
231
352
|
|
|
@@ -234,30 +355,49 @@ final class CraftMacRuntime {
|
|
|
234
355
|
send("EVENT", ["handlerId": id, "nativeEvent": payload])
|
|
235
356
|
}
|
|
236
357
|
|
|
358
|
+
/// A message from the screen, on its thread. Network requests start here, so a screen's first fetch
|
|
359
|
+
/// leaves while AppKit is still launching; whatever draws or needs AppKit goes to the main thread.
|
|
237
360
|
private func receive(_ text: String) {
|
|
238
361
|
guard let data = text.data(using: .utf8), let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
362
|
+
let payload = message["payload"] as? [String: Any] ?? [:]
|
|
363
|
+
switch message["type"] as? String {
|
|
364
|
+
case "RENDER", "MUTATE": DispatchQueue.main.async { self.whenAttached { self.handle(message) } }
|
|
365
|
+
case "API_REQUEST":
|
|
366
|
+
guard let id = message["id"] as? String else { return }
|
|
367
|
+
guard payload["version"] as? Int == 1 else { fail(id, "UNSUPPORTED_VERSION", "Native capability version must be 1"); return }
|
|
368
|
+
let module = payload["module"] as? String ?? "", method = payload["method"] as? String ?? "", args = payload["args"] as? [Any] ?? []
|
|
369
|
+
switch module {
|
|
370
|
+
case "Network": fetch(id, method, args)
|
|
371
|
+
case "Storage", "SecureStorage": store(id, module, method, args)
|
|
372
|
+
default: DispatchQueue.main.async { self.whenAttached { self.perform(id, module, method, args) } }
|
|
373
|
+
}
|
|
374
|
+
case "API_CANCEL":
|
|
375
|
+
if let id = payload["requestId"] as? String { tasks.removeValue(forKey: id)?.cancel() }
|
|
376
|
+
default: break
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/// A tree or a batch, on the main thread.
|
|
381
|
+
private func handle(_ message: [String: Any]) {
|
|
239
382
|
let payload = message["payload"] as? [String: Any] ?? [:]
|
|
240
383
|
switch message["type"] as? String {
|
|
241
384
|
case "RENDER":
|
|
242
385
|
guard let tree = payload["document"] as? [String: Any] else { return }
|
|
243
|
-
|
|
244
|
-
|
|
386
|
+
CraftTrace.span(" native RENDER") {
|
|
387
|
+
document.replace(with: tree)
|
|
388
|
+
scene?.renderer.render(tree)
|
|
389
|
+
}
|
|
390
|
+
if !hasDrawn { hasDrawn = true; let waiting = drawn; drawn.removeAll(); waiting.forEach { $0() } }
|
|
245
391
|
case "MUTATE":
|
|
246
|
-
do {
|
|
392
|
+
do { try CraftTrace.span(" native MUTATE \((payload["mutations"] as? [Any])?.count ?? 0)", atLeast: 1) {
|
|
247
393
|
let result = try document.apply(payload)
|
|
248
394
|
// Drawn where it changed; the whole tree only when the batch reshaped it.
|
|
249
395
|
if result.requiresFullRender || scene?.renderer.update(result.affectedNodeIds, parentOf: document.parentId(of:), node: document.node) != true,
|
|
250
396
|
let tree = result.document { scene?.renderer.render(tree) }
|
|
251
397
|
send("MUTATION_ACK", ["version": 1, "batchId": result.batchId, "revision": result.revision])
|
|
252
|
-
} catch let error as CraftNativeMutationFailure {
|
|
398
|
+
} } catch let error as CraftNativeMutationFailure {
|
|
253
399
|
send("MUTATION_ERROR", ["version": 1, "batchId": payload["batchId"] ?? "", "code": error.code, "message": error.message])
|
|
254
400
|
} catch { errors.append(error.localizedDescription) }
|
|
255
|
-
case "API_REQUEST":
|
|
256
|
-
guard let id = message["id"] as? String else { return }
|
|
257
|
-
guard payload["version"] as? Int == 1 else { fail(id, "UNSUPPORTED_VERSION", "Native capability version must be 1"); return }
|
|
258
|
-
perform(id, payload["module"] as? String ?? "", payload["method"] as? String ?? "", payload["args"] as? [Any] ?? [])
|
|
259
|
-
case "API_CANCEL":
|
|
260
|
-
if let id = payload["requestId"] as? String { tasks.removeValue(forKey: id)?.cancel() }
|
|
261
401
|
default: break
|
|
262
402
|
}
|
|
263
403
|
}
|
|
@@ -265,41 +405,46 @@ final class CraftMacRuntime {
|
|
|
265
405
|
/// `craft.session.onChange`: the sign-in token moved, in any window.
|
|
266
406
|
private func sessionChanged(_ key: String, token: String?, previous: String?) {
|
|
267
407
|
guard key == "auth.token", token != previous else { return }
|
|
268
|
-
|
|
269
|
-
scene.
|
|
408
|
+
DispatchQueue.main.async {
|
|
409
|
+
for scene in CraftMacApplication.shared.scenes.values {
|
|
410
|
+
scene.runtime.send("SESSION_CHANGED", ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()])
|
|
411
|
+
}
|
|
270
412
|
}
|
|
271
413
|
}
|
|
272
414
|
|
|
415
|
+
/// An answer to the screen's request `id`, from any thread.
|
|
273
416
|
func answer(_ id: String, _ value: Any = NSNull()) {
|
|
274
|
-
tasks.removeValue(forKey: id)
|
|
417
|
+
thread.run { self.tasks.removeValue(forKey: id) }
|
|
275
418
|
send("API_RESPONSE", ["version": 1, "requestId": id, "data": value], correlation: id)
|
|
276
419
|
}
|
|
277
420
|
func fail(_ id: String, _ code: String, _ message: String) {
|
|
278
|
-
tasks.removeValue(forKey: id)
|
|
421
|
+
thread.run { self.tasks.removeValue(forKey: id) }
|
|
279
422
|
send("API_ERROR", ["version": 1, "requestId": id, "code": code, "message": message], correlation: id)
|
|
280
423
|
}
|
|
281
424
|
|
|
282
|
-
|
|
425
|
+
/// `Network` requests, on the script thread. Their answers come back on the network's own queue.
|
|
426
|
+
private func fetch(_ id: String, _ method: String, _ args: [Any]) {
|
|
283
427
|
let options = args.first as? [String: Any] ?? [:]
|
|
428
|
+
if method == "cancelStream" { network.cancelStream(args.first as? String ?? "", owner: self); answer(id); return }
|
|
429
|
+
guard let request = network.request(options) else { fail(id, "INVALID_ARGUMENT", "Network.fetch requires an HTTP URL"); return }
|
|
430
|
+
if method == "stream" {
|
|
431
|
+
network.stream(request, id: options["id"] as? String ?? id, owner: self, requestId: id)
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
guard method == "fetch" else { fail(id, "UNSUPPORTED_METHOD", method); return }
|
|
435
|
+
let task = network.session.dataTask(with: request) { [weak self] data, response, error in
|
|
436
|
+
guard let self else { return }
|
|
437
|
+
if let error { self.fail(id, "NETWORK_ERROR", error.localizedDescription); return }
|
|
438
|
+
guard let http = response as? HTTPURLResponse else { self.fail(id, "NETWORK_ERROR", "Invalid HTTP response"); return }
|
|
439
|
+
if (data?.count ?? 0) > 32 * 1024 * 1024 { self.fail(id, "RESPONSE_TOO_LARGE", "Response exceeds 32 MB"); return }
|
|
440
|
+
self.answer(id, CraftMacNetwork.response(http, original: request.url, body: String(decoding: data ?? Data(), as: UTF8.self)))
|
|
441
|
+
}
|
|
442
|
+
tasks[id] = task; network.resume(task)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/// `Storage` and `SecureStorage`, on the script thread: UserDefaults and the Keychain answer on any.
|
|
446
|
+
private func store(_ id: String, _ module: String, _ method: String, _ args: [Any]) {
|
|
284
447
|
switch module {
|
|
285
|
-
case "Network":
|
|
286
|
-
if method == "cancelStream" { network.cancelStream(args.first as? String ?? "", owner: self); answer(id); return }
|
|
287
|
-
guard let request = network.request(options) else { fail(id, "INVALID_ARGUMENT", "Network.fetch requires an HTTP URL"); return }
|
|
288
|
-
if method == "stream" {
|
|
289
|
-
network.stream(request, id: options["id"] as? String ?? id, owner: self, requestId: id)
|
|
290
|
-
return
|
|
291
|
-
}
|
|
292
|
-
guard method == "fetch" else { fail(id, "UNSUPPORTED_METHOD", method); return }
|
|
293
|
-
let task = network.session.dataTask(with: request) { [weak self] data, response, error in
|
|
294
|
-
DispatchQueue.main.async {
|
|
295
|
-
guard let self, !self.disposed else { return }
|
|
296
|
-
if let error { self.fail(id, "NETWORK_ERROR", error.localizedDescription); return }
|
|
297
|
-
guard let http = response as? HTTPURLResponse else { self.fail(id, "NETWORK_ERROR", "Invalid HTTP response"); return }
|
|
298
|
-
if (data?.count ?? 0) > 32 * 1024 * 1024 { self.fail(id, "RESPONSE_TOO_LARGE", "Response exceeds 32 MB"); return }
|
|
299
|
-
self.answer(id, CraftMacNetwork.response(http, original: request.url, body: String(decoding: data ?? Data(), as: UTF8.self)))
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
tasks[id] = task; task.resume()
|
|
303
448
|
case "SecureStorage":
|
|
304
449
|
if method == "clear" { CraftMacKeychain.remove(nil); answer(id, true); return }
|
|
305
450
|
guard let key = args.first as? String, !key.isEmpty else { fail(id, "INVALID_ARGUMENT", "Secure storage needs a non-empty key"); return }
|
|
@@ -326,6 +471,15 @@ final class CraftMacRuntime {
|
|
|
326
471
|
case "keys": answer(id, UserDefaults.standard.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) })
|
|
327
472
|
default: fail(id, "UNSUPPORTED_METHOD", method)
|
|
328
473
|
}
|
|
474
|
+
default: break
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/// Every other capability, on the main thread.
|
|
479
|
+
private func perform(_ id: String, _ module: String, _ method: String, _ args: [Any]) {
|
|
480
|
+
guard !disposed else { return }
|
|
481
|
+
let options = args.first as? [String: Any] ?? [:]
|
|
482
|
+
switch module {
|
|
329
483
|
case "Clipboard":
|
|
330
484
|
if method == "read" { answer(id, NSPasteboard.general.string(forType: .string) ?? "") }
|
|
331
485
|
else { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args.first as? String ?? "", forType: .string); answer(id) }
|
|
@@ -352,15 +506,49 @@ final class CraftMacRuntime {
|
|
|
352
506
|
/// A session shared by native windows, with an isolated, HttpOnly cookie store.
|
|
353
507
|
final class CraftMacNetwork: NSObject, URLSessionDataDelegate {
|
|
354
508
|
let base: URL
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
return
|
|
509
|
+
/// Where the session answers: a queue of its own rather than the main one, so a screen hears a
|
|
510
|
+
/// response while AppKit is busy (or still launching). Stream bookkeeping happens on it too.
|
|
511
|
+
private let queue: OperationQueue = {
|
|
512
|
+
let queue = OperationQueue(); queue.name = "craft.network"; queue.maxConcurrentOperationCount = 1; queue.qualityOfService = .userInitiated
|
|
513
|
+
return queue
|
|
360
514
|
}()
|
|
515
|
+
private(set) var session: URLSession!
|
|
361
516
|
private struct Stream { let id: String; weak var owner: CraftMacRuntime?; let requestId: String; var opened: Bool = false; var carry = Data() }
|
|
362
517
|
private var streams: [Int: Stream] = [:]
|
|
363
|
-
|
|
518
|
+
/// Requests made before the agent issued the session cookie, sent once it has (`authorize`).
|
|
519
|
+
private let gate = NSLock()
|
|
520
|
+
private var authorized = true
|
|
521
|
+
private var held: [URLSessionTask] = []
|
|
522
|
+
init(base: URL) {
|
|
523
|
+
self.base = base
|
|
524
|
+
super.init()
|
|
525
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
526
|
+
configuration.timeoutIntervalForRequest = 300
|
|
527
|
+
configuration.timeoutIntervalForResource = 7 * 24 * 60 * 60
|
|
528
|
+
session = URLSession(configuration: configuration, delegate: self, delegateQueue: queue)
|
|
529
|
+
}
|
|
530
|
+
/// Apps that ship their own agent (Attic) hand out a session cookie at `/native/session`. It is asked
|
|
531
|
+
/// for at once, while the window opens and the screen starts, and every request waits for it: the
|
|
532
|
+
/// agent answers nothing else without it. The cookie is HttpOnly and stays in this session, shared
|
|
533
|
+
/// by native windows; it never reaches JavaScript. `answered` gets nil, or why there is no session.
|
|
534
|
+
func authorize(_ answered: @escaping (String?) -> Void) {
|
|
535
|
+
gate.lock(); authorized = false; gate.unlock()
|
|
536
|
+
session.dataTask(with: URLRequest(url: base.appendingPathComponent("native/session"))) { [self] _, response, error in
|
|
537
|
+
guard error == nil, (response as? HTTPURLResponse)?.statusCode == 200 else {
|
|
538
|
+
// What waits stays unsent: the app shows why and quits.
|
|
539
|
+
answered(error?.localizedDescription ?? "The local agent did not issue a native session."); return
|
|
540
|
+
}
|
|
541
|
+
gate.lock(); authorized = true; let waiting = held; held.removeAll(); gate.unlock()
|
|
542
|
+
waiting.forEach { $0.resume() }
|
|
543
|
+
answered(nil)
|
|
544
|
+
}.resume()
|
|
545
|
+
}
|
|
546
|
+
/// Sends `task` now, or once the session is issued. Every request to the agent goes through here.
|
|
547
|
+
func resume(_ task: URLSessionTask) {
|
|
548
|
+
gate.lock()
|
|
549
|
+
if authorized { gate.unlock(); task.resume(); return }
|
|
550
|
+
held.append(task); gate.unlock()
|
|
551
|
+
}
|
|
364
552
|
func request(_ options: [String: Any]) -> URLRequest? {
|
|
365
553
|
guard let address = options["url"] as? String, let url = URL(string: address, relativeTo: base)?.absoluteURL,
|
|
366
554
|
["http", "https"].contains(url.scheme ?? ""), url.host != nil else { return nil }
|
|
@@ -378,14 +566,15 @@ final class CraftMacNetwork: NSObject, URLSessionDataDelegate {
|
|
|
378
566
|
}
|
|
379
567
|
func stream(_ request: URLRequest, id: String, owner: CraftMacRuntime, requestId: String) {
|
|
380
568
|
let task = session.dataTask(with: request)
|
|
381
|
-
streams[task.taskIdentifier] = Stream(id: id, owner: owner, requestId: requestId)
|
|
382
|
-
task.resume()
|
|
569
|
+
queue.addOperation { self.streams[task.taskIdentifier] = Stream(id: id, owner: owner, requestId: requestId); self.resume(task) }
|
|
383
570
|
}
|
|
384
571
|
func cancelStream(_ id: String, owner: CraftMacRuntime) {
|
|
385
|
-
|
|
386
|
-
|
|
572
|
+
queue.addOperation {
|
|
573
|
+
let identifiers = self.streams.filter { $0.value.id == id && $0.value.owner === owner }.map(\.key)
|
|
574
|
+
self.session.getAllTasks { tasks in self.queue.addOperation { for task in tasks where identifiers.contains(task.taskIdentifier) { self.streams.removeValue(forKey: task.taskIdentifier); task.cancel() } } }
|
|
575
|
+
}
|
|
387
576
|
}
|
|
388
|
-
func cancelStreams(owner: CraftMacRuntime) { for stream in streams.values where stream.owner === owner { cancelStream(stream.id, owner: owner) } }
|
|
577
|
+
func cancelStreams(owner: CraftMacRuntime) { queue.addOperation { for stream in self.streams.values where stream.owner === owner { self.cancelStream(stream.id, owner: owner) } } }
|
|
389
578
|
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
|
390
579
|
guard var stream = streams[dataTask.taskIdentifier] else { completionHandler(.allow); return }
|
|
391
580
|
guard let http = response as? HTTPURLResponse else { completionHandler(.cancel); stream.owner?.fail(stream.requestId, "NETWORK_ERROR", "Invalid stream response"); return }
|
package/dist/package.d.ts
CHANGED
|
@@ -265,6 +265,46 @@ export declare function dmgCreateArguments(opts: {
|
|
|
265
265
|
volumeName: string;
|
|
266
266
|
}, contentBytes: number): string[];
|
|
267
267
|
export declare function shouldRetryHdiutil(error: string, attempt: number, maxAttempts?: number): boolean;
|
|
268
|
+
/** What a packaging tool reports: its output, or the diagnostic it failed with. */
|
|
269
|
+
export type PackagingToolResult = {
|
|
270
|
+
success: true;
|
|
271
|
+
stdout: string;
|
|
272
|
+
} | {
|
|
273
|
+
success: false;
|
|
274
|
+
error: string;
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* Eject a mounted image, retrying the contention hdiutil reports as
|
|
278
|
+
* "couldn't eject ... - Resource busy".
|
|
279
|
+
*
|
|
280
|
+
* Writing the installer layout is what provokes it: Spotlight and fseventsd
|
|
281
|
+
* pick the volume up as soon as files land on it and can still hold it when
|
|
282
|
+
* the eject arrives. That is the same transient class `create` is already
|
|
283
|
+
* allowed to retry, so detaching gets the same bounded patience, and the
|
|
284
|
+
* final attempt escalates to `-force` rather than reporting a failed build
|
|
285
|
+
* for a volume nothing is really using any more.
|
|
286
|
+
*/
|
|
287
|
+
export declare function detachDiskImage(mount: string, options?: {
|
|
288
|
+
run?: (args: string[]) => Promise<PackagingToolResult>;
|
|
289
|
+
wait?: (ms: number) => Promise<void>;
|
|
290
|
+
}): Promise<{
|
|
291
|
+
success: true;
|
|
292
|
+
} | {
|
|
293
|
+
success: false;
|
|
294
|
+
error: string;
|
|
295
|
+
}>;
|
|
296
|
+
/**
|
|
297
|
+
* Helper: Create DMG from app bundle
|
|
298
|
+
*/
|
|
299
|
+
export declare function createDiskImage(opts: {
|
|
300
|
+
appBundlePath: string;
|
|
301
|
+
outputPath: string;
|
|
302
|
+
volumeName: string;
|
|
303
|
+
}): Promise<{
|
|
304
|
+
success: boolean;
|
|
305
|
+
outputPath?: string;
|
|
306
|
+
error?: string;
|
|
307
|
+
}>;
|
|
268
308
|
/**
|
|
269
309
|
* Helper: Create PKG from app bundle
|
|
270
310
|
*
|
package/dist/types.d.ts
CHANGED
|
@@ -1168,6 +1168,32 @@ export interface CraftSidebar {
|
|
|
1168
1168
|
onSelect: (callback: (itemId: string) => void) => CraftSidebar;
|
|
1169
1169
|
destroy: () => void;
|
|
1170
1170
|
}
|
|
1171
|
+
/**
|
|
1172
|
+
* The legacy flat bridge consumed by the React, Svelte and Vue bindings.
|
|
1173
|
+
* This is their existing host-specific view, not a second Window.craft global
|
|
1174
|
+
* or a claim that every desktop host implements the flat mobile methods.
|
|
1175
|
+
*/
|
|
1176
|
+
export interface CraftBindingsAPI {
|
|
1177
|
+
getPlatform(): Promise<{
|
|
1178
|
+
platform: string;
|
|
1179
|
+
version: string;
|
|
1180
|
+
}>;
|
|
1181
|
+
getDeviceInfo(): Promise<{
|
|
1182
|
+
platform: string;
|
|
1183
|
+
model: string;
|
|
1184
|
+
os_version: string;
|
|
1185
|
+
}>;
|
|
1186
|
+
showToast(message: string, duration: 'short' | 'long'): Promise<void>;
|
|
1187
|
+
haptic(type: string): Promise<void>;
|
|
1188
|
+
requestPermission(permission: string): Promise<{
|
|
1189
|
+
granted: boolean;
|
|
1190
|
+
message: string;
|
|
1191
|
+
}>;
|
|
1192
|
+
on(event: string, callback: (...args: any[]) => void): void;
|
|
1193
|
+
off(event: string, callback: (...args: any[]) => void): void;
|
|
1194
|
+
emit(event: string, data?: any): void;
|
|
1195
|
+
invoke(method: string, params?: any): Promise<any>;
|
|
1196
|
+
}
|
|
1171
1197
|
export interface CraftBridgeAPI {
|
|
1172
1198
|
/**
|
|
1173
1199
|
* Where the platform drew this window's close/minimise/zoom buttons.
|