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.
- 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/CraftMacApplication.swift +18 -0
- package/dist/macos/templates/CraftMacRuntime.swift +7 -1
- package/dist/macos/templates/CraftMacWebView.swift +95 -2
- 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': {
|
|
@@ -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
|
-
|
|
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 {
|
|
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 }
|