craft-native 0.0.418 → 0.0.423

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.418";
3050
+ var version = "0.0.423";
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.418";
378
+ var version = "0.0.423";
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.418";
120
+ var version = "0.0.423";
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: "a9f246db9fa0445b32a9c9d87f3be01d33fd782d", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
46
+ "zig-js": { repository: "zig-utils/zig-js", revision: "8caf888138b04d6040dfc89567c1ae37bbc7d5fe", 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: 'a9f246db9fa0445b32a9c9d87f3be01d33fd782d';
12
+ readonly revision: '8caf888138b04d6040dfc89567c1ae37bbc7d5fe';
13
13
  readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
14
14
  };
15
15
  readonly 'zig-regex': {
@@ -73,6 +73,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
73
73
  func windowWillClose(_ notification: Notification) {
74
74
  runtime.dispose()
75
75
  CraftMacApplication.shared.scenes.removeValue(forKey: name)
76
+ // The app's other windows learn it is gone (its tabs closed with it), so none waits on it.
77
+ for other in CraftMacApplication.shared.scenes.values { other.runtime.send("CRAFT_EVENT", ["event": "craftDesktopWindowClosed", "detail": ["name": name]]) }
76
78
  CraftMacApplication.shared.quitReady(scene: name)
77
79
  }
78
80
  func windowDidBecomeKey(_ notification: Notification) { runtime.send("SCREEN_FOCUS", ["focused": true]) }
@@ -419,6 +421,22 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
419
421
  // After this result is written, from the run loop rather than a main-queue block (see applicationShouldTerminate).
420
422
  if kind == "quit" { NSApp.perform(#selector(NSApplication.terminate(_:)), with: nil, afterDelay: 0); return ["ok": true] }
421
423
  if kind == "openURL", let url = (action["url"] as? String).flatMap(URL.init(string:)) { application(NSApp, open: [url]); return ["ok": true] }
424
+ #if CRAFT_WEBKIT
425
+ // Script in a tab's page, as a person's click or a page's own code would act: what the
426
+ // renderer cannot reach (a link inside the page, an element to zap). `value` is the
427
+ // script's result as text; return JSON.stringify(...) for anything structured.
428
+ if kind == "web", let id = action["tab"] as? String, let script = action["js"] as? String {
429
+ guard let tab = CraftMacWebTabs.shared.tabs[id] else { return ["ok": false, "error": "No tab \(id)"] }
430
+ var answer: [String: Any]?
431
+ tab.view.evaluateJavaScript(script) { value, error in
432
+ if let error { answer = ["ok": false, "error": error.localizedDescription]; return }
433
+ answer = ["ok": true, "value": value.map { "\($0)" } ?? NSNull()]
434
+ }
435
+ let deadline = Date().addingTimeInterval(5)
436
+ while answer == nil && Date() < deadline { RunLoop.main.run(until: Date().addingTimeInterval(0.02)) }
437
+ return answer ?? ["ok": false, "error": "The page did not answer within 5 s"]
438
+ }
439
+ #endif
422
440
  if kind == "appearance" {
423
441
  NSApp.appearance = NSAppearance(named: action["value"] as? String == "dark" ? .darkAqua : .aqua)
424
442
  appearanceChanged(); return ["ok": true]
@@ -68,7 +68,13 @@ final class CraftMacRuntime {
68
68
  #endif
69
69
  return list
70
70
  }
71
- let context = ZigJSContext()!
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
+ }()
72
78
  let route: String
73
79
  let network: CraftMacNetwork
74
80
  weak var scene: CraftMacScene?
@@ -40,8 +40,17 @@ final class CraftMacWebProfiles {
40
40
  return configuration.copy() as! WKWebViewConfiguration
41
41
  }
42
42
 
43
+ /// One store per private profile for as long as the app runs, so clearing data or adding
44
+ /// cookies reaches the store its tabs use (each `.nonPersistent()` call makes a new, empty one).
45
+ private var ephemeralStores: [String: WKWebsiteDataStore] = [:]
46
+
43
47
  func store(profile: String, ephemeral: Bool) -> WKWebsiteDataStore {
44
- if ephemeral { return .nonPersistent() }
48
+ if ephemeral {
49
+ if let existing = ephemeralStores[profile] { return existing }
50
+ let made = WKWebsiteDataStore.nonPersistent()
51
+ ephemeralStores[profile] = made
52
+ return made
53
+ }
45
54
  if profile == "default" { return .default() }
46
55
  if #available(macOS 14.0, *) { return WKWebsiteDataStore(forIdentifier: Self.identifier(profile)) }
47
56
  return .default()
@@ -325,11 +334,20 @@ final class CraftMacContentRules {
325
334
  /// an isolated world the page's scripts cannot see; `world: 'page'` runs one in the page's own world,
326
335
  /// for a script that must wrap the page's `fetch` before the site does. CSS lands at document start,
327
336
  /// JS at document end unless `at: 'start'`.
328
- final class CraftMacUserScripts {
337
+ final class CraftMacUserScripts: NSObject, WKScriptMessageHandlerWithReply {
329
338
  static let shared = CraftMacUserScripts()
330
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)] = []
331
342
 
332
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
333
351
  scripts = entries.flatMap { entry -> [WKUserScript] in
334
352
  let hosts = (entry["hosts"] as? [String] ?? []).map { $0.lowercased() }.filter { !$0.isEmpty }
335
353
  let everySite = hosts.isEmpty || hosts.contains("*")
@@ -356,6 +374,25 @@ final class CraftMacUserScripts {
356
374
  func apply(to controller: WKUserContentController) {
357
375
  controller.removeAllUserScripts()
358
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
+ ])
359
396
  }
360
397
  }
361
398
 
@@ -469,8 +506,24 @@ final class CraftMacWebTabs {
469
506
  waiting.decide(decision); return true
470
507
  }
471
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
+
472
524
  func close(_ id: String) {
473
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") }
474
527
  tabs.removeValue(forKey: id)?.close()
475
528
  }
476
529
 
@@ -494,6 +547,38 @@ final class CraftMacWebTabs {
494
547
  }
495
548
  }
496
549
 
550
+ /// Cookies put into a profile's store, as another browser's import brings them: `domain` with
551
+ /// a leading dot covers subdomains, `expires` is milliseconds since 1970 (absent for a session
552
+ /// cookie), `sameSite` is "lax", "strict" or "none". Answers how many were set, and how many
553
+ /// could not form a cookie (no name or domain).
554
+ func setCookies(_ options: [String: Any], answer: @escaping (Any) -> Void) {
555
+ let store = CraftMacWebProfiles.shared.store(profile: options["profile"] as? String ?? "default", ephemeral: options["private"] as? Bool == true).httpCookieStore
556
+ let cookies = (options["cookies"] as? [[String: Any]] ?? []).map(Self.cookie)
557
+ let group = DispatchGroup()
558
+ for case let cookie? in cookies {
559
+ group.enter()
560
+ store.setCookie(cookie) { group.leave() }
561
+ }
562
+ let set = cookies.compactMap { $0 }.count
563
+ group.notify(queue: .main) { answer(["set": set, "skipped": cookies.count - set]) }
564
+ }
565
+
566
+ static func cookie(_ fields: [String: Any]) -> HTTPCookie? {
567
+ guard let name = fields["name"] as? String, !name.isEmpty, let domain = fields["domain"] as? String, !domain.isEmpty else { return nil }
568
+ var properties: [HTTPCookiePropertyKey: Any] = [
569
+ .name: name, .value: fields["value"] as? String ?? "", .domain: domain, .path: fields["path"] as? String ?? "/",
570
+ ]
571
+ if let expires = fields["expires"] as? Double { properties[.expires] = Date(timeIntervalSince1970: expires / 1000) }
572
+ if fields["secure"] as? Bool == true { properties[.secure] = "TRUE" }
573
+ if fields["httpOnly"] as? Bool == true { properties[HTTPCookiePropertyKey("HttpOnly")] = "TRUE" }
574
+ switch fields["sameSite"] as? String {
575
+ case "lax": properties[.sameSitePolicy] = HTTPCookieStringPolicy.sameSiteLax
576
+ case "strict": properties[.sameSitePolicy] = HTTPCookieStringPolicy.sameSiteStrict
577
+ default: break
578
+ }
579
+ return HTTPCookie(properties: properties)
580
+ }
581
+
497
582
  /// A closed window takes its tabs with it.
498
583
  func closeAll(owner: CraftMacRuntime) {
499
584
  for (id, tab) in tabs where tab.owner == nil || tab.owner === owner { tabs.removeValue(forKey: id); tab.close() }
@@ -502,6 +587,9 @@ final class CraftMacWebTabs {
502
587
  func perform(_ method: String, _ args: [Any], owner: CraftMacRuntime, answer: @escaping (Any) -> Void, fail: @escaping (String, String) -> Void) {
503
588
  let options = args.first as? [String: Any] ?? [:]
504
589
  if method == "create" {
590
+ // A tab that exists is answered as it is: loading the address again would reload a
591
+ // page that another window, say, hands over with its state intact.
592
+ if let id = options["id"] as? String, let existing = tabs[id] { answer(existing.state); return }
505
593
  let tab = make(id: options["id"] as? String, profile: options["profile"] as? String ?? "default", ephemeral: options["private"] as? Bool == true, owner: owner)
506
594
  tab.view.isInspectable = options["inspectable"] as? Bool ?? true
507
595
  if let address = options["url"] as? String, !address.isEmpty {
@@ -524,7 +612,12 @@ final class CraftMacWebTabs {
524
612
  case "answerPermission":
525
613
  let decision: WKPermissionDecision = { switch args.dropFirst().first as? String { case "grant": return .grant; case "prompt": return .prompt; default: return .deny } }()
526
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
527
619
  case "clearData": clearData(options, answer: answer); return
620
+ case "setCookies": setCookies(options, answer: answer); return
528
621
  case "cancelDownload": answer(CraftMacDownloads.shared.cancel(args.first as? String ?? "")); return
529
622
  case "revealDownload", "openDownload":
530
623
  guard let file = CraftMacDownloads.downloadedFile(args.first as? String ?? "") else { fail("NOT_FOUND", "Not a file in Downloads"); return }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.418",
3
+ "version": "0.0.423",
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>",