@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
package/src/main.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /// <reference path="./vite-env.d.ts" />
2
2
  import { easingFor, springEasing, timingFor } from './animation';
3
3
  import type { BuildEndPayload, BuildErrorPayload, BuildStartPayload } from './devEvents';
4
+ import { ChromeColor } from './chromeColor';
4
5
  import { applyPageMode, decidePageMode, readViewportInsets, watchViewport, type PageMode } from './deviceMode';
5
6
  import { EMPTY_CATALOG, catalogPath, isAssetCatalog, type AssetCatalog } from './assets';
6
7
  import { createMockApp } from './mock';
@@ -14,9 +15,16 @@ import {
14
15
  type ScenePhase,
15
16
  } from './protocol';
16
17
  import { Renderer, RendererError } from './renderer';
18
+ import { withBlockedHosts } from './blockedHosts';
19
+ import { withCORSProxy } from './corsProxy';
20
+ import { browserEnvironment } from './hostRequests';
21
+ import { callbackPageURL } from './webAuth';
22
+ import { bitmapStore } from './bitmaps';
17
23
  import { DEFAULT_TYPE_SIZE, applyTypeSizeVariables } from './typography';
18
24
  import { JobScheduler, loadApp, type AppHandle, type JobTimers } from './wasm';
19
25
  import { chooseLocale, currentTimeZone } from './locale';
26
+ import { indexedDBPersistence, launchValue, type SwiftDataPersistence, type SwiftDataStores } from './swiftData';
27
+ import { applyHomeChanges, diffHome, homeDirectory, homeEntries, indexedDBFiles, type FilesPersistence, type HomeEntries } from './appFiles';
20
28
  import type { BundleManifest } from '../plugins/bundle-resources';
21
29
 
22
30
  const THEME_KEY = 'sb-theme';
@@ -70,7 +78,7 @@ function dispatch(event: RendererEvent): void {
70
78
  return;
71
79
  }
