@swiftbrowser/web 0.1.0 → 0.3.0

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.
Files changed (99) hide show
  1. package/README.md +141 -63
  2. package/app/index.html +21 -10
  3. package/node/dist/node/apps.d.ts +16 -0
  4. package/node/dist/node/apps.d.ts.map +1 -1
  5. package/node/dist/node/apps.js +9 -0
  6. package/node/dist/node/apps.js.map +1 -1
  7. package/node/dist/node/config.d.ts +14 -1
  8. package/node/dist/node/config.d.ts.map +1 -1
  9. package/node/dist/node/config.js +20 -3
  10. package/node/dist/node/config.js.map +1 -1
  11. package/node/dist/node/examples.d.ts +2 -1
  12. package/node/dist/node/examples.d.ts.map +1 -1
  13. package/node/dist/node/examples.js +12 -2
  14. package/node/dist/node/examples.js.map +1 -1
  15. package/node/dist/plugins/app-manifests.d.ts +4 -1
  16. package/node/dist/plugins/app-manifests.d.ts.map +1 -1
  17. package/node/dist/plugins/app-manifests.js +3 -2
  18. package/node/dist/plugins/app-manifests.js.map +1 -1
  19. package/node/dist/plugins/asset-catalogs.d.ts +15 -3
  20. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
  21. package/node/dist/plugins/asset-catalogs.js +111 -13
  22. package/node/dist/plugins/asset-catalogs.js.map +1 -1
  23. package/node/dist/plugins/included-sites.d.ts +26 -0
  24. package/node/dist/plugins/included-sites.d.ts.map +1 -0
  25. package/node/dist/plugins/included-sites.js +70 -0
  26. package/node/dist/plugins/included-sites.js.map +1 -0
  27. package/node/dist/plugins/pdfImages.d.ts +27 -0
  28. package/node/dist/plugins/pdfImages.d.ts.map +1 -0
  29. package/node/dist/plugins/pdfImages.js +145 -0
  30. package/node/dist/plugins/pdfImages.js.map +1 -0
  31. package/node/dist/plugins/swift-wasm.d.ts +19 -1
  32. package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
  33. package/node/dist/plugins/swift-wasm.js +45 -7
  34. package/node/dist/plugins/swift-wasm.js.map +1 -1
  35. package/node/dist/plugins/symbolTemplates.d.ts +22 -0
  36. package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
  37. package/node/dist/plugins/symbolTemplates.js +83 -0
  38. package/node/dist/plugins/symbolTemplates.js.map +1 -0
  39. package/package.json +4 -2
  40. package/plugins/app-manifests.ts +3 -2
  41. package/plugins/asset-catalogs.ts +119 -14
  42. package/plugins/included-sites.ts +74 -0
  43. package/plugins/pdfImages.ts +163 -0
  44. package/plugins/swift-wasm.ts +52 -6
  45. package/plugins/symbolTemplates.ts +88 -0
  46. package/public/_headers +7 -0
  47. package/public/app/oauth-callback.html +62 -0
  48. package/src/animation.ts +235 -8
  49. package/src/appFiles.ts +201 -0
  50. package/src/base64.ts +43 -0
  51. package/src/bitmapPlan.ts +143 -0
  52. package/src/bitmapStore.ts +206 -0
  53. package/src/bitmapWorker.ts +41 -0
  54. package/src/bitmapWorkerProtocol.ts +15 -0
  55. package/src/bitmaps.ts +283 -0
  56. package/src/blockedHosts.ts +29 -0
  57. package/src/canvasDrawing.ts +361 -0
  58. package/src/chartDom.ts +2 -2
  59. package/src/chromeColor.ts +179 -0
  60. package/src/clipboard.ts +78 -0
  61. package/src/colorMultiply.ts +93 -0
  62. package/src/colorPicker.ts +44 -0
  63. package/src/corsProxy.ts +45 -0
  64. package/src/gaugeRing.ts +74 -0
  65. package/src/gif.ts +297 -0
  66. package/src/hostRequests.ts +426 -0
  67. package/src/imageOps.ts +592 -0
  68. package/src/interaction.ts +68 -3
  69. package/src/landing.ts +2 -2
  70. package/src/largeTitle.ts +79 -0
  71. package/src/layout/chart.ts +9 -3
  72. package/src/layout/engine.ts +681 -174
  73. package/src/layout/index.ts +3 -1
  74. package/src/layout/text.ts +217 -1
  75. package/src/layout/types.ts +31 -0
  76. package/src/layout/typography.ts +24 -7
  77. package/src/layout/video.ts +24 -0
  78. package/src/layoutDom.ts +66 -6
  79. package/src/main.ts +127 -4
  80. package/src/media.ts +132 -0
  81. package/src/mock.ts +4 -3
  82. package/src/notifications.ts +109 -0
  83. package/src/pathShape.ts +156 -0
  84. package/src/protocol.ts +782 -13
  85. package/src/renderer.ts +603 -44
  86. package/src/richText.ts +65 -0
  87. package/src/scanner.ts +234 -0
  88. package/src/scrollTo.ts +123 -0
  89. package/src/styles.css +521 -100
  90. package/src/styles.ts +61 -0
  91. package/src/swiftData.ts +128 -0
  92. package/src/symbols.ts +658 -28
  93. package/src/translation.ts +126 -0
  94. package/src/video.ts +299 -0
  95. package/src/vite-env.d.ts +6 -0
  96. package/src/wasm.ts +227 -32
  97. package/src/webAuth.ts +267 -0
  98. package/src/webSocket.ts +148 -0
  99. package/src/zoneinfo.ts +269 -0
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Phase 13: what the Wasm app asks the page to do on its behalf. The app has
3
+ * no network of its own; `URLSession` sends a `request` op of kind `fetch`
4
+ * and `AsyncImage` one of kind `image` (see docs/ops-protocol.md, "Phase
5
+ * 13"); `ShareLink` sends `share` and `PhotosPicker` `pickFiles`. The
6
+ * loader (wasm.ts) hands each op to `performHostRequest` and delivers the
7
+ * result as a `response` event with the same id.
8
+ *
9
+ * Bodies cross the boundary as base64 (JSON has no bytes). An image is never
10
+ * copied into Wasm: the page decodes it once, reports the pixel size, and the
11
+ * `<img>` the renderer creates later finds it in the browser's cache.
12
+ *
13
+ * The stand-in frameworks use the same channel: UserNotifications sends
14
+ * `notification` requests (notifications.ts), AVFoundation's capture sessions
15
+ * `scanner` requests (scanner.ts), AuthenticationServices `webAuth`
16
+ * requests (webAuth.ts). Saving to the photo library sends `download`, and
17
+ * `UIPasteboard` `clipboard` (clipboard.ts).
18
+ */
19
+
20
+ import { base64Decode, base64Encode } from './base64';
21
+ import { browserNotificationHost, performNotification, type NotificationHost, type NotificationResult } from './notifications';
22
+ import type {
23
+ BitmapRecipe,
24
+ FetchRequestParams,
25
+ ImageRequestParams,
26
+ NotificationRequestParams,
27
+ PickFilesRequestParams,
28
+ RequestOp,
29
+ ScannerRequestParams,
30
+ ShareRequestParams,
31
+ WebSocketRequestParams,
32
+ OpenURLRequestParams,
33
+ WebAuthRequestParams,
34
+ MediaRequestParams,
35
+ DownloadRequestParams,
36
+ ClipboardRequestParams,
37
+ TranslateRequestParams,
38
+ PickColorRequestParams,
39
+ SharedFile,
40
+ } from './protocol';
41
+ import { browserClipboardHost, performClipboard, type ClipboardHost } from './clipboard';
42
+ import { browserMediaHost, performMedia, type MediaHost } from './media';
43
+ import { browserScannerHost, performScanner, type ScannerHost, type ScannerResult } from './scanner';
44
+ import { browserTranslationHost, performTranslate, type TranslationHost } from './translation';
45
+ import { chooseColor, performPickColor, type ColorChooser } from './colorPicker';
46
+ import { WebSocketHost, type WebSocketLike } from './webSocket';
47
+ import { browserWebAuthHost, WebAuthSessions, type WebAuthResult } from './webAuth';
48
+
49
+ export interface FetchResult {
50
+ status: number;
51
+ statusText: string;
52
+ /** Header names lower-cased, as `Headers` yields them. */
53
+ headers: Record<string, string>;
54
+ /**
55
+ * The body. It reaches Swift through linear memory (`sb_bytes`), or as
56
+ * base64 from a module without that export (src/wasm.ts).
57
+ */
58
+ body: Uint8Array;
59
+ /** The final URL after redirects. */
60
+ url: string;
61
+ }
62
+
63
+ export interface ImageResult {
64
+ width: number;
65
+ height: number;
66
+ }
67
+
68
+ /** How a `share` request ended: shared, saved as a download, or dismissed by the viewer. */
69
+ export interface ShareResult {
70
+ outcome: 'shared' | 'downloaded' | 'cancelled';
71
+ }
72
+
73
+ /** One file the viewer picked (`pickFiles`), with its bytes. */
74
+ export interface PickedFile {
75
+ name: string;
76
+ /** The MIME type the browser reports; empty when it does not know it. */
77
+ type: string;
78
+ /** The bytes, base64. */
79
+ data: string;
80
+ /** For a picture: an object URL the app's `Image` shows, and its pixel size. */
81
+ url?: string;
82
+ width?: number;
83
+ height?: number;
84
+ }
85
+
86
+ /** The files a `pickFiles` request chose; none when the viewer dismissed the chooser. */
87
+ export interface PickFilesResult {
88
+ items: PickedFile[];
89
+ }
90
+
91
+ /** The browser facilities a request handler uses; tests substitute them. */
92
+ export interface HostRequestEnvironment {
93
+ fetch: typeof fetch;
94
+ /** Decodes an image and resolves with its natural size; rejects when it cannot be loaded. */
95
+ loadImage: (url: string) => Promise<ImageResult>;
96
+ /** The Web Share API (`navigator.share`), where the browser has it. */
97
+ share?: (data: ShareData) => Promise<void>;
98
+ /** `navigator.canShare`, where the browser has it. */
99
+ canShare?: (data: ShareData) => boolean;
100
+ /** Saves a file the way a download link does. */
101
+ download?: (blob: Blob, filename: string) => void;
102
+ /** An asset-catalog image's URL (the page knows the catalog), to share it as a file. */
103
+ assetURL?: (name: string) => string | undefined;
104
+ /**
105
+ * Opens a file chooser and resolves with the chosen files, or none when the
106
+ * viewer dismisses it. It must open the chooser before it returns: browsers
107
+ * only show one during the tap that asked (user activation).
108
+ */
109
+ pickFiles?: (options: { accept: string; multiple: boolean }) => Promise<File[]>;
110
+ /** A URL the page can show a picked file at (`URL.createObjectURL`). */
111
+ objectURL?: (file: File) => string;
112
+ /** A `UIImage` bitmap drawn into a PNG file (src/bitmapStore.ts), to share it as a file. */
113
+ bitmapFile?: (recipe: BitmapRecipe) => Promise<Blob | null>;
114
+ /** The Notification API (UserNotifications), where the browser has it. */
115
+ notifications?: NotificationHost;
116
+ /** The camera and `BarcodeDetector`, or a typed code, for AVFoundation's capture sessions (scanner.ts). */
117
+ scanner?: ScannerHost;
118
+ /** The page's WebSockets (`URLSessionWebSocketTask`). */
119
+ webSockets?: WebSocketHost;
120
+ /** Opens a web or mail URL in a new tab (`openURL`). */
121
+ openURL?: (url: string) => void;
122
+ /** Sign-in windows (`webAuth`, webAuth.ts). */
123
+ webAuth?: WebAuthSessions;
124
+ /** AVFoundation's media files and probes, QuickLook's previews (`media`, media.ts). */
125
+ media?: MediaHost;
126
+ /** The system clipboard (`clipboard`, clipboard.ts). */
127
+ clipboard?: ClipboardHost;
128
+ /** The browser's built-in translator (`translate`, translation.ts), where it has one. */
129
+ translation?: TranslationHost;
130
+ /** The browser's color chooser (`pickColor`, colorPicker.ts); it must open before it returns. */
131
+ pickColor?: ColorChooser;
132
+ }
133
+
134
+ /**
135
+ * The browser's file chooser: a hidden `<input type="file">` clicked at once,
136
+ * inside the tap that sent the request (the loader performs the request while
137
+ * the tap's click handler is still running; see wasm.ts). Resolves on
138
+ * `change` with the files, or on `cancel` (the viewer closed the chooser)
139
+ * with none. A browser without the `cancel` event never answers a dismissal;
140
+ * the app's selection then just stays as it was.
141
+ */
142
+ function chooseFiles(options: { accept: string; multiple: boolean }): Promise<File[]> {
143
+ const input = document.createElement('input');
144
+ input.type = 'file';
145
+ if (options.accept) input.accept = options.accept;
146
+ input.multiple = options.multiple;
147
+ // In the document (some browsers only open a chooser for an attached
148
+ // input), but invisible and out of the way.
149
+ input.style.cssText = 'position:fixed;left:-1000px;top:0;width:1px;height:1px;opacity:0';
150
+ input.dataset['sbPicker'] = '';
151
+ document.body.append(input);
152
+ const result = new Promise<File[]>((resolve) => {
153
+ const finish = (files: File[]): void => {
154
+ input.remove();
155
+ resolve(files);
156
+ };
157
+ input.addEventListener('change', () => finish(Array.from(input.files ?? [])), { once: true });
158
+ input.addEventListener('cancel', () => finish([]), { once: true });
159
+ });
160
+ input.click();
161
+ return result;
162
+ }
163
+
164
+ function downloadBlob(blob: Blob, filename: string): void {
165
+ const url = URL.createObjectURL(blob);
166
+ const link = document.createElement('a');
167
+ link.href = url;
168
+ link.download = filename;
169
+ link.style.display = 'none';
170
+ document.body.append(link);
171
+ link.click();
172
+ link.remove();
173
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
174
+ }
175
+
176
+ const notificationHost = browserNotificationHost();
177
+ const scannerHost = browserScannerHost();
178
+ const webAuthHost = browserWebAuthHost();
179
+ const mediaHost = browserMediaHost(typeof document !== 'undefined' ? downloadBlob : undefined);
180
+ const clipboardHost = browserClipboardHost();
181
+ const translationHost = browserTranslationHost();
182
+
183
+ export const browserEnvironment: HostRequestEnvironment = {
184
+ fetch: (input, init) => fetch(input, init),
185
+ loadImage: (url) =>
186
+ new Promise((resolve, reject) => {
187
+ const img = new Image();
188
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
189
+ img.onerror = () => reject(new Error(`network: could not load ${url}`));
190
+ img.src = url;
191
+ }),
192
+ ...(typeof navigator !== 'undefined' && typeof navigator.share === 'function'
193
+ ? { share: (data: ShareData) => navigator.share(data) }
194
+ : {}),
195
+ ...(typeof navigator !== 'undefined' && typeof navigator.canShare === 'function'
196
+ ? { canShare: (data: ShareData) => navigator.canShare(data) }
197
+ : {}),
198
+ ...(typeof document !== 'undefined' ? { download: downloadBlob, pickFiles: chooseFiles } : {}),
199
+ ...(typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
200
+ ? { objectURL: (file: File) => URL.createObjectURL(file) }
201
+ : {}),
202
+ ...(notificationHost ? { notifications: notificationHost } : {}),
203
+ ...(scannerHost ? { scanner: scannerHost } : {}),
204
+ ...(typeof window !== 'undefined' && typeof window.open === 'function'
205
+ ? { openURL: (url: string) => void window.open(url, '_blank', 'noopener') }
206
+ : {}),
207
+ ...(typeof WebSocket !== 'undefined'
208
+ ? { webSockets: new WebSocketHost((url, protocols) => new WebSocket(url, protocols) as unknown as WebSocketLike) }
209
+ : {}),
210
+ ...(webAuthHost ? { webAuth: new WebAuthSessions(webAuthHost) } : {}),
211
+ ...(mediaHost ? { media: mediaHost } : {}),
212
+ ...(clipboardHost ? { clipboard: clipboardHost } : {}),
213
+ ...(translationHost ? { translation: translationHost } : {}),
214
+ ...(typeof document !== 'undefined' ? { pickColor: chooseColor } : {}),
215
+ };
216
+
217
+ /**
218
+ * Performs one request. Resolves with the result the app expects for the
219
+ * op's kind; rejects with an `Error` whose message starts with a short tag
220
+ * the app maps to a `URLError` (`timeout`, `abort`, `offline`, `network`).
221
+ */
222
+ export async function performHostRequest(
223
+ op: RequestOp,
224
+ env: HostRequestEnvironment = browserEnvironment,
225
+ ): Promise<FetchResult | ImageResult | ShareResult | PickFilesResult | NotificationResult | ScannerResult | WebAuthResult | Record<string, unknown>> {
226
+ switch (op.kind) {
227
+ case 'fetch':
228
+ return performFetch(op.params as FetchRequestParams, env);
229
+ case 'image':
230
+ return env.loadImage((op.params as ImageRequestParams).url);
231
+ case 'share':
232
+ return performShare(op.params as ShareRequestParams, env);
233
+ case 'pickFiles':
234
+ // Not awaited before the chooser opens: `performPickFiles` calls
235
+ // `env.pickFiles` synchronously, inside the tap that sent the request.
236
+ return performPickFiles(op.params as PickFilesRequestParams, env);
237
+ case 'notification':
238
+ return performNotification(op.params as NotificationRequestParams, env.notifications);
239
+ case 'scanner':
240
+ return performScanner(op.params as ScannerRequestParams, env.scanner);
241
+ case 'openURL': {
242
+ const { url } = op.params as OpenURLRequestParams;
243
+ if (!/^(https?|mailto|tel|sms):/i.test(url)) throw new Error(`unsupported: cannot open ${url}`);
244
+ if (!env.openURL) throw new Error('unsupported: this page cannot open URLs');
245
+ env.openURL(url);
246
+ return {};
247
+ }
248
+ case 'websocket':
249
+ if (!env.webSockets) throw new Error('unsupported: this browser has no WebSocket');
250
+ return env.webSockets.perform(op.params as WebSocketRequestParams);
251
+ case 'webAuth':
252
+ // Not awaited before the popup opens: `perform` calls `window.open`
253
+ // synchronously, while the tap's activation may still count.
254
+ if (!env.webAuth) throw new Error('unsupported: this page cannot open a sign-in window');
255
+ return env.webAuth.perform(op.params as WebAuthRequestParams);
256
+ case 'media':
257
+ // `open` is called before any await, while the tap's activation may still count.
258
+ return performMedia(op.params as MediaRequestParams, env.media);
259
+ case 'download':
260
+ return performDownload(op.params as DownloadRequestParams, env);
261
+ case 'clipboard': {
262
+ // The write starts before any await, while the tap's activation may still count.
263
+ const params = op.params as ClipboardRequestParams;
264
+ return performClipboard(params, env.clipboard, () => collectFiles(params, env, 'Image'));
265
+ }
266
+ case 'translate':
267
+ return performTranslate(op.params as TranslateRequestParams, env.translation);
268
+ case 'pickColor':
269
+ // Not awaited before the chooser opens: it is clicked inside the tap that sent the request.
270
+ return performPickColor(op.params as PickColorRequestParams, env.pickColor);
271
+ default:
272
+ throw new Error(`unsupported: request kind ${String((op as { kind: unknown }).kind)}`);
273
+ }
274
+ }
275
+
276
+ /**
277
+ * `ShareLink`: the Web Share API when the browser has it (and can share the
278
+ * files, if any); otherwise, or when the browser refuses (no user
279
+ * activation, unsupported data), a download: each image as its file, else
280
+ * the title, text and link as a `.txt`. A share the viewer dismisses is not
281
+ * downloaded.
282
+ */
283
+ async function performShare(params: ShareRequestParams, env: HostRequestEnvironment): Promise<ShareResult> {
284
+ const files = await collectFiles(params, env, 'Image');
285
+ const data: ShareData = {};
286
+ if (params.title) data.title = params.title;
287
+ if (params.text) data.text = params.text;
288
+ if (params.url) data.url = params.url;
289
+ if (files.length) data.files = files;
290
+ const canShareFiles = !files.length || (env.canShare?.({ files }) ?? false);
291
+ if (env.share && canShareFiles) {
292
+ try {
293
+ await env.share(data);
294
+ return { outcome: 'shared' };
295
+ } catch (error) {
296
+ if (error instanceof Error && error.name === 'AbortError') return { outcome: 'cancelled' };
297
+ // NotAllowedError (no user activation), TypeError (unsupported data): download instead.
298
+ }
299
+ }
300
+ if (!env.download) throw new Error('unsupported: this browser can neither share nor download');
301
+ if (files.length) {
302
+ for (const file of files) env.download(file, file.name);
303
+ } else {
304
+ const body = [params.title, params.text, params.url].filter(Boolean).join('\n');
305
+ env.download(new Blob([`${body}\n`], { type: 'text/plain' }), `${safeFilename(params.title || 'Shared')}.txt`);
306
+ }
307
+ return { outcome: 'downloaded' };
308
+ }
309
+
310
+ /**
311
+ * `PhotosPicker`: the browser's file chooser stands in for the photo
312
+ * library. Every chosen file comes back with its bytes (the app loads them
313
+ * with `loadTransferable(type: Data.self)` without asking again); a picture
314
+ * the browser can decode also gets an object URL and its pixel size, for
315
+ * `loadTransferable(type: Image.self)`. A dismissed chooser answers with no
316
+ * items.
317
+ */
318
+ async function performPickFiles(params: PickFilesRequestParams, env: HostRequestEnvironment): Promise<PickFilesResult> {
319
+ if (!env.pickFiles) throw new Error('unsupported: this page cannot open a file chooser');
320
+ // Called before any `await`, so the chooser opens within the tap.
321
+ const files = await env.pickFiles({ accept: params.accept ?? '', multiple: params.multiple === true });
322
+ const items: PickedFile[] = [];
323
+ for (const file of files) {
324
+ const item: PickedFile = {
325
+ name: file.name,
326
+ type: file.type,
327
+ data: base64Encode(new Uint8Array(await file.arrayBuffer())),
328
+ };
329
+ if (file.type.startsWith('image/') && env.objectURL) {
330
+ const url = env.objectURL(file);
331
+ try {
332
+ const size = await env.loadImage(url);
333
+ Object.assign(item, { url, width: size.width, height: size.height });
334
+ } catch {
335
+ // A format this browser cannot draw (HEIC outside Safari): the bytes still go.
336
+ if (typeof URL !== 'undefined' && url.startsWith('blob:')) URL.revokeObjectURL(url);
337
+ }
338
+ }
339
+ items.push(item);
340
+ }
341
+ return { items };
342
+ }
343
+
344
+ /**
345
+ * The files a `share`, `download` or `clipboard` request carries: asset
346
+ * images (fetched from the catalog), `UIImage` bitmaps (drawn into PNGs
347
+ * named after the title, or `fallbackTitle`), and files the app made
348
+ * (`files`: a `Transferable` item's bytes, a `UIImage`'s own file).
349
+ */
350
+ async function collectFiles(
351
+ params: { title?: string; images?: string[]; bitmaps?: BitmapRecipe[]; files?: SharedFile[] },
352
+ env: HostRequestEnvironment,
353
+ fallbackTitle: string,
354
+ ): Promise<File[]> {
355
+ const files: File[] = [];
356
+ for (const name of params.images ?? []) {
357
+ const src = env.assetURL?.(name);
358
+ if (!src) continue;
359
+ const blob = await (await env.fetch(src)).blob();
360
+ const extension = blob.type.split('/')[1]?.replace('svg+xml', 'svg') ?? 'png';
361
+ files.push(new File([blob], `${safeFilename(name)}.${extension}`, { type: blob.type }));
362
+ }
363
+ for (const [index, recipe] of (params.bitmaps ?? []).entries()) {
364
+ const blob = await env.bitmapFile?.(recipe);
365
+ if (!blob) continue;
366
+ const name = safeFilename(params.title || fallbackTitle) + (index > 0 ? ` ${index + 1}` : '');
367
+ files.push(new File([blob], `${name}.png`, { type: 'image/png' }));
368
+ }
369
+ for (const file of params.files ?? []) {
370
+ const bytes = base64Decode(file.data);
371
+ files.push(new File([bytes as Uint8Array<ArrayBuffer>], safeFilename(file.name), { type: file.type }));
372
+ }
373
+ return files;
374
+ }
375
+
376
+ /**
377
+ * Saving to the photo library (Photos, UIKit): every file the request
378
+ * carries is downloaded, as the browser saves any download.
379
+ */
380
+ async function performDownload(params: DownloadRequestParams, env: HostRequestEnvironment): Promise<ShareResult> {
381
+ if (!env.download) throw new Error('unsupported: this page cannot download files');
382
+ const files = await collectFiles(params, env, 'Photo');
383
+ if (!files.length) throw new Error('unsupported: nothing to download');
384
+ for (const file of files) env.download(file, file.name);
385
+ return { outcome: 'downloaded' };
386
+ }
387
+
388
+ /** A file name from a title: no path separators or characters filesystems reject. */
389
+ function safeFilename(name: string): string {
390
+ return name.replace(/[\\/:*?"<>|]+/g, '-').trim() || 'Shared';
391
+ }
392
+
393
+ async function performFetch(params: FetchRequestParams, env: HostRequestEnvironment): Promise<FetchResult> {
394
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) throw new Error('offline');
395
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
396
+ const timeout = typeof params.timeout === 'number' && params.timeout > 0 ? params.timeout : 0;
397
+ const timer = controller && timeout > 0 ? setTimeout(() => controller.abort('timeout'), timeout) : null;
398
+ const init: RequestInit = { method: params.method || 'GET', headers: params.headers ?? {} };
399
+ if (params.body) init.body = base64Decode(params.body) as BodyInit;
400
+ if (controller) init.signal = controller.signal;
401
+ let response: Response;
402
+ try {
403
+ response = await env.fetch(params.url, init);
404
+ } catch (error) {
405
+ if (controller?.signal.aborted) {
406
+ throw new Error(controller.signal.reason === 'timeout' ? `timeout: ${params.url}` : `abort: ${params.url}`);
407
+ }
408
+ throw new Error(`network: ${error instanceof Error ? error.message : String(error)}`);
409
+ } finally {
410
+ if (timer !== null) clearTimeout(timer);
411
+ }
412
+ const headers: Record<string, string> = {};
413
+ response.headers.forEach((value, key) => {
414
+ headers[key] = value;
415
+ });
416
+ const bytes = new Uint8Array(await response.arrayBuffer());
417
+ return {
418
+ status: response.status,
419
+ statusText: response.statusText,
420
+ headers,
421
+ body: bytes,
422
+ url: response.url || params.url,
423
+ };
424
+ }
425
+
426
+ export { base64Decode, base64Encode } from './base64';