craft-native 0.0.420 → 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.
- package/dist/android/src/emulator.d.ts +4 -0
- package/dist/android/src/index.d.ts +4 -0
- package/dist/android/src/index.js +177 -25
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/macos/src/index.js +1 -1
- package/dist/macos/src/toolchain.d.ts +1 -1
- package/dist/macos/templates/CraftMacWebView.swift +49 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
286
|
+
async function disposeGroup(pid, graceMs) {
|
|
287
|
+
const errors = [];
|
|
288
|
+
if (!groupExists(pid))
|
|
289
|
+
return errors;
|
|
290
|
+
let termSent = false;
|
|
267
291
|
try {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
503
|
+
options.signal?.throwIfAborted();
|
|
504
|
+
return { serial, adb, env, dispose, closed };
|
|
356
505
|
}
|
|
357
|
-
await
|
|
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([
|
|
530
|
+
throw new AggregateError([failure, cleanup], "Android emulator setup and cleanup failed");
|
|
379
531
|
}
|
|
380
|
-
throw
|
|
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.
|
|
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
package/dist/index.js
CHANGED
package/dist/macos/src/index.js
CHANGED
|
@@ -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: "
|
|
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: '
|
|
12
|
+
readonly revision: '8caf888138b04d6040dfc89567c1ae37bbc7d5fe';
|
|
13
13
|
readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
|
|
14
14
|
};
|
|
15
15
|
readonly 'zig-regex': {
|
|
@@ -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
|