@swiftbrowser/web 0.1.0 → 0.2.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 (98) hide show
  1. package/README.md +43 -6
  2. package/app/index.html +11 -0
  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 +11 -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 +18 -1
  69. package/src/landing.ts +2 -2
  70. package/src/layout/chart.ts +9 -3
  71. package/src/layout/engine.ts +279 -25
  72. package/src/layout/index.ts +3 -1
  73. package/src/layout/text.ts +217 -1
  74. package/src/layout/types.ts +31 -0
  75. package/src/layout/typography.ts +24 -7
  76. package/src/layout/video.ts +24 -0
  77. package/src/layoutDom.ts +53 -3
  78. package/src/main.ts +124 -4
  79. package/src/media.ts +132 -0
  80. package/src/mock.ts +2 -1
  81. package/src/notifications.ts +109 -0
  82. package/src/pathShape.ts +156 -0
  83. package/src/protocol.ts +761 -12
  84. package/src/renderer.ts +584 -36
  85. package/src/richText.ts +65 -0
  86. package/src/scanner.ts +234 -0
  87. package/src/scrollTo.ts +123 -0
  88. package/src/styles.css +212 -7
  89. package/src/styles.ts +31 -0
  90. package/src/swiftData.ts +128 -0
  91. package/src/symbols.ts +564 -21
  92. package/src/translation.ts +126 -0
  93. package/src/video.ts +299 -0
  94. package/src/vite-env.d.ts +6 -0
  95. package/src/wasm.ts +227 -32
  96. package/src/webAuth.ts +267 -0
  97. package/src/webSocket.ts +148 -0
  98. package/src/zoneinfo.ts +269 -0
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
  },
@@ -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
+ }