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.
Files changed (39) hide show
  1. package/LICENSE.md +30 -0
  2. package/README.md +35 -0
  3. package/dist/android/src/index.d.ts +8 -0
  4. package/dist/android/src/index.js +17 -2
  5. package/dist/android/templates/CraftHealthServices.kt.template +2 -0
  6. package/dist/android/templates/CraftNativeCapabilities.kt.template +42 -1
  7. package/dist/android/templates/CraftNativeMotion.kt.template +269 -0
  8. package/dist/android/templates/CraftNativeServices.kt.template +6 -1
  9. package/dist/android/templates/CraftPassiveSteps.kt.template +159 -0
  10. package/dist/android/templates/CraftRecordingPressure.kt.template +156 -0
  11. package/dist/android/templates/LocationRecordingService.kt.template +77 -9
  12. package/dist/android/templates/MainActivityNative.kt.template +129 -1
  13. package/dist/cli.js +289 -108
  14. package/dist/components/index.d.ts +25 -0
  15. package/dist/index.cjs +207 -27
  16. package/dist/index.d.cts +2 -2
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +321 -140
  19. package/dist/installer-background.d.ts +2 -0
  20. package/dist/installer-layout.d.ts +1 -0
  21. package/dist/ios/src/index.d.ts +10 -0
  22. package/dist/ios/src/index.js +28 -2
  23. package/dist/ios/templates/CraftApp.swift +14 -0
  24. package/dist/ios/templates/CraftHealth.swift +2 -1
  25. package/dist/ios/templates/CraftHealthBackground.swift +308 -0
  26. package/dist/ios/templates/CraftNativeActions.swift +153 -0
  27. package/dist/ios/templates/CraftNativeMutation.swift +18 -3
  28. package/dist/ios/templates/CraftNativePedometer.swift +181 -0
  29. package/dist/ios/templates/CraftNativeScreen.swift +76 -0
  30. package/dist/ios/templates/testing/DriverTests.swift.template +24 -4
  31. package/dist/macos/src/index.js +1 -1
  32. package/dist/macos/src/toolchain.d.ts +1 -1
  33. package/dist/macos/templates/CraftMacApplication.swift +124 -39
  34. package/dist/macos/templates/CraftMacRenderer.swift +169 -37
  35. package/dist/macos/templates/CraftMacRuntime.swift +298 -77
  36. package/dist/macos/templates/CraftNativeMutation.swift +18 -3
  37. package/dist/package.d.ts +40 -0
  38. package/dist/types.d.ts +26 -0
  39. 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
- /// One zig-js context per STX window. No DOM and no browser view.
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
- weak var scene: CraftMacScene?
81
- private var timers: [Int: DispatchWorkItem] = [:]
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
- init(route: String, params: [String: Any], network: CraftMacNetwork) {
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?.errors.append(message)
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.disposed else { return }
99
- let work = DispatchWorkItem { [weak self] in
100
- guard let self, self.timers.removeValue(forKey: id) != nil, !self.disposed else { return }
101
- self.context.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
102
- ZJSContextRunMicrotasks(self.context)
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)?.cancel() }
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?.errors.append(text) }
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": CraftMacColor.isDark ? "dark" : "light",
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
- func load(_ bundle: String) { context.evaluateScript(bundle); ZJSContextRunMicrotasks(context) }
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
- DispatchQueue.main.async { [weak self] in
196
- guard let self, !self.disposed else { return }
197
- self.context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [text])
198
- ZJSContextRunMicrotasks(self.context)
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
- document.replace(with: tree)
214
- scene?.renderer.render(tree)
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
- if let tree = result.document { scene?.renderer.render(tree) }
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
- for scene in CraftMacApplication.shared.scenes.values {
237
- scene.runtime.send("SESSION_CHANGED", ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()])
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
- private func perform(_ id: String, _ module: String, _ method: String, _ args: [Any]) {
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
- lazy var session: URLSession = {
324
- let configuration = URLSessionConfiguration.ephemeral
325
- configuration.timeoutIntervalForRequest = 300
326
- configuration.timeoutIntervalForResource = 7 * 24 * 60 * 60
327
- return URLSession(configuration: configuration, delegate: self, delegateQueue: .main)
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
- init(base: URL) { self.base = base }
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
- let identifiers = streams.filter { $0.value.id == id && $0.value.owner === owner }.map(\.key)
354
- session.getAllTasks { tasks in DispatchQueue.main.async { for task in tasks where identifiers.contains(task.taskIdentifier) { self.streams.removeValue(forKey: task.taskIdentifier); task.cancel() } } }
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.