craft-native 0.0.435 → 0.0.442
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 +8 -0
- package/dist/android/src/index.js +17 -2
- 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/MainActivityNative.kt.template +129 -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 +10 -0
- package/dist/ios/src/index.js +28 -2
- 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/CraftNativeMutation.swift +18 -3
- package/dist/ios/templates/CraftNativePedometer.swift +181 -0
- package/dist/ios/templates/CraftNativeScreen.swift +76 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +24 -4
- 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 +169 -37
- package/dist/macos/templates/CraftMacRuntime.swift +298 -77
- package/dist/macos/templates/CraftNativeMutation.swift +18 -3
- 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,29 +256,97 @@ final class CraftMacRuntime {
|
|
|
173
256
|
""")
|
|
174
257
|
}
|
|
175
258
|
|
|
176
|
-
|
|
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) } }
|
|
177
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) }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/// `CRAFT_JS_PROFILE=<file>`: sample this screen's JavaScript from the moment its bundle loads,
|
|
291
|
+
/// for `CRAFT_JS_PROFILE_MS` milliseconds (5000, every `CRAFT_JS_PROFILE_INTERVAL_US`, 1000),
|
|
292
|
+
/// and write it as Chrome's `.cpuprofile` (DevTools and speedscope open it). A window other
|
|
293
|
+
/// than Main writes beside it, its route in the name.
|
|
294
|
+
private func startProfileIfAsked() {
|
|
295
|
+
let env = ProcessInfo.processInfo.environment
|
|
296
|
+
guard let path = env["CRAFT_JS_PROFILE"], !path.isEmpty, let ref = context?.jsGlobalContextRef else { return }
|
|
297
|
+
guard ZJSContextStartCPUProfile(ref, UInt32(env["CRAFT_JS_PROFILE_INTERVAL_US"] ?? "") ?? 0) else { return }
|
|
298
|
+
let seconds = (Double(env["CRAFT_JS_PROFILE_MS"] ?? "") ?? 5000) / 1000
|
|
299
|
+
_ = thread.after(seconds) { [weak self] in self?.writeProfile(to: path) }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private func writeProfile(to path: String) {
|
|
303
|
+
guard !stopped, let ref = context?.jsGlobalContextRef, let json = ZJSContextStopCPUProfile(ref) else { return }
|
|
304
|
+
defer { JSStringRelease(json) }
|
|
305
|
+
let size = JSStringGetMaximumUTF8CStringSize(json)
|
|
306
|
+
var bytes = [CChar](repeating: 0, count: size)
|
|
307
|
+
JSStringGetUTF8CString(json, &bytes, size)
|
|
308
|
+
let url = URL(fileURLWithPath: path)
|
|
309
|
+
let target = route == "Main" ? url : url.deletingPathExtension().appendingPathExtension("\(route).cpuprofile")
|
|
310
|
+
do {
|
|
311
|
+
try String(cString: bytes).write(to: target, atomically: true, encoding: .utf8)
|
|
312
|
+
fputs("[craft] JavaScript profile written to \(target.path)\n", stderr)
|
|
313
|
+
}
|
|
314
|
+
catch { fputs("[craft] could not write the JavaScript profile to \(target.path): \(error)\n", stderr) }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// Unmounts the screen and stops its thread. Waits (a little) for the unmount, as a quit would.
|
|
178
318
|
func dispose() {
|
|
179
319
|
guard !disposed else { return }
|
|
320
|
+
disposed = true
|
|
180
321
|
#if CRAFT_WEBKIT
|
|
181
322
|
CraftMacWebTabs.shared.closeAll(owner: self)
|
|
182
323
|
#endif
|
|
183
|
-
context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
|
|
184
|
-
disposed = true
|
|
185
|
-
timers.values.forEach { $0.cancel() }; timers.removeAll()
|
|
186
|
-
tasks.values.forEach { $0.cancel() }; tasks.removeAll()
|
|
187
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)
|
|
188
337
|
}
|
|
189
338
|
|
|
339
|
+
/// A message for the screen, from any thread: delivered on its thread, in the order sent.
|
|
190
340
|
func send(_ type: String, _ payload: [String: Any], correlation: String? = nil) {
|
|
191
|
-
guard !disposed else { return }
|
|
192
341
|
var message: [String: Any] = ["id": UUID().uuidString, "type": type, "timestamp": Date().timeIntervalSince1970 * 1000, "source": "native", "payload": payload]
|
|
193
342
|
if let correlation { message["correlationId"] = correlation }
|
|
194
343
|
guard let bytes = try? JSONSerialization.data(withJSONObject: message), let text = String(data: bytes, encoding: .utf8) else { return }
|
|
195
|
-
|
|
196
|
-
guard let self, !self.
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
}
|
|
199
350
|
}
|
|
200
351
|
}
|
|
201
352
|
|
|
@@ -204,28 +355,49 @@ final class CraftMacRuntime {
|
|
|
204
355
|
send("EVENT", ["handlerId": id, "nativeEvent": payload])
|
|
205
356
|
}
|
|
206
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.
|
|
207
360
|
private func receive(_ text: String) {
|
|
208
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]) {
|
|
209
382
|
let payload = message["payload"] as? [String: Any] ?? [:]
|
|
210
383
|
switch message["type"] as? String {
|
|
211
384
|
case "RENDER":
|
|
212
385
|
guard let tree = payload["document"] as? [String: Any] else { return }
|
|
213
|
-
|
|
214
|
-
|
|
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() } }
|
|
215
391
|
case "MUTATE":
|
|
216
|
-
do {
|
|
392
|
+
do { try CraftTrace.span(" native MUTATE \((payload["mutations"] as? [Any])?.count ?? 0)", atLeast: 1) {
|
|
217
393
|
let result = try document.apply(payload)
|
|
218
|
-
|
|
394
|
+
// Drawn where it changed; the whole tree only when the batch reshaped it.
|
|
395
|
+
if result.requiresFullRender || scene?.renderer.update(result.affectedNodeIds, parentOf: document.parentId(of:), node: document.node) != true,
|
|
396
|
+
let tree = result.document { scene?.renderer.render(tree) }
|
|
219
397
|
send("MUTATION_ACK", ["version": 1, "batchId": result.batchId, "revision": result.revision])
|
|
220
|
-
} catch let error as CraftNativeMutationFailure {
|
|
398
|
+
} } catch let error as CraftNativeMutationFailure {
|
|
221
399
|
send("MUTATION_ERROR", ["version": 1, "batchId": payload["batchId"] ?? "", "code": error.code, "message": error.message])
|
|
222
400
|
} catch { errors.append(error.localizedDescription) }
|
|
223
|
-
case "API_REQUEST":
|
|
224
|
-
guard let id = message["id"] as? String else { return }
|
|
225
|
-
guard payload["version"] as? Int == 1 else { fail(id, "UNSUPPORTED_VERSION", "Native capability version must be 1"); return }
|
|
226
|
-
perform(id, payload["module"] as? String ?? "", payload["method"] as? String ?? "", payload["args"] as? [Any] ?? [])
|
|
227
|
-
case "API_CANCEL":
|
|
228
|
-
if let id = payload["requestId"] as? String { tasks.removeValue(forKey: id)?.cancel() }
|
|
229
401
|
default: break
|
|
230
402
|
}
|
|
231
403
|
}
|
|
@@ -233,41 +405,46 @@ final class CraftMacRuntime {
|
|
|
233
405
|
/// `craft.session.onChange`: the sign-in token moved, in any window.
|
|
234
406
|
private func sessionChanged(_ key: String, token: String?, previous: String?) {
|
|
235
407
|
guard key == "auth.token", token != previous else { return }
|
|
236
|
-
|
|
237
|
-
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
|
+
}
|
|
238
412
|
}
|
|
239
413
|
}
|
|
240
414
|
|
|
415
|
+
/// An answer to the screen's request `id`, from any thread.
|
|
241
416
|
func answer(_ id: String, _ value: Any = NSNull()) {
|
|
242
|
-
tasks.removeValue(forKey: id)
|
|
417
|
+
thread.run { self.tasks.removeValue(forKey: id) }
|
|
243
418
|
send("API_RESPONSE", ["version": 1, "requestId": id, "data": value], correlation: id)
|
|
244
419
|
}
|
|
245
420
|
func fail(_ id: String, _ code: String, _ message: String) {
|
|
246
|
-
tasks.removeValue(forKey: id)
|
|
421
|
+
thread.run { self.tasks.removeValue(forKey: id) }
|
|
247
422
|
send("API_ERROR", ["version": 1, "requestId": id, "code": code, "message": message], correlation: id)
|
|
248
423
|
}
|
|
249
424
|
|
|
250
|
-
|
|
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]) {
|
|
251
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]) {
|
|
252
447
|
switch module {
|
|
253
|
-
case "Network":
|
|
254
|
-
if method == "cancelStream" { network.cancelStream(args.first as? String ?? "", owner: self); answer(id); return }
|
|
255
|
-
guard let request = network.request(options) else { fail(id, "INVALID_ARGUMENT", "Network.fetch requires an HTTP URL"); return }
|
|
256
|
-
if method == "stream" {
|
|
257
|
-
network.stream(request, id: options["id"] as? String ?? id, owner: self, requestId: id)
|
|
258
|
-
return
|
|
259
|
-
}
|
|
260
|
-
guard method == "fetch" else { fail(id, "UNSUPPORTED_METHOD", method); return }
|
|
261
|
-
let task = network.session.dataTask(with: request) { [weak self] data, response, error in
|
|
262
|
-
DispatchQueue.main.async {
|
|
263
|
-
guard let self, !self.disposed else { return }
|
|
264
|
-
if let error { self.fail(id, "NETWORK_ERROR", error.localizedDescription); return }
|
|
265
|
-
guard let http = response as? HTTPURLResponse else { self.fail(id, "NETWORK_ERROR", "Invalid HTTP response"); return }
|
|
266
|
-
if (data?.count ?? 0) > 32 * 1024 * 1024 { self.fail(id, "RESPONSE_TOO_LARGE", "Response exceeds 32 MB"); return }
|
|
267
|
-
self.answer(id, CraftMacNetwork.response(http, original: request.url, body: String(decoding: data ?? Data(), as: UTF8.self)))
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
tasks[id] = task; task.resume()
|
|
271
448
|
case "SecureStorage":
|
|
272
449
|
if method == "clear" { CraftMacKeychain.remove(nil); answer(id, true); return }
|
|
273
450
|
guard let key = args.first as? String, !key.isEmpty else { fail(id, "INVALID_ARGUMENT", "Secure storage needs a non-empty key"); return }
|
|
@@ -294,6 +471,15 @@ final class CraftMacRuntime {
|
|
|
294
471
|
case "keys": answer(id, UserDefaults.standard.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) })
|
|
295
472
|
default: fail(id, "UNSUPPORTED_METHOD", method)
|
|
296
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 {
|
|
297
483
|
case "Clipboard":
|
|
298
484
|
if method == "read" { answer(id, NSPasteboard.general.string(forType: .string) ?? "") }
|
|
299
485
|
else { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args.first as? String ?? "", forType: .string); answer(id) }
|
|
@@ -320,15 +506,49 @@ final class CraftMacRuntime {
|
|
|
320
506
|
/// A session shared by native windows, with an isolated, HttpOnly cookie store.
|
|
321
507
|
final class CraftMacNetwork: NSObject, URLSessionDataDelegate {
|
|
322
508
|
let base: URL
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
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
|
|
328
514
|
}()
|
|
515
|
+
private(set) var session: URLSession!
|
|
329
516
|
private struct Stream { let id: String; weak var owner: CraftMacRuntime?; let requestId: String; var opened: Bool = false; var carry = Data() }
|
|
330
517
|
private var streams: [Int: Stream] = [:]
|
|
331
|
-
|
|
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
|
+
}
|
|
332
552
|
func request(_ options: [String: Any]) -> URLRequest? {
|
|
333
553
|
guard let address = options["url"] as? String, let url = URL(string: address, relativeTo: base)?.absoluteURL,
|
|
334
554
|
["http", "https"].contains(url.scheme ?? ""), url.host != nil else { return nil }
|
|
@@ -346,14 +566,15 @@ final class CraftMacNetwork: NSObject, URLSessionDataDelegate {
|
|
|
346
566
|
}
|
|
347
567
|
func stream(_ request: URLRequest, id: String, owner: CraftMacRuntime, requestId: String) {
|
|
348
568
|
let task = session.dataTask(with: request)
|
|
349
|
-
streams[task.taskIdentifier] = Stream(id: id, owner: owner, requestId: requestId)
|
|
350
|
-
task.resume()
|
|
569
|
+
queue.addOperation { self.streams[task.taskIdentifier] = Stream(id: id, owner: owner, requestId: requestId); self.resume(task) }
|
|
351
570
|
}
|
|
352
571
|
func cancelStream(_ id: String, owner: CraftMacRuntime) {
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
}
|
|
355
576
|
}
|
|
356
|
-
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) } } }
|
|
357
578
|
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
|
358
579
|
guard var stream = streams[dataTask.taskIdentifier] else { completionHandler(.allow); return }
|
|
359
580
|
guard let http = response as? HTTPURLResponse else { completionHandler(.cancel); stream.owner?.fail(stream.requestId, "NETWORK_ERROR", "Invalid stream response"); return }
|
|
@@ -18,10 +18,22 @@ struct CraftNativeMutationFailure: Error, Equatable {
|
|
|
18
18
|
struct CraftNativeMutationResult {
|
|
19
19
|
let batchId: String
|
|
20
20
|
let revision: Int
|
|
21
|
-
let document: [String: Any]?
|
|
22
21
|
let updatedNodeIds: [String]
|
|
23
22
|
let affectedNodeIds: [String]
|
|
24
23
|
let requiresFullRender: Bool
|
|
24
|
+
private let materialize: () -> [String: Any]?
|
|
25
|
+
/// The committed tree, built when asked for: a host that redraws only the
|
|
26
|
+
/// nodes a batch touched never needs the whole tree.
|
|
27
|
+
var document: [String: Any]? { materialize() }
|
|
28
|
+
|
|
29
|
+
init(batchId: String, revision: Int, document: @escaping () -> [String: Any]?, updatedNodeIds: [String], affectedNodeIds: [String], requiresFullRender: Bool) {
|
|
30
|
+
self.batchId = batchId
|
|
31
|
+
self.revision = revision
|
|
32
|
+
self.materialize = document
|
|
33
|
+
self.updatedNodeIds = updatedNodeIds
|
|
34
|
+
self.affectedNodeIds = affectedNodeIds
|
|
35
|
+
self.requiresFullRender = requiresFullRender
|
|
36
|
+
}
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
/// Validates and applies versioned native-tree mutations without touching UIKit.
|
|
@@ -91,6 +103,8 @@ final class CraftNativeMutationDocument {
|
|
|
91
103
|
var updatedNodeIds: [String] = []
|
|
92
104
|
var affectedNodeIds: [String] = []
|
|
93
105
|
var requiresFullRender = false
|
|
106
|
+
// Only an operation that adds, moves or removes nodes can leave one unreachable.
|
|
107
|
+
var reshaped = false
|
|
94
108
|
for (index, value) in operations.enumerated() {
|
|
95
109
|
guard let operation = value as? [String: Any], let name = operation["op"] as? String else {
|
|
96
110
|
throw CraftNativeMutationFailure(
|
|
@@ -99,6 +113,7 @@ final class CraftNativeMutationDocument {
|
|
|
99
113
|
operationIndex: index
|
|
100
114
|
)
|
|
101
115
|
}
|
|
116
|
+
if name != "updateNode" { reshaped = true }
|
|
102
117
|
do {
|
|
103
118
|
switch name {
|
|
104
119
|
case "createNode":
|
|
@@ -147,7 +162,7 @@ final class CraftNativeMutationDocument {
|
|
|
147
162
|
guard let root = candidateNodes[candidateRoot], root.parent == nil else {
|
|
148
163
|
throw CraftNativeMutationFailure("INVALID_TREE", "root node must not have a parent")
|
|
149
164
|
}
|
|
150
|
-
guard descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
|
|
165
|
+
guard !reshaped || descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
|
|
151
166
|
throw CraftNativeMutationFailure("INVALID_TREE", "all nodes must be reachable from the root")
|
|
152
167
|
}
|
|
153
168
|
}
|
|
@@ -158,7 +173,7 @@ final class CraftNativeMutationDocument {
|
|
|
158
173
|
return CraftNativeMutationResult(
|
|
159
174
|
batchId: batchId,
|
|
160
175
|
revision: nextRevision,
|
|
161
|
-
document: candidateRoot.flatMap { materialize($0, nodes: candidateNodes) },
|
|
176
|
+
document: { [candidateRoot, candidateNodes, weak self] in candidateRoot.flatMap { self?.materialize($0, nodes: candidateNodes) } },
|
|
162
177
|
updatedNodeIds: updatedNodeIds,
|
|
163
178
|
affectedNodeIds: affectedNodeIds,
|
|
164
179
|
requiresFullRender: requiresFullRender
|
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.
|