craft-native 0.0.420 → 0.0.424

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.
@@ -15,6 +15,8 @@ export interface AndroidEmulatorOptions {
15
15
  /** Requested guest CPU count, 1–8; defaults to 2. */
16
16
  virtualCpus?: number;
17
17
  env?: Record<string, string | undefined>;
18
+ /** Cancels provisioning, boot and the owned session without touching other devices. */
19
+ signal?: AbortSignal;
18
20
  }
19
21
  export interface AndroidEmulatorSession {
20
22
  serial: string;
@@ -22,6 +24,8 @@ export interface AndroidEmulatorSession {
22
24
  env: Record<string, string | undefined>;
23
25
  /** Stops only this session's process and removes only its disposable AVD. */
24
26
  dispose: () => Promise<void>;
27
+ /** First disposal result, including cancellation. A failure stays recorded even if dispose() later retries successfully. */
28
+ closed: Promise<void>;
25
29
  }
26
30
  /** Provision and own a real Android emulator on a macOS or Linux SDK host. */
27
31
  export declare function createAndroidEmulator(options: AndroidEmulatorOptions): Promise<AndroidEmulatorSession>;
@@ -58,6 +58,10 @@ export interface CraftAndroidConfig {
58
58
  enableCamera?: boolean;
59
59
  enableBiometric?: boolean;
60
60
  enablePushNotifications?: boolean;
61
+ /** Enable native SQLite queries and transactions. Disabled unless explicitly true. */
62
+ enableLocalDatabase?: boolean;
63
+ /** Enable native AlarmManager local notification scheduling. Disabled unless explicitly true. */
64
+ enableLocalNotifications?: boolean;
61
65
  enableSecureStorage?: boolean;
62
66
  enableGeolocation?: boolean;
63
67
  enableBackgroundLocation?: boolean;
@@ -182,7 +182,9 @@ tasks.register('runFixture', JavaExec) {
182
182
  import { appendFileSync, closeSync, mkdirSync, mkdtempSync, openSync, rmSync } from "fs";
183
183
  import { availableParallelism, cpus, tmpdir as tmpdir2, totalmem } from "os";
184
184
  import { basename, isAbsolute, join as join2, resolve } from "path";
185
+ import { setTimeout as delay } from "timers/promises";
185
186
  async function createAndroidEmulator(options) {
187
+ options.signal?.throwIfAborted();
186
188
  if (!["darwin", "linux"].includes(process.platform))
187
189
  throw new Error("Android emulator sessions require macOS or Linux");
188
190
  if (!["hardware", "software"].includes(options.acceleration))
@@ -238,6 +240,14 @@ async function createAndroidEmulator(options) {
238
240
  let cpuStart = 0;
239
241
  let idleStart = 0;
240
242
  let identity;
243
+ const processes = new Set;
244
+ let resolveClosed;
245
+ let rejectClosed;
246
+ const closed = new Promise((resolve, reject) => {
247
+ resolveClosed = resolve;
248
+ rejectClosed = reject;
249
+ });
250
+ closed.catch(() => {});
241
251
  async function recordBootResult(status, endedAt = Date.now()) {
242
252
  if (!identity)
243
253
  return;
@@ -253,31 +263,165 @@ async function createAndroidEmulator(options) {
253
263
  }, null, 2) + `
254
264
  `);
255
265
  }
256
- async function run(args, input, timeout = 900000, allowFailure = false) {
257
- const startedAt = new Date().toISOString();
258
- const command = Bun.spawn(args, { env, stdin: input === undefined ? "ignore" : new TextEncoder().encode(input), stdout: "pipe", stderr: "pipe", signal: AbortSignal.timeout(timeout) });
259
- const [code, stdout, stderr] = await Promise.all([command.exited, new Response(command.stdout).text(), new Response(command.stderr).text()]);
260
- appendFileSync(join2(artifacts, "device-commands.jsonl"), JSON.stringify({ sequence: ++sequence, startedAt, finishedAt: new Date().toISOString(), args, code, stdout, stderr }) + `
261
- `);
262
- if (code !== 0 && !allowFailure)
263
- throw new Error(`${basename(args[0])} failed with exit ${code}; inspect ${artifacts}`);
264
- return { code, stdout };
266
+ function groupExists(pid) {
267
+ try {
268
+ process.kill(-pid, 0);
269
+ return true;
270
+ } catch (error) {
271
+ if (error.code === "ESRCH")
272
+ return false;
273
+ if (error.code === "EPERM")
274
+ return true;
275
+ throw error;
276
+ }
277
+ }
278
+ function signalGroup(pid, signal) {
279
+ try {
280
+ process.kill(-pid, signal);
281
+ } catch (error) {
282
+ if (error.code !== "ESRCH")
283
+ throw error;
284
+ }
265
285
  }
266
- const dispose = () => disposal ??= (async () => {
286
+ async function disposeGroup(pid, graceMs) {
287
+ const errors = [];
288
+ if (!groupExists(pid))
289
+ return errors;
290
+ let termSent = false;
267
291
  try {
268
- if (child && child.exitCode === null) {
269
- child.kill("SIGTERM");
270
- await Promise.race([child.exited, Bun.sleep(1e4)]);
271
- if (child.exitCode === null)
272
- child.kill("SIGKILL");
273
- await child.exited;
292
+ signalGroup(pid, "SIGTERM");
293
+ termSent = true;
294
+ } catch (error) {
295
+ errors.push(error);
296
+ }
297
+ if (termSent) {
298
+ const grace = performance.now() + graceMs;
299
+ while (groupExists(pid) && performance.now() < grace)
300
+ await Bun.sleep(10);
301
+ }
302
+ if (!groupExists(pid))
303
+ return errors;
304
+ try {
305
+ signalGroup(pid, "SIGKILL");
306
+ } catch (error) {
307
+ errors.push(error);
308
+ }
309
+ const deadline = performance.now() + 1000;
310
+ while (groupExists(pid) && performance.now() < deadline)
311
+ await Bun.sleep(10);
312
+ if (groupExists(pid))
313
+ throw new AggregateError([...errors, new Error(`Owned Android process group ${pid} remained after disposal`)], `Android cleanup failed; private registry retained at ${owned}`);
314
+ return errors;
315
+ }
316
+ function closeProcess(record) {
317
+ if (record.closing)
318
+ return record.closing;
319
+ const attempt = (async () => {
320
+ const errors = await disposeGroup(record.process.pid, record.graceMs);
321
+ await record.process.exited;
322
+ if (record.output) {
323
+ let timer;
324
+ try {
325
+ const output = await Promise.race([
326
+ record.output,
327
+ new Promise((resolve) => {
328
+ timer = setTimeout(() => resolve(null), 1000);
329
+ })
330
+ ]);
331
+ if (!output)
332
+ throw new Error(`Owned Android command pipes did not close; private registry retained at ${owned}`);
333
+ for (const stream of output)
334
+ if (stream.status === "rejected")
335
+ errors.push(stream.reason);
336
+ record.stdout = output[0]?.status === "fulfilled" ? output[0].value : "";
337
+ } finally {
338
+ if (timer)
339
+ clearTimeout(timer);
340
+ }
274
341
  }
342
+ processes.delete(record);
343
+ if (errors.length)
344
+ throw new AggregateError(errors, "Owned Android process cleanup encountered an error");
345
+ })();
346
+ record.closing = attempt;
347
+ attempt.catch(() => {
348
+ if (record.closing === attempt)
349
+ record.closing = undefined;
350
+ });
351
+ return attempt;
352
+ }
353
+ async function run(args, input, timeout = 900000, allowFailure = false) {
354
+ options.signal?.throwIfAborted();
355
+ const startedAt = new Date().toISOString();
356
+ const command = Bun.spawn(args, { env, detached: true, stdin: input === undefined ? "ignore" : new TextEncoder().encode(input), stdout: "pipe", stderr: "pipe" });
357
+ const record = { process: command, graceMs: 250, output: Promise.allSettled([new Response(command.stdout).text(), new Response(command.stderr).text()]) };
358
+ processes.add(record);
359
+ let stop;
360
+ const stopping = new Promise((resolve) => {
361
+ stop = resolve;
362
+ });
363
+ const onCommandAbort = () => stop("aborted");
364
+ const timer = setTimeout(() => stop("timeout"), timeout);
365
+ options.signal?.addEventListener("abort", onCommandAbort, { once: true });
366
+ try {
367
+ if (options.signal?.aborted)
368
+ stop("aborted");
369
+ const result = await Promise.race([command.exited, stopping]);
370
+ await closeProcess(record);
371
+ const code = await command.exited;
372
+ const output = await record.output;
373
+ const stdout = record.stdout ?? "";
374
+ const stderr = output[1]?.status === "fulfilled" ? output[1].value : "";
375
+ const timedOut = result === "timeout";
376
+ appendFileSync(join2(artifacts, "device-commands.jsonl"), JSON.stringify({ sequence: ++sequence, startedAt, finishedAt: new Date().toISOString(), args, code, stdout, stderr, timedOut, commandGroupDisposed: true }) + `
377
+ `);
378
+ options.signal?.throwIfAborted();
379
+ if (timedOut && !allowFailure)
380
+ throw new Error(`${basename(args[0])} timed out; inspect ${artifacts}`);
381
+ if (code !== 0 && !allowFailure)
382
+ throw new Error(`${basename(args[0])} failed with exit ${code}; inspect ${artifacts}`);
383
+ return { code: timedOut ? code || 1 : code, stdout };
275
384
  } finally {
276
- if (log !== undefined)
277
- closeSync(log);
278
- rmSync(owned, { recursive: true, force: true });
385
+ clearTimeout(timer);
386
+ options.signal?.removeEventListener("abort", onCommandAbort);
279
387
  }
280
- })();
388
+ }
389
+ const dispose = () => {
390
+ if (disposal)
391
+ return disposal;
392
+ const attempt = (async () => {
393
+ const results = await Promise.allSettled([...processes].map(closeProcess));
394
+ const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason);
395
+ try {
396
+ if (!processes.size) {
397
+ options.signal?.removeEventListener("abort", onAbort);
398
+ if (log !== undefined) {
399
+ closeSync(log);
400
+ log = undefined;
401
+ }
402
+ rmSync(owned, { recursive: true, force: true });
403
+ } else
404
+ errors.push(new Error(`Owned Android processes remain; private registry retained at ${owned}`));
405
+ } catch (error) {
406
+ errors.push(error);
407
+ }
408
+ if (errors.length)
409
+ throw new AggregateError(errors, `Android emulator disposal failed; inspect ${artifacts}`);
410
+ })().then(resolveClosed, (error) => {
411
+ rejectClosed(error);
412
+ throw error;
413
+ });
414
+ disposal = attempt;
415
+ attempt.catch(() => {
416
+ if (disposal === attempt)
417
+ disposal = undefined;
418
+ });
419
+ return attempt;
420
+ };
421
+ const onAbort = () => {
422
+ dispose().catch(() => {});
423
+ };
424
+ options.signal?.addEventListener("abort", onAbort, { once: true });
281
425
  try {
282
426
  mkdirSync(env.ANDROID_AVD_HOME, { recursive: true });
283
427
  mkdirSync(env.ANDROID_USER_HOME, { recursive: true });
@@ -302,7 +446,10 @@ async function createAndroidEmulator(options) {
302
446
  const hostCpus = cpus();
303
447
  cpuStart = hostCpus.reduce((total, cpu) => total + Object.values(cpu.times).reduce((sum, value) => sum + value, 0), 0);
304
448
  idleStart = hostCpus.reduce((total, cpu) => total + cpu.times.idle, 0);
305
- child = Bun.spawn([executable, "-avd", name, "-port", String(port), "-no-window", "-no-snapshot", "-no-audio", "-no-boot-anim", "-gpu", "software", "-memory", String(memoryMb), "-cores", String(virtualCpus), "-accel", options.acceleration === "software" ? "off" : "on"], { env, stdout: log, stderr: log });
449
+ child = Bun.spawn([executable, "-avd", name, "-port", String(port), "-no-window", "-no-snapshot", "-no-audio", "-no-boot-anim", "-gpu", "software", "-memory", String(memoryMb), "-cores", String(virtualCpus), "-accel", options.acceleration === "software" ? "off" : "on"], { env, detached: true, stdout: log, stderr: log });
450
+ processes.add({ process: child, graceMs: 1e4 });
451
+ if (options.signal?.aborted)
452
+ onAbort();
306
453
  identity = {
307
454
  image: options.image,
308
455
  acceleration: options.acceleration,
@@ -323,6 +470,7 @@ async function createAndroidEmulator(options) {
323
470
  let connected = false;
324
471
  let lastReconnectAt = 0;
325
472
  while (Date.now() < deadline && child.exitCode === null) {
473
+ options.signal?.throwIfAborted();
326
474
  let booted = false;
327
475
  const connection = await run([adb, "devices"], undefined, probeTimeout(), true);
328
476
  const state = connection.stdout.split(/\r?\n/).find((line) => line.startsWith(`${serial} `))?.split(/\s+/)[1];
@@ -352,14 +500,18 @@ async function createAndroidEmulator(options) {
352
500
  const checkedAt = Date.now();
353
501
  if (booted && checkedAt <= deadline) {
354
502
  await recordBootResult("booted", checkedAt);
355
- return { serial, adb, env, dispose };
503
+ options.signal?.throwIfAborted();
504
+ return { serial, adb, env, dispose, closed };
356
505
  }
357
- await Bun.sleep(Math.min(2000, Math.max(0, deadline - Date.now())));
506
+ await delay(Math.min(2000, Math.max(0, deadline - Date.now())), undefined, { signal: options.signal });
358
507
  }
508
+ options.signal?.throwIfAborted();
359
509
  if (child.exitCode !== null)
360
510
  throw new Error(`The owned Android emulator exited with code ${child.exitCode} before boot; inspect ${join2(artifacts, "emulator.log")}`);
361
511
  throw new Error(`The owned Android emulator did not boot within ${bootTimeoutMs}ms; inspect ${artifacts}`);
362
512
  } catch (error) {
513
+ const timerAborted = options.signal?.aborted && error instanceof Error && error.name === "AbortError" && error.cause === options.signal.reason;
514
+ const failure = timerAborted ? options.signal.reason : options.signal?.aborted && error !== options.signal.reason ? new AggregateError([options.signal.reason, error], `Android emulator setup failed: ${String(options.signal.reason)}`) : error;
363
515
  if (child) {
364
516
  try {
365
517
  await recordBootResult("failed");
@@ -375,9 +527,9 @@ async function createAndroidEmulator(options) {
375
527
  try {
376
528
  await dispose();
377
529
  } catch (cleanup) {
378
- throw new AggregateError([error, cleanup], "Android emulator setup and cleanup failed");
530
+ throw new AggregateError([failure, cleanup], "Android emulator setup and cleanup failed");
379
531
  }
380
- throw error;
532
+ throw failure;
381
533
  }
382
534
  }
383
535
 
package/dist/cli.js CHANGED
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3047
3047
  `);
3048
3048
  }
3049
3049
  // package.json
3050
- var version = "0.0.420";
3050
+ var version = "0.0.424";
3051
3051
 
3052
3052
  // bin/cli.ts
3053
3053
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.420";
378
+ var version = "0.0.424";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.420";
120
+ var version = "0.0.424";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -43,7 +43,7 @@ import process from "process";
43
43
  var macosToolchain = {
44
44
  zig: "0.18.0-dev.252+86dc5ce25",
45
45
  sources: {
46
- "zig-js": { repository: "zig-utils/zig-js", revision: "fef92e7f6c5e110102df15e73bbea4a6be7493b5", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
46
+ "zig-js": { repository: "zig-utils/zig-js", revision: "a6739721273ae0f13d36fb3422052a588c5079a8", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
47
47
  "zig-regex": { repository: "zig-utils/zig-regex", revision: "fdd9c2e333ea6ac2fd06d080d939ef7f11d61b1b", paths: ["src"] },
48
48
  "zig-gc": { repository: "zig-utils/zig-gc", revision: "0285ef801dd221a4ae8e88b31a09ae31627657a3", paths: ["src"] }
49
49
  }
@@ -9,7 +9,7 @@ export declare const macosToolchain: {
9
9
  readonly sources: {
10
10
  readonly 'zig-js': {
11
11
  readonly repository: 'zig-utils/zig-js';
12
- readonly revision: 'fef92e7f6c5e110102df15e73bbea4a6be7493b5';
12
+ readonly revision: 'a6739721273ae0f13d36fb3422052a588c5079a8';
13
13
  readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
14
14
  };
15
15
  readonly 'zig-regex': {
@@ -277,6 +277,18 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
277
277
  var lastDropPosition = ""
278
278
  var acceptsDrops = false
279
279
  var wantedFocus = false
280
+ /// Measurements are pure in a box's subtree (its style, props, text and children) and the size
281
+ /// offered, but a flex pass asks for the same ones over and over: a column measures its children
282
+ /// twice, a row measures them again at their final widths, and each child's own layout measures its
283
+ /// subtree once more. Unremembered, that repeats exponentially with nesting depth (a browser window
284
+ /// spent most of its first render here). Every change to a box (its style, props, text, visibility
285
+ /// or children) arrives through `render`, which starts a new generation and so forgets every measurement.
286
+ static var measureGeneration = 0
287
+ /// Measurements computed rather than remembered; the layout checks hold it to the size of the tree.
288
+ static var measuredCount = 0
289
+ private struct MeasureKey: Hashable { let width: CGFloat, stretch: Bool }
290
+ private var measured: [MeasureKey: CGSize] = [:]
291
+ private var measuredGeneration = -1
280
292
  var componentRoot: CraftMacBox? { props["data-stx-component-wrapper"] != nil && flow?.boxes.count == 1 && flow?.boxes.first?.kind != "Modal" ? flow?.boxes.first : nil }
281
293
  var layoutStyle: [String: Any] {
282
294
  guard let child = componentRoot else { return style }
@@ -705,6 +717,15 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
705
717
  return NSEdgeInsets(top: number(style["paddingTop"]) ?? vertical, left: number(style["paddingLeft"]) ?? horizontal, bottom: number(style["paddingBottom"]) ?? vertical, right: number(style["paddingRight"]) ?? horizontal)
706
718
  }
707
719
  func measure(width: CGFloat, stretch: Bool = false) -> CGSize {
720
+ if measuredGeneration != CraftMacBox.measureGeneration || measured.count > 64 { measured.removeAll(keepingCapacity: true); measuredGeneration = CraftMacBox.measureGeneration }
721
+ let key = MeasureKey(width: width, stretch: stretch)
722
+ if let size = measured[key] { return size }
723
+ let size = measureSubtree(width: width, stretch: stretch)
724
+ measured[key] = size
725
+ return size
726
+ }
727
+ private func measureSubtree(width: CGFloat, stretch: Bool) -> CGSize {
728
+ CraftMacBox.measuredCount &+= 1
708
729
  if isHidden { return .zero }
709
730
  if let child = componentRoot { return child.measure(width: width, stretch: stretch) }
710
731
  let specifiedWidth = dimension(style["width"], base: width)
@@ -966,6 +987,8 @@ final class CraftMacRenderer {
966
987
  return box
967
988
  }
968
989
  let box = reconcile(document)
990
+ // Boxes were reconfigured and children replaced: nothing measured before now holds.
991
+ CraftMacBox.measureGeneration &+= 1
969
992
  for (id, box) in boxes where !retained.contains(id) {
970
993
  box.imageTask?.cancel(); box.removeFromSuperview(); boxes.removeValue(forKey: id)
971
994
  if let sheet = sheets.removeValue(forKey: id) { window?.endSheet(sheet); sheet.close() }
@@ -334,11 +334,20 @@ final class CraftMacContentRules {
334
334
  /// an isolated world the page's scripts cannot see; `world: 'page'` runs one in the page's own world,
335
335
  /// for a script that must wrap the page's `fetch` before the site does. CSS lands at document start,
336
336
  /// JS at document end unless `at: 'start'`.
337
- final class CraftMacUserScripts {
337
+ final class CraftMacUserScripts: NSObject, WKScriptMessageHandlerWithReply {
338
338
  static let shared = CraftMacUserScripts()
339
339
  private var scripts: [WKUserScript] = []
340
+ /// Message handlers the scripts post to (`bridge`), each in its script's content world.
341
+ private var bridges: [(name: String, world: WKContentWorld)] = []
340
342
 
341
343
  func set(_ entries: [[String: Any]]) {
344
+ var named: [(name: String, world: WKContentWorld)] = []
345
+ for entry in entries {
346
+ guard let name = entry["bridge"] as? String, !name.isEmpty else { continue }
347
+ let world: WKContentWorld = entry["world"] as? String == "page" ? .page : .defaultClient
348
+ if !named.contains(where: { $0.name == name && $0.world == world }) { named.append((name, world)) }
349
+ }
350
+ bridges = named
342
351
  scripts = entries.flatMap { entry -> [WKUserScript] in
343
352
  let hosts = (entry["hosts"] as? [String] ?? []).map { $0.lowercased() }.filter { !$0.isEmpty }
344
353
  let everySite = hosts.isEmpty || hosts.contains("*")
@@ -365,6 +374,25 @@ final class CraftMacUserScripts {
365
374
  func apply(to controller: WKUserContentController) {
366
375
  controller.removeAllUserScripts()
367
376
  scripts.forEach(controller.addUserScript)
377
+ controller.removeAllScriptMessageHandlers()
378
+ for bridge in bridges { controller.addScriptMessageHandler(self, contentWorld: bridge.world, name: bridge.name) }
379
+ }
380
+
381
+ /// A bridged script posted to its handler (`webkit.messageHandlers[name].postMessage(body)`).
382
+ /// The tab's screen answers with `answerBridge`; the script's promise resolves to that answer,
383
+ /// or rejects with its error. A tab no screen hears, or one that does not answer within 30
384
+ /// seconds, is answered with an error, so the page never waits forever.
385
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage, replyHandler: @escaping (Any?, String?) -> Void) {
386
+ guard let tab = CraftMacWebTabs.shared.tabs.values.first(where: { $0.view === message.webView }), tab.owner != nil else {
387
+ replyHandler(nil, "No screen is listening to this tab"); return
388
+ }
389
+ // A body WebKit can carry but JSON cannot (a Date) never reaches the screen.
390
+ guard JSONSerialization.isValidJSONObject(["body": message.body]) else { replyHandler(nil, "The message is not JSON"); return }
391
+ let request = CraftMacWebTabs.shared.awaitBridge(tab: tab, reply: replyHandler)
392
+ tab.emit("bridge", [
393
+ "request": request, "handler": message.name, "body": message.body,
394
+ "url": message.frameInfo.request.url?.absoluteString ?? "", "mainFrame": message.frameInfo.isMainFrame,
395
+ ])
368
396
  }
369
397
  }
370
398
 
@@ -478,8 +506,24 @@ final class CraftMacWebTabs {
478
506
  waiting.decide(decision); return true
479
507
  }
480
508
 
509
+ /// Bridge messages waiting for the screen, by request id.
510
+ private var bridgeReplies: [String: (tab: String, reply: (Any?, String?) -> Void)] = [:]
511
+ func awaitBridge(tab: CraftMacTab, reply: @escaping (Any?, String?) -> Void) -> String {
512
+ let request = UUID().uuidString
513
+ bridgeReplies[request] = (tab.id, reply)
514
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in self?.answerBridge(request, nil, error: "The app did not answer") }
515
+ return request
516
+ }
517
+ @discardableResult
518
+ func answerBridge(_ request: String, _ value: Any?, error: String?) -> Bool {
519
+ guard let waiting = bridgeReplies.removeValue(forKey: request) else { return false }
520
+ // JSON's null crosses as NSNull, which a reply carries as the page's `null`.
521
+ waiting.reply(error == nil ? (value is NSNull ? nil : value) : nil, error); return true
522
+ }
523
+
481
524
  func close(_ id: String) {
482
525
  for (request, waiting) in pending where waiting.tab == id { answer(request, .deny) }
526
+ for (request, waiting) in bridgeReplies where waiting.tab == id { answerBridge(request, nil, error: "The tab closed") }
483
527
  tabs.removeValue(forKey: id)?.close()
484
528
  }
485
529
 
@@ -568,6 +612,10 @@ final class CraftMacWebTabs {
568
612
  case "answerPermission":
569
613
  let decision: WKPermissionDecision = { switch args.dropFirst().first as? String { case "grant": return .grant; case "prompt": return .prompt; default: return .deny } }()
570
614
  answer(self.answer(args.first as? String ?? "", decision)); return
615
+ case "answerBridge":
616
+ // [request, value] answers; [request, null, error] rejects the page's promise with `error`.
617
+ let error = args.dropFirst(2).first as? String
618
+ answer(answerBridge(args.first as? String ?? "", args.dropFirst().first, error: error)); return
571
619
  case "clearData": clearData(options, answer: answer); return
572
620
  case "setCookies": setCookies(options, answer: answer); return
573
621
  case "cancelDownload": answer(CraftMacDownloads.shared.cancel(args.first as? String ?? "")); return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.420",
3
+ "version": "0.0.424",
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>",