72
80
  try {
73
- if (event.type === 'tap') {
81
+ if (event.type === 'tap' && event.x === undefined) {
74
82
  app.sendEvent(event.id);
75
83
  } else if (event.type === 'batch' && !app.supportsBatchEvents) {
76
84
  // The mock apps handle events one at a time.
@@ -87,6 +95,11 @@ function dispatch(event: RendererEvent): void {
87
95
 
88
96
  const renderer = new Renderer(screen, dispatch);
89
97
 
98
+ // On a phone the browser's status bar strip and safe areas take the color the
99
+ // app draws at its top edge (chromeColor.ts).
100
+ const chromeColor = deviceMode ? new ChromeColor(device, screen) : null;
101
+ if (chromeColor) renderer.afterLayout = () => chromeColor.schedule();
102
+
90
103
  // Phase 12: `.preferredColorScheme` from the app overrides the viewer's theme
91
104
  // for this page (not persisted); `null` goes back to the toggle / OS setting.
92
105
  screen.addEventListener('sb:color-scheme', (event) => {
@@ -122,6 +135,7 @@ function applyTheme(theme: Theme, persist: boolean): void {
122
135
  themeToggle.setAttribute('aria-pressed', String(theme === 'dark'));
123
136
  themeToggle.textContent = theme === 'dark' ? 'Light mode' : 'Dark mode';
124
137
  renderer.setEnvironment({ colorScheme: theme });
138
+ chromeColor?.schedule();
125
139
  if (persist) {
126
140
  try {
127
141
  localStorage.setItem(THEME_KEY, theme);
@@ -210,8 +224,8 @@ function currentSizeClass(): HorizontalSizeClass {
210
224
  let sentSizeClass: HorizontalSizeClass | null = null;
211
225
 
212
226
  if (deviceMode) {
213
- // `#screen` fills the viewport (styles.css); the engine reads its size on
214
- // every pass. The safe areas come from env(safe-area-inset-*): non-zero
227
+ // `#screen` fills the viewport (styles.css); the renderer re-reads its size
228
+ // when it resizes. The safe areas come from env(safe-area-inset-*): non-zero
215
229
  // only in standalone (home screen) mode or landscape, which is what the
216
230
  // real app would get.
217
231
  renderer.setSafeArea(readViewportInsets());
@@ -357,9 +371,78 @@ function launchEnvironment(): string[] {
357
371
  if (zone) env.push(`TZ=${zone}`);
358
372
  const defaults = readStoredDefaults();
359
373
  if (defaults !== null) env.push(`SB_DEFAULTS=${defaults}`);
374
+ // Signing in: the app's URL schemes (its Info.plist's CFBundleURLTypes) and
375
+ // the callback page its OAuth redirects become (webAuth.ts).
376
+ const schemes = registeredApp()?.urlSchemes;
377
+ if (schemes?.length) env.push(`SB_URL_SCHEMES=${schemes.join(',')}`);
378
+ env.push(`SB_WEB_AUTH_CALLBACK=${callbackPageURL(new URL(import.meta.env.BASE_URL, location.href).href)}`);
360
379
  return env;
361
380
  }
362
381
 
382
+ // ---------------------------------------------------------------------------
383
+ // Phase 18: SwiftData's stores, kept in IndexedDB
384
+ // ---------------------------------------------------------------------------
385
+
386
+ /** The app's database, or null where the browser has no IndexedDB (or forbids it). */
387
+ const swiftData: SwiftDataPersistence | null = (() => {
388
+ try {
389
+ return typeof indexedDB === 'undefined' ? null : indexedDBPersistence(requestedApp);
390
+ } catch {
391
+ return null;
392
+ }
393
+ })();
394
+
395
+ /**
396
+ * Every SwiftData store the app saved, for `SB_SWIFTDATA`. `?swiftdata=reset`
397
+ * deletes them first (a model changed in a way the saved records cannot
398
+ * follow). A database that cannot be read starts the app empty.
399
+ */
400
+ async function loadSwiftData(): Promise<SwiftDataStores> {
401
+ if (!swiftData) return {};
402
+ try {
403
+ if (params.get('swiftdata') === 'reset') await swiftData.clear();
404
+ return await swiftData.load();
405
+ } catch (error) {
406
+ console.warn('[sb] could not read the SwiftData stores; starting empty', error);
407
+ return {};
408
+ }
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Phase 19: the app's home directory, kept in IndexedDB
413
+ // ---------------------------------------------------------------------------
414
+
415
+ const files: FilesPersistence | null = (() => {
416
+ try {
417
+ return typeof indexedDB === 'undefined' ? null : indexedDBFiles(requestedApp);
418
+ } catch {
419
+ return null;
420
+ }
421
+ })();
422
+
423
+ /** The home as last written to IndexedDB, to diff the live tree against. */
424
+ let savedHome: HomeEntries = new Map();
425
+
426
+ /** The saved home (`?files=reset` deletes it first); empty when it cannot be read. */
427
+ async function loadHome(): Promise<HomeEntries> {
428
+ if (!files) return new Map();
429
+ try {
430
+ if (params.get('files') === 'reset') await files.clear();
431
+ return await files.load();
432
+ } catch (error) {
433
+ console.warn('[sb] could not read the app\'s files; starting empty', error);
434
+ return new Map();
435
+ }
436
+ }
437
+
438
+ /** Writes what the app changed in its home since the last write. */
439
+ function saveHome(home: ReturnType<typeof homeDirectory>): void {
440
+ const changes = diffHome(savedHome, homeEntries(home));
441
+ if (changes.put.length === 0 && changes.remove.length === 0) return;
442
+ savedHome = applyHomeChanges(savedHome, changes);
443
+ files?.apply(changes).catch((error: unknown) => console.warn('[sb] could not save the app\'s files to IndexedDB', error));
444
+ }
445
+
363
446
  /** The app's resources (`/bundle/<App>.json` and its files), for the WASI `/bundle` mount; empty without a manifest. */
364
447
  async function loadBundle(app: string): Promise<Map<string, Uint8Array>> {
365
448
  const files = new Map<string, Uint8Array>();
@@ -489,7 +572,38 @@ async function boot(): Promise<void> {
489
572
  console.info(`[sb] restoring state for ${requestedApp}: ${restored}`);
490
573
  }
491
574
  const bundle = await loadBundle(requestedApp);
492
- app = await loadApp(url, onOps, { args: [requestedApp], env, bundle, onDefaults: storeDefaults });
575
+ const stores = launchValue(await loadSwiftData());
576
+ if (stores !== null) env.push(`SB_SWIFTDATA=${stores}`);
577
+ savedHome = await loadHome();
578
+ const home = homeDirectory(savedHome);
579
+ // The standard directories a new home gets are saved with its first write.
580
+ const corsProxy = registeredApp()?.corsProxy;
581
+ app = await loadApp(url, onOps, {
582
+ // `?arg=` (repeatable) adds launch arguments after the app's name, as
583
+ // `simctl launch <app> <args>` does: CI's iOS fidelity workflow opens a
584
+ // screen of Examples/Fidelity with `?arg=-screen&arg=<name>`.
585
+ args: [requestedApp, ...params.getAll('arg')],
586
+ env,
587
+ bundle,
588
+ home,
589
+ onFilesWritten: () => saveHome(home),
590
+ onDefaults: storeDefaults,
591
+ // `ShareLink` shares asset-catalog images as their files. An app built
592
+ // with a CORS proxy retries a refused cross-origin read through it
593
+ // (corsProxy.ts); one built with blocked hosts never reaches them (blockedHosts.ts).
594
+ requests: {
595
+ ...browserEnvironment,
596
+ fetch: withBlockedHosts(
597
+ corsProxy ? withCORSProxy(browserEnvironment.fetch, corsProxy, location.origin) : browserEnvironment.fetch,
598
+ registeredApp()?.blockedHosts ?? [],
599
+ ),
600
+ assetURL: (name) => assets.images[name]?.src,
601
+ bitmapFile: (recipe) => bitmapStore.png(recipe),
602
+ },
603
+ onSwiftData: (op) => {
604
+ swiftData?.apply(op).catch((error: unknown) => console.warn('[sb] could not save to IndexedDB', error));
605
+ },
606
+ });
493
607
  setLoaded(requestedApp);
494
608
  sendEnvironment();
495
609
  flushQueuedEvents();
@@ -527,6 +641,10 @@ declare global {
527
641
  * module (or mock) has no executor.
528
642
  */
529
643
  runJobs: () => number | null;
644
+ /** Phase 19: the app's home as IndexedDB holds it now: path → text of each file (`null` for a directory). */
645
+ files: () => Promise<Record<string, string | null>>;
646
+ /** Phase 18: the app's SwiftData stores as IndexedDB holds them now (all saves written). */
647
+ swiftData: () => Promise<SwiftDataStores>;
530
648
  /** The executor driver class, so tests can run it against a stub. */
531
649
  JobScheduler: new (
532
650
  run: (nowMs: number) => number,
@@ -550,5 +668,10 @@ window.__sb = {
550
668
  assets: () => assets,
551
669
  animation: { easingFor, timingFor, springEasing },
552
670
  runJobs: () => app?.runJobs() ?? null,
671
+ swiftData: async () => (swiftData ? swiftData.load() : {}),
672
+ files: async () => {
673
+ const entries = files ? await files.load() : new Map<string, Uint8Array | null>();
674
+ return Object.fromEntries([...entries].map(([path, data]) => [path, data === null ? null : new TextDecoder().decode(data)]));
675
+ },
553
676
  JobScheduler,
554
677
  };
package/src/media.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `media` host requests (docs/ops-protocol.md, "Video and audio"):
3
+ * AVFoundation's files and metadata probes, and QuickLook's previews.
4
+ *
5
+ * - `file` (`name`, `type`, `data` base64): a blob of the bytes; answers
6
+ * `{ url }`, an object URL a `video` element can play.
7
+ * - `probe` (`url`): reads the media's metadata in a detached `<video>`;
8
+ * answers `{ duration, width, height }` (`duration` null for a stream),
9
+ * fails (`network: …`) when the browser cannot load or decode it.
10
+ * - `release` (`url`): revokes an object URL `file` made.
11
+ * - `open` (`url`, or `name` / `type` / `data`): opens the URL, or the file
12
+ * as a blob, in a new tab; when the browser blocks the tab (no tap just
13
+ * before), a file is downloaded instead. Answers `{ opened }`.
14
+ */
15
+
16
+ import type { MediaRequestParams } from './protocol';
17
+
18
+ /** Standard base64 to bytes (hostRequests.ts imports this module, so not from there). */
19
+ function decode(text: string): Uint8Array {
20
+ const binary = atob(text);
21
+ const bytes = new Uint8Array(binary.length);
22
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
23
+ return bytes;
24
+ }
25
+
26
+ export interface MediaInfo {
27
+ duration: number | null;
28
+ width: number;
29
+ height: number;
30
+ }
31
+
32
+ /** What the page does for `media` requests; tests pass their own. */
33
+ export interface MediaHost {
34
+ objectURL(blob: Blob): string;
35
+ revoke(url: string): void;
36
+ probe(url: string): Promise<MediaInfo>;
37
+ /** Opens `url` in a new tab; false when the browser blocked it. */
38
+ open(url: string): boolean;
39
+ download?(blob: Blob, filename: string): void;
40
+ }
41
+
42
+ export async function performMedia(params: MediaRequestParams, host: MediaHost | undefined): Promise<Record<string, unknown>> {
43
+ if (!host) throw new Error('unsupported: this page has no media host');
44
+ switch (params.action) {
45
+ case 'file': {
46
+ const blob = new Blob([decode(params.data ?? '') as BlobPart], { type: params.type ?? '' });
47
+ return { url: host.objectURL(blob) };
48
+ }
49
+ case 'probe': {
50
+ if (!params.url) throw new Error('malformed request: probe needs a url');
51
+ const info = await host.probe(params.url);
52
+ return { ...info };
53
+ }
54
+ case 'release':
55
+ if (params.url?.startsWith('blob:')) host.revoke(params.url);
56
+ return {};
57
+ case 'open': {
58
+ if (params.url !== undefined) {
59
+ if (!/^(https?|blob|data):/i.test(params.url)) throw new Error(`unsupported: cannot open ${params.url}`);
60
+ return { opened: host.open(params.url) };
61
+ }
62
+ const blob = new Blob([decode(params.data ?? '') as BlobPart], { type: params.type ?? '' });
63
+ const url = host.objectURL(blob);
64
+ if (host.open(url)) {
65
+ // The tab has loaded it long before then.
66
+ setTimeout(() => host.revoke(url), 60_000);
67
+ return { opened: true };
68
+ }
69
+ host.revoke(url);
70
+ host.download?.(blob, params.name || 'Preview');
71
+ return { opened: false };
72
+ }
73
+ default:
74
+ throw new Error(`unsupported: media action ${String((params as { action: unknown }).action)}`);
75
+ }
76
+ }
77
+
78
+ /** Reads metadata in a detached `<video>` (no sound, no frames beyond the first). */
79
+ function probe(url: string): Promise<MediaInfo> {
80
+ return new Promise((resolve, reject) => {
81
+ const video = document.createElement('video');
82
+ video.preload = 'metadata';
83
+ video.muted = true;
84
+ const finish = (): void => {
85
+ video.removeAttribute('src');
86
+ video.load();
87
+ };
88
+ video.addEventListener(
89
+ 'loadedmetadata',
90
+ () => {
91
+ const info = {
92
+ duration: Number.isFinite(video.duration) ? video.duration : null,
93
+ width: video.videoWidth,
94
+ height: video.videoHeight,
95
+ };
96
+ finish();
97
+ resolve(info);
98
+ },
99
+ { once: true },
100
+ );
101
+ video.addEventListener(
102
+ 'error',
103
+ () => {
104
+ const message = video.error?.message || `could not load ${url}`;
105
+ finish();
106
+ reject(new Error(`network: ${message}`));
107
+ },
108
+ { once: true },
109
+ );
110
+ video.src = url;
111
+ });
112
+ }
113
+
114
+ export function browserMediaHost(download?: (blob: Blob, filename: string) => void): MediaHost | undefined {
115
+ if (typeof document === 'undefined' || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return undefined;
116
+ return {
117
+ objectURL: (blob) => URL.createObjectURL(blob),
118
+ revoke: (url) => URL.revokeObjectURL(url),
119
+ probe,
120
+ open: (url) => {
121
+ const tab = window.open(url, '_blank');
122
+ if (!tab) return false;
123
+ try {
124
+ tab.opener = null;
125
+ } catch {
126
+ // Another origin's window: nothing to cut.
127
+ }
128
+ return true;
129
+ },
130
+ ...(download ? { download } : {}),
131
+ };
132
+ }
package/src/mock.ts CHANGED
@@ -122,7 +122,8 @@ export function createCounterMock(onOps: OpsSink): MockAppHandle {
122
122
  received,
123
123
  sendEvent,
124
124
  sendEventJson(event) {
125
- if (event.type === 'tap') sendEvent(event.id);
125
+ // A tap with a point (a `tapgesture` with `location`) is kept as sent.
126
+ if (event.type === 'tap' && event.x === undefined) sendEvent(event.id);
126
127
  else received.push(event);
127
128
  return true;
128
129
  },
@@ -380,8 +381,8 @@ export function galleryTextStyleIds(style: TextStyle): { styled: number; text: n
380
381
  return { styled: GALLERY_IDS.firstTextStyle + 2 * i, text: GALLERY_IDS.firstTextStyle + 2 * i + 1 };
381
382
  }
382
383
 
383
- const BLUE = { r: 0, g: 0.478, b: 1, a: 1 };
384
- const RED = { r: 1, g: 0.231, b: 0.188, a: 1 };
384
+ const BLUE = { r: 0, g: 0.533, b: 1, a: 1 };
385
+ const RED = { r: 1, g: 0.22, b: 0.235, a: 1 };
385
386
  const GREEN = { r: 0.204, g: 0.78, b: 0.349, a: 1 };
386
387
 
387
388
  /** The animated box: `styled{frame}` > `shape`; expanded doubles the width and swaps the fill. */
@@ -0,0 +1,109 @@
1
+ /**
2
+ * UserNotifications' page side (docs/ops-protocol.md, "Notifications"). The
3
+ * app schedules its own timers and sends a `notification` request when one
4
+ * comes due; the page only answers for the permission and shows what it is
5
+ * told to, with the browser's Notification API. Nothing is scheduled here, so
6
+ * nothing fires once the page is closed.
7
+ */
8
+
9
+ import type { NotificationRequestParams } from './protocol';
10
+
11
+ /** `NotificationPermission`, or no Notification API at all. */
12
+ export type NotificationPermissionState = NotificationPermission | 'unsupported';
13
+
14
+ export interface NotificationResult {
15
+ /** `status` and `request`. */
16
+ permission?: NotificationPermissionState;
17
+ /** `show`: whether a notification appeared. */
18
+ shown?: boolean;
19
+ }
20
+
21
+ /** The browser's notification facilities; tests substitute them. */
22
+ export interface NotificationHost {
23
+ permission(): NotificationPermissionState;
24
+ requestPermission(): Promise<NotificationPermission>;
25
+ /** Shows a notification; resolves with whether the browser showed it. */
26
+ show(title: string, options: NotificationOptions): Promise<boolean>;
27
+ /** Closes the shown notifications with these tags. */
28
+ close(tags: readonly string[]): void;
29
+ /** `navigator.setAppBadge` (0 clears), where the browser has it. */
30
+ setBadge?(count: number): Promise<void>;
31
+ }
32
+
33
+ export async function performNotification(params: NotificationRequestParams, host: NotificationHost | undefined): Promise<NotificationResult> {
34
+ const permission = host?.permission() ?? 'unsupported';
35
+ switch (params.action) {
36
+ case 'status':
37
+ return { permission };
38
+ case 'request':
39
+ // The browser asks only once; after that the answer stands until the
40
+ // viewer changes it in the site settings.
41
+ if (!host || permission !== 'default') return { permission };
42
+ return { permission: await host.requestPermission() };
43
+ case 'show': {
44
+ if (params.badge !== undefined) await host?.setBadge?.(params.badge).catch(() => undefined);
45
+ if (params.title === undefined || !host || permission !== 'granted') return { shown: false };
46
+ const options: NotificationOptions = { silent: params.silent ?? false };
47
+ const body = [params.subtitle, params.body].filter((line) => line).join('\n');
48
+ if (body) options.body = body;
49
+ if (params.id) options.tag = params.id;
50
+ return { shown: await host.show(params.title, options) };
51
+ }
52
+ case 'close':
53
+ host?.close(params.ids ?? []);
54
+ return {};
55
+ case 'badge':
56
+ await host?.setBadge?.(params.badge ?? 0).catch(() => undefined);
57
+ return {};
58
+ default:
59
+ throw new Error(`unsupported: notification action ${String((params as { action: unknown }).action)}`);
60
+ }
61
+ }
62
+
63
+ /** The Notification API, or nothing where the browser has none (an iPhone tab not on the Home Screen). */
64
+ export function browserNotificationHost(): NotificationHost | undefined {
65
+ if (typeof window === 'undefined' || typeof Notification === 'undefined') return undefined;
66
+ const open = new Map<string, Notification>();
67
+ const badgeNavigator = navigator as Navigator & { setAppBadge?: (n?: number) => Promise<void>; clearAppBadge?: () => Promise<void> };
68
+ return {
69
+ permission: () => Notification.permission,
70
+ requestPermission: () =>
71
+ new Promise((resolve) => {
72
+ // Older Safari takes a callback and returns nothing.
73
+ const result = Notification.requestPermission((answer) => resolve(answer));
74
+ if (result) result.then(resolve, () => resolve(Notification.permission));
75
+ }),
76
+ show: async (title, options) => {
77
+ try {
78
+ const notification = new Notification(title, options);
79
+ if (options.tag) open.set(options.tag, notification);
80
+ notification.onclick = () => {
81
+ window.focus();
82
+ notification.close();
83
+ };
84
+ notification.onclose = () => {
85
+ if (options.tag && open.get(options.tag) === notification) open.delete(options.tag);
86
+ };
87
+ return true;
88
+ } catch {
89
+ // Chrome on Android shows notifications only through a service worker.
90
+ const registration = await navigator.serviceWorker?.getRegistration().catch(() => undefined);
91
+ if (!registration) return false;
92
+ await registration.showNotification(title, options);
93
+ return true;
94
+ }
95
+ },
96
+ close: (tags) => {
97
+ for (const tag of tags) {
98
+ open.get(tag)?.close();
99
+ open.delete(tag);
100
+ }
101
+ },
102
+ ...(typeof badgeNavigator.setAppBadge === 'function'
103
+ ? {
104
+ setBadge: (count: number) =>
105
+ count > 0 ? badgeNavigator.setAppBadge!(count) : (badgeNavigator.clearAppBadge?.() ?? Promise.resolve()),
106
+ }
107
+ : {}),
108
+ };
109
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Custom shapes (`shape: "path"`): a SwiftUI `Shape` drawn from its
3
+ * `path(in:)`. The Swift side sends the path as SVG path data for the size the
4
+ * element reported (`reportsSize`), so the element holds an `<svg>` whose
5
+ * view box is that size, stretched to the element's box until the path for a
6
+ * new size arrives (docs/ops-protocol.md, "Custom shapes").
7
+ *
8
+ * An update whose shape's `animatableData` changed carries `frames`: the
9
+ * paths at fractions of the change. When the commit animates the element,
10
+ * the Animator calls `playPathFrames`, which draws the frame for the
11
+ * animation's eased progress on each animation frame and ends on the final
12
+ * path; otherwise the final path is drawn at once.
13
+ */
14
+
15
+ import type { Animation, ShapeProps } from './protocol';
16
+ import { paintFor, type IdSource } from './chartDom';
17
+ import { easedProgress } from './animation';
18
+
19
+ const SVG_NS = 'http://www.w3.org/2000/svg';
20
+
21
+ /** The paths along an animated change (`index[i]` is the path at `progress[i]`). */
22
+ export interface PathFrames {
23
+ progress: number[];
24
+ index: number[];
25
+ paths: string[];
26
+ }
27
+
28
+ interface PathState {
29
+ svg: SVGSVGElement;
30
+ path: SVGPathElement;
31
+ final: string;
32
+ frames: PathFrames | null;
33
+ /** The running playback's frame request. */
34
+ request: number | null;
35
+ }
36
+
37
+ const STATE = new WeakMap<HTMLElement, PathState>();
38
+ let gradientIds = 0;
39
+
40
+ /** Draws `props` (a `"path"` shape) into `el`. */
41
+ export function applyPathShape(el: HTMLElement, props: ShapeProps): void {
42
+ const doc = el.ownerDocument;
43
+ let state = STATE.get(el);
44
+ if (!state || state.svg.parentElement !== el) {
45
+ const svg = doc.createElementNS(SVG_NS, 'svg');
46
+ svg.classList.add('sb-path');
47
+ svg.setAttribute('aria-hidden', 'true');
48
+ const path = doc.createElementNS(SVG_NS, 'path');
49
+ svg.append(doc.createElementNS(SVG_NS, 'defs'), path);
50
+ el.replaceChildren(svg);
51
+ state = { svg, path, final: '', frames: null, request: null };
52
+ STATE.set(el, state);
53
+ }
54
+ stop(state);
55
+ const { svg, path } = state;
56
+ const width = Number(props.size?.width) || 0;
57
+ const height = Number(props.size?.height) || 0;
58
+ if (width > 0 && height > 0) svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
59
+ else svg.removeAttribute('viewBox');
60
+ svg.setAttribute('preserveAspectRatio', 'none');
61
+
62
+ const defs = svg.querySelector('defs')!;
63
+ defs.replaceChildren();
64
+ const ids: IdSource = { next: 0, prefix: `sb-path${gradientIds++}` };
65
+ const bbox = { x: 0, y: 0, width, height };
66
+ const stroke = props.stroke;
67
+ const fill = props.fill ? paintFor(doc, defs, props.fill, bbox, ids) : stroke ? 'none' : 'currentColor';
68
+ path.style.fill = fill;
69
+ path.style.fillRule = props.fillRule === 'evenodd' ? 'evenodd' : 'nonzero';
70
+ if (stroke) {
71
+ path.style.stroke = stroke.color ? paintFor(doc, defs, stroke.color, bbox, ids) : 'currentColor';
72
+ path.style.strokeWidth = String(Number.isFinite(stroke.lineWidth) ? Math.max(0, stroke.lineWidth) : 1);
73
+ path.style.strokeLinecap = stroke.lineCap ?? 'butt';
74
+ path.style.strokeLinejoin = stroke.lineJoin ?? 'miter';
75
+ path.style.strokeMiterlimit = String(stroke.miterLimit ?? 10);
76
+ path.style.strokeDasharray = stroke.dash?.length ? stroke.dash.join(' ') : '';
77
+ path.style.strokeDashoffset = stroke.dashPhase ? String(stroke.dashPhase) : '';
78
+ } else {
79
+ path.style.stroke = 'none';
80
+ }
81
+ // The stroke is not scaled with a stretched view box.
82
+ path.setAttribute('vector-effect', 'non-scaling-stroke');
83
+
84
+ state.final = typeof props.path === 'string' ? props.path : '';
85
+ state.frames = validFrames(props.frames);
86
+ path.setAttribute('d', state.final);
87
+ }
88
+
89
+ /** Whether `el` is a path shape whose last update carried frames. */
90
+ export function hasPathFrames(el: HTMLElement): boolean {
91
+ return STATE.get(el)?.frames != null;
92
+ }
93
+
94
+ /**
95
+ * Plays `el`'s frames in `animation`'s timing: the Animator calls it for an
96
+ * element whose update the commit animates. Returns whether there were frames.
97
+ */
98
+ export function playPathFrames(el: HTMLElement, animation: Animation | undefined): boolean {
99
+ const state = STATE.get(el);
100
+ const frames = state?.frames;
101
+ if (!state || !frames) return false;
102
+ stop(state);
103
+ const view = el.ownerDocument.defaultView;
104
+ const now = () => view?.performance.now() ?? 0;
105
+ const start = now();
106
+ const draw = (): boolean => {
107
+ const elapsed = now() - start;
108
+ const done = isFinished(animation, elapsed);
109
+ state.path.setAttribute('d', done ? state.final : frameAt(frames, easedProgress(animation, elapsed)));
110
+ return done;
111
+ };
112
+ draw();
113
+ const tick = () => {
114
+ if (!el.isConnected || draw()) {
115
+ state.request = null;
116
+ state.path.setAttribute('d', state.final);
117
+ return;
118
+ }
119
+ state.request = view?.requestAnimationFrame(tick) ?? null;
120
+ };
121
+ state.request = view?.requestAnimationFrame(tick) ?? null;
122
+ return true;
123
+ }
124
+
125
+ /** The frame whose progress is nearest `progress`. */
126
+ export function frameAt(frames: PathFrames, progress: number): string {
127
+ let best = 0;
128
+ let distance = Infinity;
129
+ for (let i = 0; i < frames.progress.length; i++) {
130
+ const d = Math.abs(frames.progress[i]! - progress);
131
+ if (d < distance) {
132
+ distance = d;
133
+ best = i;
134
+ }
135
+ }
136
+ return frames.paths[frames.index[best] ?? 0] ?? '';
137
+ }
138
+
139
+ function isFinished(animation: Animation | undefined, elapsed: number): boolean {
140
+ const delay = Math.max(0, (animation?.delay ?? 0) * 1000);
141
+ const duration = Math.max(0, (animation?.duration ?? 0.35) * 1000);
142
+ return elapsed >= delay + duration;
143
+ }
144
+
145
+ function stop(state: PathState): void {
146
+ if (state.request != null) {
147
+ state.svg.ownerDocument.defaultView?.cancelAnimationFrame(state.request);
148
+ state.request = null;
149
+ }
150
+ }
151
+
152
+ function validFrames(frames: ShapeProps['frames']): PathFrames | null {
153
+ if (!frames || !Array.isArray(frames.progress) || !Array.isArray(frames.index) || !Array.isArray(frames.paths)) return null;
154
+ if (frames.progress.length === 0 || frames.progress.length !== frames.index.length) return null;
155
+ return frames;
156
+ }