craft-native 0.0.436 → 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 (36) 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/CraftNativePedometer.swift +181 -0
  28. package/dist/ios/templates/CraftNativeScreen.swift +76 -0
  29. package/dist/macos/src/index.js +1 -1
  30. package/dist/macos/src/toolchain.d.ts +1 -1
  31. package/dist/macos/templates/CraftMacApplication.swift +124 -39
  32. package/dist/macos/templates/CraftMacRenderer.swift +111 -33
  33. package/dist/macos/templates/CraftMacRuntime.swift +270 -81
  34. package/dist/package.d.ts +40 -0
  35. package/dist/types.d.ts +26 -0
  36. 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,9 +256,35 @@ final class CraftMacRuntime {
173
256
  """)
174
257
  }
175
258
 
176
- func load(_ bundle: String) {
177
- startProfileIfAsked()
178
- 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) } }
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.jsGlobalContextRef else { return }
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
- DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in self?.writeProfile(to: path) }
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 !disposed, let ref = context.jsGlobalContextRef, let json = ZJSContextStopCPUProfile(ref) else { return }
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
- DispatchQueue.main.async { [weak self] in
226
- guard let self, !self.disposed else { return }
227
- self.context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [text])
228
- 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
+ }
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
- document.replace(with: tree)
244
- 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() } }
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
- for scene in CraftMacApplication.shared.scenes.values {
269
- 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
+ }
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
- 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]) {
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
- lazy var session: URLSession = {
356
- let configuration = URLSessionConfiguration.ephemeral
357
- configuration.timeoutIntervalForRequest = 300
358
- configuration.timeoutIntervalForResource = 7 * 24 * 60 * 60
359
- 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
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
- 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
+ }
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
- let identifiers = streams.filter { $0.value.id == id && $0.value.owner === owner }.map(\.key)
386
- 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
+ }
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.436",
3
+ "version": "0.0.442",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",