craft-native 0.0.186 → 0.0.189

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.
@@ -0,0 +1,705 @@
1
+ // src/browser/storage.ts
2
+ async function createBrowserStorage(options) {
3
+ const { storage, namespace, tokenKey, changed } = options;
4
+ const prefix = `${namespace}:`;
5
+ let token = storage.getItem(tokenKey);
6
+ let scope = token ? await options.hash(token) : "guest";
7
+ let transitioning = false;
8
+ let revision = 0;
9
+ let transition = null;
10
+ const available = () => !transitioning && storage.getItem(tokenKey) === token;
11
+ const key = (name) => `${prefix}account:${scope}:${name}`;
12
+ const snapshotKey = (name) => name === "app" ? `${prefix}app` : key(`snapshot:${name}`);
13
+ const keys = (base) => Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter((value) => value !== null && value.startsWith(base));
14
+ const read = (name) => {
15
+ if (!available())
16
+ return null;
17
+ const value = storage.getItem(key(`storage:${name}`));
18
+ if (value === null)
19
+ return null;
20
+ try {
21
+ return JSON.parse(value);
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+ const accountChanged = async () => {
27
+ const next = storage.getItem(tokenKey);
28
+ if (next === token) {
29
+ await transition;
30
+ return;
31
+ }
32
+ const previous = scope;
33
+ const current = ++revision;
34
+ transitioning = true;
35
+ for (const name of keys(`${prefix}account:${previous}:`))
36
+ storage.removeItem(name);
37
+ for (const name of options.accountKeys ?? []) {
38
+ if (name !== tokenKey)
39
+ storage.removeItem(name);
40
+ }
41
+ token = next;
42
+ transition = (async () => {
43
+ const nextScope = next ? await options.hash(next) : "guest";
44
+ if (current !== revision)
45
+ return;
46
+ scope = nextScope;
47
+ transitioning = false;
48
+ changed("session");
49
+ })();
50
+ await transition;
51
+ };
52
+ const secureKey = (name) => name === "auth.token" ? tokenKey : key(`credential:${name}`);
53
+ return {
54
+ scope: () => scope,
55
+ accountChanged,
56
+ craft: {
57
+ storage: { getSync: read, setSync: (name, value) => {
58
+ if (available())
59
+ storage.setItem(key(`storage:${name}`), JSON.stringify(value));
60
+ } },
61
+ secureStorage: { getSync: (name) => name === "auth.token" || available() ? storage.getItem(secureKey(name)) : null },
62
+ snapshots: {
63
+ get: (name) => {
64
+ if (!available() && name !== "app")
65
+ return null;
66
+ const value = storage.getItem(snapshotKey(name));
67
+ if (value === null)
68
+ return null;
69
+ try {
70
+ return JSON.parse(value);
71
+ } catch {
72
+ return value;
73
+ }
74
+ },
75
+ async set(name, value) {
76
+ if (name !== "app" && !available())
77
+ return false;
78
+ storage.setItem(snapshotKey(name), typeof value === "string" ? value : JSON.stringify(value));
79
+ changed(name);
80
+ return true;
81
+ },
82
+ async clear(keep = []) {
83
+ if (!available())
84
+ return false;
85
+ for (const name of keys(key("snapshot:"))) {
86
+ const label = name.slice(key("snapshot:").length);
87
+ if (!keep.includes(label)) {
88
+ storage.removeItem(name);
89
+ changed(label);
90
+ }
91
+ }
92
+ if (!keep.includes("app"))
93
+ storage.removeItem(snapshotKey("app"));
94
+ return true;
95
+ },
96
+ keys: () => available() ? keys(key("snapshot:")).map((name) => name.slice(key("snapshot:").length)) : []
97
+ }
98
+ },
99
+ async call(module, method, args) {
100
+ await accountChanged();
101
+ const name = String(args[0] ?? "");
102
+ if (module === "Storage") {
103
+ if (method === "get")
104
+ return read(name);
105
+ if (method === "set") {
106
+ storage.setItem(key(`storage:${name}`), JSON.stringify(args[1]));
107
+ return true;
108
+ }
109
+ if (method === "remove") {
110
+ storage.removeItem(key(`storage:${name}`));
111
+ return true;
112
+ }
113
+ if (method === "keys")
114
+ return keys(key("storage:")).map((item) => item.slice(key("storage:").length));
115
+ if (method === "clear") {
116
+ for (const item of keys(key("storage:")))
117
+ storage.removeItem(item);
118
+ return true;
119
+ }
120
+ }
121
+ if (module === "SecureStorage") {
122
+ if (method === "get")
123
+ return storage.getItem(secureKey(name));
124
+ if (method === "set") {
125
+ storage.setItem(secureKey(name), String(args[1]));
126
+ if (name === "auth.token")
127
+ await accountChanged();
128
+ return true;
129
+ }
130
+ if (method === "remove") {
131
+ storage.removeItem(secureKey(name));
132
+ if (name === "auth.token")
133
+ await accountChanged();
134
+ return true;
135
+ }
136
+ if (method === "clear") {
137
+ for (const item of keys(key("credential:")))
138
+ storage.removeItem(item);
139
+ storage.removeItem(tokenKey);
140
+ await accountChanged();
141
+ return true;
142
+ }
143
+ }
144
+ throw Object.assign(new Error(`Unsupported browser storage operation ${module}.${method}`), { code: "UNSUPPORTED" });
145
+ }
146
+ };
147
+ }
148
+
149
+ // src/browser/dialogs.ts
150
+ function showBrowserDialog(kind, options, signal) {
151
+ return new Promise((resolve, reject) => {
152
+ if (signal.aborted) {
153
+ reject(signal.reason);
154
+ return;
155
+ }
156
+ const previous = document.activeElement;
157
+ const dialog = document.createElement("dialog");
158
+ dialog.style.cssText = "font:16px system-ui;max-width:min(400px,90vw);padding:24px;border:1px solid #8885;border-radius:16px;color:CanvasText;background:Canvas;box-shadow:0 16px 64px #0004";
159
+ dialog.setAttribute("aria-modal", "true");
160
+ const title = document.createElement("h2");
161
+ title.id = `craft-dialog-${crypto.randomUUID()}`;
162
+ title.textContent = String(options.title ?? "");
163
+ title.style.cssText = "font-size:20px;margin:0 0 12px";
164
+ dialog.append(title);
165
+ dialog.setAttribute("aria-labelledby", title.id);
166
+ if (options.message) {
167
+ const message = document.createElement("p");
168
+ message.textContent = options.message;
169
+ dialog.append(message);
170
+ }
171
+ const finish = (answer) => {
172
+ signal.removeEventListener("abort", abort);
173
+ dialog.close();
174
+ dialog.remove();
175
+ previous?.focus();
176
+ resolve(answer);
177
+ };
178
+ const abort = () => {
179
+ dialog.close();
180
+ dialog.remove();
181
+ previous?.focus();
182
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
183
+ };
184
+ signal.addEventListener("abort", abort, { once: true });
185
+ const actions = options.buttons ?? options.actions ?? [];
186
+ const cancelAction = actions.find((item) => item.style === "cancel");
187
+ const buttons = kind === "actionSheet" ? actions.filter((item) => item.style !== "cancel") : [{ id: "confirm", title: options.confirmLabel ?? "OK" }];
188
+ const controls = document.createElement("div");
189
+ controls.style.cssText = "display:flex;flex-direction:column;gap:8px;margin-top:20px";
190
+ for (const item of buttons) {
191
+ const button = document.createElement("button");
192
+ button.type = "button";
193
+ button.textContent = String(item.title ?? item.label ?? item.id);
194
+ button.style.cssText = `font:inherit;min-height:44px;border:1px solid #8885;border-radius:8px;background:transparent;color:${item.style === "destructive" ? "#e11d48" : "inherit"}`;
195
+ button.onclick = () => finish(kind === "actionSheet" ? item.id : kind === "confirm" ? true : undefined);
196
+ controls.append(button);
197
+ }
198
+ if (kind !== "alert") {
199
+ const cancel = document.createElement("button");
200
+ cancel.type = "button";
201
+ cancel.textContent = options.cancelLabel ?? cancelAction?.title ?? cancelAction?.label ?? "Cancel";
202
+ cancel.style.cssText = "font:inherit;min-height:44px;border:0;background:none;color:inherit";
203
+ cancel.onclick = () => finish(kind === "confirm" ? false : undefined);
204
+ controls.append(cancel);
205
+ }
206
+ dialog.addEventListener("cancel", (event) => {
207
+ event.preventDefault();
208
+ finish(kind === "confirm" ? false : undefined);
209
+ });
210
+ dialog.append(controls);
211
+ document.body.append(dialog);
212
+ dialog.showModal();
213
+ });
214
+ }
215
+
216
+ // src/browser.ts
217
+ var object = (value) => value !== null && typeof value === "object" ? value : {};
218
+ var unsupported = (module, method) => {
219
+ throw Object.assign(new Error(`${module}.${method} is not available in this browser`), { code: "UNSUPPORTED" });
220
+ };
221
+ async function createBrowserPlatform(options = {}) {
222
+ const namespace = options.namespace ?? "craft";
223
+ const protocols = new Set(["https:", "http:", "mailto:", "tel:", "webcal:", ...(options.urlSchemes ?? []).filter((name) => /^[a-z][a-z\d+.-]*$/i.test(name) && !/^(?:javascript|data|vbscript|file)$/i.test(name)).map((name) => name + ":")]);
224
+ const listeners = new Set;
225
+ const emit = (type, payload) => {
226
+ for (const listener of listeners)
227
+ listener(type, payload);
228
+ };
229
+ const persistence = await createBrowserStorage({
230
+ storage: localStorage,
231
+ namespace,
232
+ tokenKey: options.tokenKey ?? "craft.auth.token",
233
+ accountKeys: options.accountKeys,
234
+ hash: async (value) => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))), (byte) => byte.toString(16).padStart(2, "0")).join(""),
235
+ changed: (name) => emit("SNAPSHOT_CHANGED", { name })
236
+ });
237
+ await persistence.craft.snapshots.set("app", { apiBase: `${location.origin}/api`, locale: options.locale ?? (navigator.language.startsWith("de") ? "de" : "en") });
238
+ const files = new Map;
239
+ const mediaURLs = new Map;
240
+ const sounds = new Map;
241
+ const fades = new Map;
242
+ const cancelFade = (sound) => {
243
+ const fade2 = fades.get(sound);
244
+ if (fade2) {
245
+ cancelAnimationFrame(fade2.frame);
246
+ fades.delete(sound);
247
+ fade2.done();
248
+ }
249
+ };
250
+ const fade = (sound, volume, milliseconds) => {
251
+ cancelFade(sound);
252
+ const to = Math.max(0, Math.min(1, volume));
253
+ if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
254
+ sound.volume = to;
255
+ return Promise.resolve();
256
+ }
257
+ const from = sound.volume;
258
+ const start = performance.now();
259
+ return new Promise((resolve) => {
260
+ const tick = (now) => {
261
+ const progress = Math.min(1, (now - start) / milliseconds);
262
+ sound.volume = from + (to - from) * progress;
263
+ if (progress < 1)
264
+ fades.set(sound, { frame: requestAnimationFrame(tick), done: resolve });
265
+ else {
266
+ fades.delete(sound);
267
+ resolve();
268
+ }
269
+ };
270
+ fades.set(sound, { frame: requestAnimationFrame(tick), done: resolve });
271
+ });
272
+ };
273
+ const stopSound = async (id, milliseconds = 0) => {
274
+ const sound = sounds.get(id);
275
+ if (!sound)
276
+ return;
277
+ await fade(sound, 0, sound.paused ? 0 : milliseconds);
278
+ if (sounds.get(id) !== sound)
279
+ return;
280
+ sound.pause();
281
+ sound.removeAttribute("src");
282
+ sounds.delete(id);
283
+ };
284
+ const lifetime = new AbortController;
285
+ let account = new AbortController;
286
+ let mediaScope = persistence.scope();
287
+ let wakeLock = null;
288
+ let disposed = false;
289
+ const cacheName = () => `${namespace}:media:${persistence.scope()}`;
290
+ const file = (body, name, type) => {
291
+ const created = new File(body, name, { type });
292
+ created.path = URL.createObjectURL(created);
293
+ files.set(created.path, created);
294
+ return { path: created.path, name: created.name, type: created.type, size: created.size };
295
+ };
296
+ const getFile = (value) => value instanceof File ? value : files.get(typeof value === "string" ? value : String(object(value).path ?? "")) ?? null;
297
+ const navigate = (path, replace = false) => {
298
+ const url = new URL(path, location.href);
299
+ if (!protocols.has(url.protocol))
300
+ throw new TypeError("Unsupported navigation URL");
301
+ if (url.origin === location.origin && !url.pathname.startsWith("/"))
302
+ throw new TypeError("Invalid application path");
303
+ if (replace)
304
+ location.replace(url.href);
305
+ else
306
+ location.assign(url.href);
307
+ };
308
+ const resetAccount = async () => {
309
+ const previousCache = `${namespace}:media:${mediaScope}`;
310
+ account.abort();
311
+ account = new AbortController;
312
+ for (const path of files.keys())
313
+ URL.revokeObjectURL(path);
314
+ for (const path of mediaURLs.values())
315
+ URL.revokeObjectURL(path);
316
+ files.clear();
317
+ mediaURLs.clear();
318
+ for (const audio of sounds.values()) {
319
+ cancelFade(audio);
320
+ audio.pause();
321
+ audio.src = "";
322
+ }
323
+ sounds.clear();
324
+ await persistence.accountChanged();
325
+ mediaScope = persistence.scope();
326
+ if ("caches" in window && previousCache !== cacheName())
327
+ await caches.delete(previousCache);
328
+ };
329
+ const storageEvent = (event) => {
330
+ if (event.key === (options.tokenKey ?? "craft.auth.token") || event.key === null)
331
+ resetAccount().then(() => {
332
+ if (!disposed)
333
+ location.reload();
334
+ });
335
+ };
336
+ window.addEventListener("storage", storageEvent);
337
+ const capabilityNames = ["storage", "secureStorage", "lifecycle", "dialog", "browser", "linking", "files", "share", "device"];
338
+ if (navigator.clipboard)
339
+ capabilityNames.push("clipboard");
340
+ if ("vibrate" in navigator)
341
+ capabilityNames.push("haptics");
342
+ if ("speechSynthesis" in window)
343
+ capabilityNames.push("speech");
344
+ if ("wakeLock" in navigator)
345
+ capabilityNames.push("keepAwake");
346
+ if ("caches" in window)
347
+ capabilityNames.push("media");
348
+ if (typeof Audio === "function")
349
+ capabilityNames.push("audio");
350
+ const platform = {
351
+ craft: { ...persistence.craft },
352
+ capabilities: capabilityNames,
353
+ navigate,
354
+ emit(listener) {
355
+ listeners.add(listener);
356
+ return () => listeners.delete(listener);
357
+ },
358
+ resolveFile(value) {
359
+ if (!value || typeof value !== "object")
360
+ return value;
361
+ const saved = getFile(value);
362
+ return saved ? Object.assign(saved, Object.fromEntries(Object.entries(value).filter(([key]) => !["name", "size", "type"].includes(key)))) : value;
363
+ },
364
+ async resolveMedia(url) {
365
+ if (!("caches" in window))
366
+ return url;
367
+ const scope = persistence.scope();
368
+ const existing = mediaURLs.get(url);
369
+ if (existing)
370
+ return existing;
371
+ const saved = await (await caches.open(cacheName())).match(url);
372
+ if (!saved)
373
+ return url;
374
+ const local = URL.createObjectURL(await saved.blob());
375
+ if (disposed || scope !== persistence.scope()) {
376
+ URL.revokeObjectURL(local);
377
+ return url;
378
+ }
379
+ mediaURLs.set(url, local);
380
+ return local;
381
+ },
382
+ async call(module, method, args, signal) {
383
+ if (disposed || signal.aborted)
384
+ throw new DOMException("Aborted", "AbortError");
385
+ signal = AbortSignal.any([signal, lifetime.signal, account.signal]);
386
+ const first = object(args[0]);
387
+ if (module === "Storage" || module === "SecureStorage") {
388
+ const before = persistence.craft.secureStorage.getSync("auth.token");
389
+ const answer = await persistence.call(module, method, args);
390
+ if (before !== persistence.craft.secureStorage.getSync("auth.token"))
391
+ await resetAccount();
392
+ return answer;
393
+ }
394
+ if (module === "Dialog")
395
+ return showBrowserDialog(method, first, signal);
396
+ if (module === "Device" && method === "getInfo")
397
+ return { platform: "web", userAgent: navigator.userAgent, language: navigator.language, native: false };
398
+ if (module === "Haptics") {
399
+ if (typeof navigator.vibrate !== "function")
400
+ return unsupported(module, method);
401
+ navigator.vibrate(method === "selection" ? 5 : 10);
402
+ return true;
403
+ }
404
+ if (module === "Clipboard") {
405
+ if (!navigator.clipboard)
406
+ return unsupported(module, method);
407
+ if (method === "write") {
408
+ await navigator.clipboard.writeText(String(args[0]));
409
+ return true;
410
+ }
411
+ if (method === "read")
412
+ return navigator.clipboard.readText();
413
+ }
414
+ if (module === "Linking") {
415
+ const url = new URL(String(args[0]), location.href);
416
+ const allowed = protocols.has(url.protocol);
417
+ if (method === "canOpenURL")
418
+ return allowed;
419
+ if (method === "openURL" && allowed) {
420
+ navigate(url.href);
421
+ return true;
422
+ }
423
+ return false;
424
+ }
425
+ if (module === "Browser" && method === "open") {
426
+ const url = new URL(String(args[0]), location.href);
427
+ if (!["https:", "http:"].includes(url.protocol))
428
+ throw new TypeError("Browser URLs must use HTTP or HTTPS");
429
+ const popup = window.open("about:blank", "_blank");
430
+ if (!popup)
431
+ throw Object.assign(new Error("Allow popups to open this page"), { code: "POPUP_BLOCKED" });
432
+ popup.opener = null;
433
+ popup.location.replace(url.href);
434
+ return { opened: true };
435
+ }
436
+ if (module === "Files") {
437
+ if (method === "pickImage" || method === "pickVideo")
438
+ return new Promise((resolve, reject) => {
439
+ const input = document.createElement("input");
440
+ input.type = "file";
441
+ input.accept = method === "pickVideo" ? "video/*" : "image/*";
442
+ input.style.display = "none";
443
+ if (first.source === "camera")
444
+ input.setAttribute("capture", "environment");
445
+ const done = (value) => {
446
+ signal.removeEventListener("abort", abort);
447
+ input.remove();
448
+ resolve(value);
449
+ };
450
+ const abort = () => {
451
+ signal.removeEventListener("abort", abort);
452
+ input.remove();
453
+ reject(signal.reason);
454
+ };
455
+ signal.addEventListener("abort", abort, { once: true });
456
+ input.addEventListener("cancel", () => done(null), { once: true });
457
+ input.onchange = () => {
458
+ const chosen = input.files?.[0];
459
+ if (chosen && typeof first.maxBytes === "number" && chosen.size > first.maxBytes) {
460
+ signal.removeEventListener("abort", abort);
461
+ input.remove();
462
+ reject(Object.assign(new Error("The selected video is too large"), { code: "FILE_TOO_LARGE" }));
463
+ return;
464
+ }
465
+ done(chosen ? file([chosen], chosen.name, chosen.type) : null);
466
+ };
467
+ document.body.append(input);
468
+ input.click();
469
+ });
470
+ if (method === "download") {
471
+ const settings = object(args[1]);
472
+ const response = await fetch(String(args[0]), { headers: settings.headers, signal, cache: "no-store" });
473
+ if (!response.ok)
474
+ return { ok: false, status: response.status, body: await response.text() };
475
+ const body = await response.blob();
476
+ signal.throwIfAborted();
477
+ const disposition = response.headers.get("content-disposition") ?? "";
478
+ const name = typeof settings.name === "string" ? settings.name : /filename="?([^";]+)/i.exec(disposition)?.[1] ?? "download";
479
+ return Object.assign(file([body], name, body.type), { ok: true, status: response.status });
480
+ }
481
+ if (method === "write") {
482
+ const settings = object(args[2]);
483
+ const contents = settings.encoding === "base64" ? Uint8Array.from(atob(String(args[1])), (letter) => letter.charCodeAt(0)) : String(args[1]);
484
+ return file([contents], String(args[0]), String(settings.type ?? "application/octet-stream"));
485
+ }
486
+ if (method === "remove") {
487
+ const path = String(args[0]);
488
+ const found = files.delete(path);
489
+ if (found)
490
+ URL.revokeObjectURL(path);
491
+ return found;
492
+ }
493
+ }
494
+ if (module === "Share" && method === "share") {
495
+ const shared = Array.isArray(first.files) ? first.files.map(getFile).filter((value) => value !== null) : [];
496
+ if (typeof first.image === "string") {
497
+ const response = await fetch(first.image, { signal });
498
+ if (!response.ok)
499
+ throw new Error(`Image download failed (${response.status})`);
500
+ const image = await response.blob();
501
+ signal.throwIfAborted();
502
+ const name = /filename="?([^";]+)/i.exec(response.headers.get("content-disposition") ?? "")?.[1] ?? `image.${image.type === "image/png" ? "png" : "jpg"}`;
503
+ shared.push(new File([image], name, { type: image.type }));
504
+ }
505
+ const url = typeof first.url === "string" ? first.url : undefined;
506
+ const data = { title: typeof first.title === "string" ? first.title : undefined, text: typeof first.text === "string" ? first.text : undefined, ...shared.length ? { files: shared } : {}, ...url ? { url } : {} };
507
+ const native = typeof navigator.share === "function" && (!navigator.canShare || navigator.canShare(data));
508
+ const answer = await showBrowserDialog("actionSheet", { title: String(first.title ?? "Share"), buttons: [...native ? [{ id: "share", title: "Share…" }] : [], ...shared.length ? [{ id: "download", title: "Save file" }] : [], ...url && navigator.clipboard ? [{ id: "copy", title: "Copy link" }] : []] }, signal);
509
+ if (answer === "share") {
510
+ try {
511
+ await navigator.share(data);
512
+ return true;
513
+ } catch (error) {
514
+ if (error instanceof Error && error.name === "AbortError")
515
+ return false;
516
+ throw error;
517
+ }
518
+ }
519
+ if (answer === "copy" && url) {
520
+ await navigator.clipboard.writeText(url);
521
+ return true;
522
+ }
523
+ if (answer === "download") {
524
+ for (const item of shared) {
525
+ const link = document.createElement("a");
526
+ const local = URL.createObjectURL(item);
527
+ link.href = local;
528
+ link.download = item.name;
529
+ document.body.append(link);
530
+ link.click();
531
+ link.remove();
532
+ setTimeout(() => URL.revokeObjectURL(local), 1000);
533
+ }
534
+ return true;
535
+ }
536
+ return false;
537
+ }
538
+ if (module === "KeepAwake" && method === "set") {
539
+ if (!("wakeLock" in navigator))
540
+ return unsupported(module, method);
541
+ if (args[0] !== false) {
542
+ await wakeLock?.release();
543
+ wakeLock = await navigator.wakeLock.request("screen");
544
+ } else {
545
+ await wakeLock?.release();
546
+ wakeLock = null;
547
+ }
548
+ return true;
549
+ }
550
+ if (module === "Speech") {
551
+ if (!("speechSynthesis" in window))
552
+ return unsupported(module, method);
553
+ if (method === "stop") {
554
+ speechSynthesis.cancel();
555
+ return true;
556
+ }
557
+ if (method === "speak")
558
+ return new Promise((resolve, reject) => {
559
+ const utterance = new SpeechSynthesisUtterance(String(args[0]));
560
+ const settings = object(args[1]);
561
+ utterance.lang = String(settings.language ?? settings.lang ?? options.locale ?? navigator.language);
562
+ if (typeof settings.rate === "number")
563
+ utterance.rate = settings.rate;
564
+ utterance.onend = () => {
565
+ signal.removeEventListener("abort", abort);
566
+ resolve(true);
567
+ };
568
+ utterance.onerror = (event) => {
569
+ signal.removeEventListener("abort", abort);
570
+ if (event.error === "canceled" || event.error === "interrupted")
571
+ resolve(false);
572
+ else
573
+ reject(new Error(event.error));
574
+ };
575
+ const abort = () => {
576
+ speechSynthesis.cancel();
577
+ resolve(false);
578
+ };
579
+ signal.addEventListener("abort", abort, { once: true });
580
+ speechSynthesis.speak(utterance);
581
+ });
582
+ }
583
+ if (module === "Audio") {
584
+ const id = String(args[0]);
585
+ const sound = sounds.get(id);
586
+ if (method === "play") {
587
+ await stopSound(id);
588
+ const audio = new Audio(await platform.resolveMedia(String(args[1])));
589
+ const settings = object(args[2]);
590
+ audio.loop = Boolean(settings.loop);
591
+ const volume = Math.max(0, Math.min(1, Number(settings.volume ?? 1)));
592
+ audio.volume = Number(settings.fadeIn) > 0 ? 0 : volume;
593
+ audio.onended = () => {
594
+ sounds.delete(id);
595
+ emit("SOUND_EVENT", { id, event: "ended" });
596
+ };
597
+ audio.onerror = () => {
598
+ sounds.delete(id);
599
+ emit("SOUND_EVENT", { id, event: "error", message: audio.error?.message });
600
+ };
601
+ sounds.set(id, audio);
602
+ await audio.play();
603
+ fade(audio, volume, Number(settings.fadeIn ?? 0));
604
+ return true;
605
+ }
606
+ if (method === "stopAll") {
607
+ await Promise.all([...sounds.keys()].map((key) => stopSound(key, Number(first.fadeOut ?? 0))));
608
+ return true;
609
+ }
610
+ if (!sound)
611
+ return false;
612
+ if (method === "pause") {
613
+ sound.pause();
614
+ return true;
615
+ }
616
+ if (method === "resume") {
617
+ await sound.play();
618
+ return true;
619
+ }
620
+ if (method === "stop") {
621
+ await stopSound(id, Number(object(args[1]).fadeOut ?? 0));
622
+ return true;
623
+ }
624
+ if (method === "setVolume") {
625
+ await fade(sound, Number(args[1]), Number(object(args[2]).fade ?? 0));
626
+ return true;
627
+ }
628
+ }
629
+ if (module === "Media") {
630
+ if (!("caches" in window))
631
+ return unsupported(module, method);
632
+ const cache = await caches.open(cacheName());
633
+ if (method === "keep") {
634
+ const url = String(args[0]);
635
+ const settings = object(args[1]);
636
+ const connection = navigator.connection;
637
+ if (settings.cellular === false && (!["wifi", "ethernet"].includes(connection?.type ?? "") || connection?.saveData))
638
+ return { ok: false, reason: connection?.type ? "cellular" : "network-unknown" };
639
+ const old = await cache.match(url);
640
+ if (old)
641
+ return { ok: true, url, path: url, size: Number(old.headers.get("x-craft-size") ?? 0) };
642
+ const response = await fetch(url, { headers: settings.headers, signal });
643
+ if (!response.ok)
644
+ return { ok: false, status: response.status };
645
+ const body = await response.blob();
646
+ signal.throwIfAborted();
647
+ const headers = new Headers(response.headers);
648
+ headers.set("x-craft-size", String(body.size));
649
+ await cache.put(url, new Response(body, { headers, status: response.status }));
650
+ return { ok: true, url, path: url, size: body.size };
651
+ }
652
+ if (method === "path")
653
+ return await cache.match(String(args[0])) ? String(args[0]) : null;
654
+ if (method === "usage") {
655
+ const entries = await cache.keys();
656
+ let bytes = 0;
657
+ for (const entry of entries)
658
+ bytes += Number((await cache.match(entry))?.headers.get("x-craft-size") ?? 0);
659
+ return { bytes, count: entries.length };
660
+ }
661
+ if (method === "prune") {
662
+ const keep = new Set(Array.isArray(args[0]) ? args[0].map(String) : []);
663
+ let removed = 0;
664
+ for (const entry of await cache.keys())
665
+ if (!keep.has(entry.url)) {
666
+ await cache.delete(entry);
667
+ const local = mediaURLs.get(entry.url);
668
+ if (local)
669
+ URL.revokeObjectURL(local);
670
+ mediaURLs.delete(entry.url);
671
+ removed++;
672
+ }
673
+ return removed;
674
+ }
675
+ }
676
+ return unsupported(module, method);
677
+ },
678
+ dispose() {
679
+ if (disposed)
680
+ return;
681
+ disposed = true;
682
+ window.removeEventListener("storage", storageEvent);
683
+ lifetime.abort();
684
+ account.abort();
685
+ for (const path of files.keys())
686
+ URL.revokeObjectURL(path);
687
+ for (const path of mediaURLs.values())
688
+ URL.revokeObjectURL(path);
689
+ files.clear();
690
+ mediaURLs.clear();
691
+ for (const audio of sounds.values()) {
692
+ cancelFade(audio);
693
+ audio.pause();
694
+ audio.src = "";
695
+ }
696
+ sounds.clear();
697
+ wakeLock?.release();
698
+ listeners.clear();
699
+ }
700
+ };
701
+ return platform;
702
+ }
703
+ export {
704
+ createBrowserPlatform
705
+ };