@swiftbrowser/native 0.17.7 → 0.17.8

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 (70) hide show
  1. package/README.md +79 -2
  2. package/dist/blob.d.ts +60 -0
  3. package/dist/blob.d.ts.map +1 -0
  4. package/dist/blob.js +153 -0
  5. package/dist/blob.js.map +1 -0
  6. package/dist/bridge.d.ts +39 -1
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +11 -3
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +26 -2
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +49 -0
  14. package/dist/hostRequests.d.ts.map +1 -0
  15. package/dist/hostRequests.js +134 -0
  16. package/dist/hostRequests.js.map +1 -0
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/matchMedia.d.ts +43 -0
  22. package/dist/matchMedia.d.ts.map +1 -0
  23. package/dist/matchMedia.js +63 -0
  24. package/dist/matchMedia.js.map +1 -0
  25. package/dist/notifications.d.ts +108 -0
  26. package/dist/notifications.d.ts.map +1 -0
  27. package/dist/notifications.js +251 -0
  28. package/dist/notifications.js.map +1 -0
  29. package/dist/run.d.ts.map +1 -1
  30. package/dist/run.js +9 -1
  31. package/dist/run.js.map +1 -1
  32. package/dist/runtime.d.ts +1 -0
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +58 -0
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/secureStorage.d.ts +20 -0
  37. package/dist/secureStorage.d.ts.map +1 -0
  38. package/dist/secureStorage.js +28 -0
  39. package/dist/secureStorage.js.map +1 -0
  40. package/dist/subtle.d.ts +38 -0
  41. package/dist/subtle.d.ts.map +1 -0
  42. package/dist/subtle.js +72 -0
  43. package/dist/subtle.js.map +1 -0
  44. package/dist/webSocket.d.ts +73 -0
  45. package/dist/webSocket.d.ts.map +1 -0
  46. package/dist/webSocket.js +145 -0
  47. package/dist/webSocket.js.map +1 -0
  48. package/package.json +3 -3
  49. package/src/blob.ts +186 -0
  50. package/src/bridge.ts +49 -1
  51. package/src/fetch.ts +29 -3
  52. package/src/hostRequests.ts +148 -0
  53. package/src/index.ts +7 -0
  54. package/src/matchMedia.ts +79 -0
  55. package/src/notifications.ts +317 -0
  56. package/src/run.ts +10 -1
  57. package/src/runtime.ts +60 -0
  58. package/src/secureStorage.ts +39 -0
  59. package/src/subtle.ts +87 -0
  60. package/src/webSocket.ts +175 -0
  61. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +166 -0
  62. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +437 -0
  63. package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
  64. package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
  65. package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
  66. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
  67. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +26 -0
  68. package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
  69. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +16 -5
  70. package/wasm/SwiftBrowserNative.wasm +2 -2
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The host requests `@swiftbrowser/react` sends to the browser environment
3
+ * (`ShareLink`, `fileImporter`, the clipboard, a color chooser: the web
4
+ * renderer's `performHostRequest` with its `browserEnvironment`), answered by
5
+ * the native app instead: the bridge's `hostRequest(id, kind, json)` performs
6
+ * one with UIKit (a share sheet, the photo or document picker, the
7
+ * pasteboard, the color picker, `ASWebAuthenticationSession`), answering
8
+ * `__sbHostResponse(id, { value } | { error })`. `installHostEnvironment`
9
+ * fills the environment's gaps with these, leaving what the page has (the
10
+ * round trip runs in a browser with a share sheet of its own).
11
+ *
12
+ * `webAuthenticate` is the sign-in window apps open for OAuth: the system's
13
+ * sheet, resolving with the callback URL.
14
+ */
15
+
16
+ import { objectURL, SimpleBlob, SimpleFile } from './blob';
17
+ import type { NativeBridge } from './bridge';
18
+ import { base64Decode, base64Encode } from './encoding';
19
+
20
+ export type HostRequestBridge = Required<Pick<NativeBridge, 'hostRequest'>>;
21
+
22
+ /** A file as it crosses the bridge: its name, MIME type and bytes (base64). */
23
+ export interface BridgedFile {
24
+ name: string;
25
+ type: string;
26
+ data: string;
27
+ }
28
+
29
+ export interface WebAuthenticateOptions {
30
+ /** The scheme of the callback URL the sign-in ends with (`myapp` for `myapp://auth`); universal links need none. */
31
+ callbackScheme?: string;
32
+ /** Whether the session shares no cookies with the system browser. */
33
+ ephemeral?: boolean;
34
+ }
35
+
36
+ let bridge: HostRequestBridge | null = null;
37
+ let nextId = 1;
38
+ const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
39
+
40
+ /** Points the requests at the bridge (the runtime, once per install). */
41
+ export function installHostRequests(native: HostRequestBridge): void {
42
+ bridge = native;
43
+ }
44
+
45
+ /** The bridge's `__sbHostResponse(id, result)`. */
46
+ export function receiveHostResponse(id: number, result: string | { value?: unknown; error?: string }): void {
47
+ const waiting = pending.get(id);
48
+ if (!waiting) return;
49
+ pending.delete(id);
50
+ let parsed: { value?: unknown; error?: string };
51
+ try {
52
+ parsed = typeof result === 'string' ? (JSON.parse(result) as { value?: unknown; error?: string }) : result;
53
+ } catch {
54
+ waiting.reject(new Error('host: malformed response'));
55
+ return;
56
+ }
57
+ if (typeof parsed.error === 'string') {
58
+ const error = new Error(parsed.error);
59
+ if (parsed.error.startsWith('cancelled')) error.name = 'AbortError';
60
+ waiting.reject(error);
61
+ } else {
62
+ waiting.resolve(parsed.value);
63
+ }
64
+ }
65
+
66
+ /** Performs one request of `kind` with `params`; resolves with the host's value. */
67
+ export function hostRequest<T = unknown>(kind: string, params: Record<string, unknown>): Promise<T> {
68
+ return new Promise<T>((resolve, reject) => {
69
+ if (!bridge) {
70
+ reject(new Error(`unsupported: no native host for ${kind}`));
71
+ return;
72
+ }
73
+ const id = nextId++;
74
+ pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
75
+ bridge.hostRequest(id, kind, JSON.stringify(params));
76
+ });
77
+ }
78
+
79
+ /** Forgets the bridge and the waiting requests, for tests. */
80
+ export function resetHostRequests(): void {
81
+ bridge = null;
82
+ pending.clear();
83
+ nextId = 1;
84
+ }
85
+
86
+ async function bridgedFile(file: SimpleBlob & { name?: string }): Promise<BridgedFile> {
87
+ return { name: file.name ?? 'file', type: file.type, data: base64Encode(file.bytes()) };
88
+ }
89
+
90
+ function filesOf(value: unknown): SimpleFile[] {
91
+ const files = (value as { files?: BridgedFile[] } | undefined)?.files ?? [];
92
+ return files.map((file) => new SimpleFile([base64Decode(file.data)], file.name, { type: file.type }));
93
+ }
94
+
95
+ /**
96
+ * Opens the system's sign-in sheet at `url` and resolves with the callback
97
+ * URL it ends with; rejects with an `AbortError` when the viewer closes it.
98
+ */
99
+ export async function webAuthenticate(url: string, options: WebAuthenticateOptions = {}): Promise<string> {
100
+ const params: Record<string, unknown> = { action: 'start', url };
101
+ if (options.callbackScheme) params['callbackScheme'] = options.callbackScheme;
102
+ if (options.ephemeral !== undefined) params['ephemeral'] = options.ephemeral;
103
+ const result = await hostRequest<{ url?: string }>('webAuth', params);
104
+ if (typeof result?.url !== 'string') throw new Error('webAuth: the sign-in ended without a URL');
105
+ return result.url;
106
+ }
107
+
108
+ /** The environment fields the host answers, shaped as the web renderer's `HostRequestEnvironment`. */
109
+ export function nativeHostEnvironment(): Record<string, unknown> {
110
+ return {
111
+ share: async (data: { title?: string; text?: string; url?: string; files?: SimpleFile[] }): Promise<void> => {
112
+ const params: Record<string, unknown> = {};
113
+ if (data.title) params['title'] = data.title;
114
+ if (data.text) params['text'] = data.text;
115
+ if (data.url) params['url'] = data.url;
116
+ if (data.files?.length) params['files'] = await Promise.all(data.files.map(bridgedFile));
117
+ const result = await hostRequest<{ outcome?: string }>('share', params);
118
+ if (result?.outcome === 'cancelled') {
119
+ const error = new Error('cancelled: the viewer closed the share sheet');
120
+ error.name = 'AbortError';
121
+ throw error;
122
+ }
123
+ },
124
+ canShare: () => true,
125
+ download: (blob: SimpleBlob, filename: string): void => {
126
+ void hostRequest('share', { files: [{ name: filename, type: blob.type, data: base64Encode(blob.bytes()) }] }).catch(() => undefined);
127
+ },
128
+ pickFiles: async (options: { accept: string; multiple: boolean }): Promise<SimpleFile[]> => filesOf(await hostRequest('pickFiles', { accept: options.accept, multiple: options.multiple })),
129
+ objectURL: (file: SimpleBlob): string => objectURL(file),
130
+ clipboard: {
131
+ writeText: (text: string): Promise<void> => hostRequest('clipboard', { action: 'write', text }).then(() => undefined),
132
+ writeImage: async (png: Promise<SimpleBlob>): Promise<void> => {
133
+ const file = await png;
134
+ await hostRequest('clipboard', { action: 'write', files: [{ name: 'image.png', type: file.type || 'image/png', data: base64Encode(file.bytes()) }] });
135
+ },
136
+ readText: async (): Promise<string> => (await hostRequest<{ text?: string }>('clipboard', { action: 'readText' }))?.text ?? '',
137
+ },
138
+ pickColor: async (color: string): Promise<string | null> => (await hostRequest<{ color?: string }>('pickColor', { color }))?.color ?? null,
139
+ };
140
+ }
141
+
142
+ /** Fills what `environment` lacks with the host's answers; what the page has stays. */
143
+ export function installHostEnvironment(environment: Record<string, unknown>): void {
144
+ if (!bridge) return;
145
+ for (const [key, value] of Object.entries(nativeHostEnvironment())) {
146
+ if (environment[key] === undefined) environment[key] = value;
147
+ }
148
+ }
package/src/index.ts CHANGED
@@ -17,7 +17,14 @@ export { installRuntime, type InstallOptions } from './runtime';
17
17
  export { nativeBridge, launchArguments, type NativeBridge, type NativeFetchRequest, type NativeFetchResult, type WorkerEngine } from './bridge';
18
18
  export { SimpleHeaders, SimpleRequest, SimpleResponse, SimpleAbortController, SimpleAbortSignal, createFetch } from './fetch';
19
19
  export { SimpleURL, SimpleURLSearchParams } from './url';
20
+ export { SimpleBlob, SimpleFile, SimpleFormData, encodeMultipart, objectURL } from './blob';
21
+ export { NativeWebSocket, type NativeWebSocketEvent } from './webSocket';
22
+ export { createSubtleCrypto } from './subtle';
23
+ export { matchMedia, updateMediaEnvironment, type MediaEnvironment } from './matchMedia';
24
+ export { hostRequest, webAuthenticate, nativeHostEnvironment, installHostEnvironment, type WebAuthenticateOptions, type BridgedFile } from './hostRequests';
25
+ export { secureStorage, createSecureStorage, type SecureStorage } from './secureStorage';
20
26
  export { SimpleTextEncoder, SimpleTextDecoder, base64Encode, base64Decode, utf8Encode, utf8Decode } from './encoding';
21
27
  export { createWorker, NativeWorker, encodeMessage, decodeMessage, workerName, type NativeWorkerOptions, type WorkerLike, type WorkerMessageEvent, type WorkerErrorEvent } from './worker';
22
28
  export { onOpenURL } from './openURL';
29
+ export { onNotification, registerForPush, type NotificationEvent, type NativeNotificationOptions, type RegisterForPushOptions } from './notifications';
23
30
  export { createMediaSession, NativeMediaMetadata, type NativeMediaSession, type MediaMetadataInit, type MediaActionDetails, type MediaActionHandler, type MediaPositionState } from './mediaSession';
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `matchMedia` for the native app: `(prefers-color-scheme: dark)` and
3
+ * `light` follow the color scheme the host sends with the environment
4
+ * (run.ts hands it over), `(prefers-reduced-motion: reduce)` the host's
5
+ * accessibility setting when it sends one; every other query matches
6
+ * nothing. Listeners hear a change when the environment does.
7
+ */
8
+
9
+ export interface MediaQueryListLike {
10
+ readonly media: string;
11
+ readonly matches: boolean;
12
+ onchange: ((event: { matches: boolean; media: string }) => void) | null;
13
+ addEventListener(type: 'change', listener: (event: { matches: boolean; media: string }) => void): void;
14
+ removeEventListener(type: 'change', listener: (event: { matches: boolean; media: string }) => void): void;
15
+ /** The older names. */
16
+ addListener(listener: (event: { matches: boolean; media: string }) => void): void;
17
+ removeListener(listener: (event: { matches: boolean; media: string }) => void): void;
18
+ }
19
+
20
+ export interface MediaEnvironment {
21
+ colorScheme?: 'light' | 'dark';
22
+ reducedMotion?: boolean;
23
+ }
24
+
25
+ type Listener = (event: { matches: boolean; media: string }) => void;
26
+
27
+ let environment: MediaEnvironment = {};
28
+ const lists = new Set<{ media: string; matches: boolean; listeners: Set<Listener>; onchange: Listener | null }>();
29
+
30
+ function evaluate(query: string): boolean {
31
+ const normalized = query.trim().toLowerCase().replace(/\s+/g, '');
32
+ if (normalized === '(prefers-color-scheme:dark)') return environment.colorScheme === 'dark';
33
+ if (normalized === '(prefers-color-scheme:light)') return environment.colorScheme !== 'dark';
34
+ if (normalized === '(prefers-reduced-motion:reduce)') return environment.reducedMotion === true;
35
+ if (normalized === '(prefers-reduced-motion:no-preference)') return environment.reducedMotion !== true;
36
+ return false;
37
+ }
38
+
39
+ /** The runtime's `matchMedia(query)`. */
40
+ export function matchMedia(query: string): MediaQueryListLike {
41
+ const media = String(query);
42
+ const state = { media, matches: evaluate(media), listeners: new Set<Listener>(), onchange: null as Listener | null };
43
+ lists.add(state);
44
+ return {
45
+ media,
46
+ get matches() {
47
+ return state.matches;
48
+ },
49
+ get onchange() {
50
+ return state.onchange;
51
+ },
52
+ set onchange(listener) {
53
+ state.onchange = listener;
54
+ },
55
+ addEventListener: (_type, listener) => void state.listeners.add(listener),
56
+ removeEventListener: (_type, listener) => void state.listeners.delete(listener),
57
+ addListener: (listener) => void state.listeners.add(listener),
58
+ removeListener: (listener) => void state.listeners.delete(listener),
59
+ };
60
+ }
61
+
62
+ /** What the host sent with the environment; lists whose answer changed tell their listeners. */
63
+ export function updateMediaEnvironment(next: MediaEnvironment): void {
64
+ environment = { ...environment, ...next };
65
+ for (const state of lists) {
66
+ const matches = evaluate(state.media);
67
+ if (matches === state.matches) continue;
68
+ state.matches = matches;
69
+ const event = { matches, media: state.media };
70
+ state.onchange?.(event);
71
+ for (const listener of state.listeners) listener(event);
72
+ }
73
+ }
74
+
75
+ /** Forgets the environment and the lists, for tests. */
76
+ export function resetMediaEnvironment(): void {
77
+ environment = {};
78
+ lists.clear();
79
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Notifications in the native app: the browser's `Notification` (the
3
+ * permission, local notifications and their clicks) and `navigator.setAppBadge`
4
+ * over the host's UserNotifications (Native/Sources/SwiftBrowserNative/Host/NotificationBridge.swift),
5
+ * and remote notifications: `registerForPush` resolves with the device token
6
+ * (APNs, as hex) and `onNotification` delivers what the viewer opened and what
7
+ * arrived while the app is open.
8
+ *
9
+ * ```ts
10
+ * const token = await registerForPush();
11
+ * await fetch('/api/devices', { method: 'POST', body: JSON.stringify({ token }) });
12
+ * useEffect(() => onNotification((n) => { if (n.event === 'opened') show(n.data.postId); }), []);
13
+ * ```
14
+ *
15
+ * In the page (the round trip, or any browser) the host has no bridge for
16
+ * these: `Notification` is the browser's own, and `registerForPush` subscribes
17
+ * to Web Push through the site's service worker (`sw.js`, which the web
18
+ * renderer ships: docs/ops-protocol.md, "Push notifications") with the
19
+ * `applicationServerKey` given, resolving with the subscription's JSON as
20
+ * the token; the worker's messages become `onNotification` events. A server
21
+ * sends to both with one payload: APNs' (`aps.alert`, `aps.badge`), which
22
+ * the worker reads too.
23
+ */
24
+
25
+ import type { NativeBridge } from './bridge';
26
+
27
+ export type NotificationPermissionState = 'default' | 'granted' | 'denied';
28
+
29
+ /** What the viewer did with a notification, or one that arrived while the app is open. */
30
+ export interface NotificationEvent {
31
+ event: 'opened' | 'received';
32
+ /** Whether it came from the push service. */
33
+ remote: boolean;
34
+ /** The notification's identifier (`tag`, or the push payload's `id`). */
35
+ id: string;
36
+ title: string;
37
+ body: string;
38
+ /** A push's payload, or what `new Notification(title, { data })` gave. */
39
+ data: Record<string, unknown>;
40
+ /** `opened`: the action the viewer chose, empty for the notification itself. */
41
+ action?: string;
42
+ }
43
+
44
+ export interface NativeNotificationOptions {
45
+ body?: string;
46
+ /** The notification's identifier: a later one with the same tag replaces it. */
47
+ tag?: string;
48
+ data?: unknown;
49
+ silent?: boolean;
50
+ /** The app badge to set with it. */
51
+ badge?: number;
52
+ }
53
+
54
+ export interface RegisterForPushOptions {
55
+ /** The Web Push application server key (the server's VAPID public key, base64url), for the page. */
56
+ applicationServerKey?: string;
57
+ }
58
+
59
+ type Listener = (notification: NotificationEvent) => void;
60
+
61
+ /** The message types the web renderer's service worker posts (Web/public/sw.js). */
62
+ const PUSH_MESSAGE = 'swiftbrowser:push';
63
+ const CLICK_MESSAGE = 'swiftbrowser:notificationclick';
64
+
65
+ /** The bridge functions notifications need. */
66
+ export type NotificationBridge = Pick<NativeBridge, 'notificationsReady' | 'notificationPermission' | 'notificationShow' | 'notificationClose' | 'setBadge' | 'pushRegister'>;
67
+
68
+ export interface NotificationGlobals {
69
+ Notification: NativeNotificationClass;
70
+ __sbNotificationPermission: (id: number, permission: NotificationPermissionState) => void;
71
+ __sbPushRegistered: (id: number, result: { token?: string; error?: string }) => void;
72
+ __sbNotification: (json: string) => void;
73
+ /** `navigator.setAppBadge` and `clearAppBadge`. */
74
+ setAppBadge: (count?: number) => Promise<void>;
75
+ clearAppBadge: () => Promise<void>;
76
+ /** The native `registerForPush`. */
77
+ registerForPush: (options?: RegisterForPushOptions) => Promise<string>;
78
+ }
79
+
80
+ export interface NativeNotificationClass {
81
+ new (title: string, options?: NativeNotificationOptions): NativeNotificationInstance;
82
+ readonly permission: NotificationPermissionState;
83
+ requestPermission(): Promise<NotificationPermissionState>;
84
+ }
85
+
86
+ export interface NativeNotificationInstance {
87
+ readonly title: string;
88
+ readonly body: string;
89
+ readonly tag: string;
90
+ readonly data: unknown;
91
+ onclick: ((event: { notification: NativeNotificationInstance; action: string }) => void) | null;
92
+ close(): void;
93
+ }
94
+
95
+ const listeners = new Set<Listener>();
96
+ let pending: NotificationEvent[] = [];
97
+ /** Where the events come from once installed: the bridge, or the page's service worker. */
98
+ let installed: 'native' | 'page' | null = null;
99
+ let nativeRegister: ((options?: RegisterForPushOptions) => Promise<string>) | null = null;
100
+
101
+ /**
102
+ * Listens for notifications the viewer opened and ones that arrived while
103
+ * the app is open; returns what stops listening. Events before any listener
104
+ * (a tap that launched the app) wait for the first one.
105
+ */
106
+ export function onNotification(listener: Listener): () => void {
107
+ listeners.add(listener);
108
+ if (installed === null && typeof navigator !== 'undefined') listenToPage();
109
+ if (pending.length > 0) {
110
+ const events = pending;
111
+ pending = [];
112
+ for (const event of events) listener(event);
113
+ }
114
+ return () => {
115
+ listeners.delete(listener);
116
+ };
117
+ }
118
+
119
+ /** The runtime's `__sbNotification(json)`, and what the page's worker messages become. */
120
+ export function receiveNotification(event: NotificationEvent): void {
121
+ if (listeners.size === 0) {
122
+ pending.push(event);
123
+ return;
124
+ }
125
+ for (const listener of listeners) listener(event);
126
+ }
127
+
128
+ /**
129
+ * Registers for remote notifications and resolves with the device token:
130
+ * APNs' as hex in the native app, the Web Push subscription's JSON in the
131
+ * page (which needs `applicationServerKey`). Rejects with the reason the
132
+ * system or the browser refused.
133
+ */
134
+ export function registerForPush(options: RegisterForPushOptions = {}): Promise<string> {
135
+ if (nativeRegister) return nativeRegister(options);
136
+ return registerForWebPush(options);
137
+ }
138
+
139
+ /** Resets the listeners and the delivery source, for tests. */
140
+ export function resetNotifications(): void {
141
+ listeners.clear();
142
+ pending = [];
143
+ installed = null;
144
+ nativeRegister = null;
145
+ }
146
+
147
+ // MARK: The native app
148
+
149
+ /** The globals for a host with the notification bridge. */
150
+ export function createNotificationGlobals(native: NotificationBridge): NotificationGlobals {
151
+ installed = 'native';
152
+ let permission: NotificationPermissionState = 'default';
153
+ let nextId = 1;
154
+ const permissionWaits = new Map<number, (permission: NotificationPermissionState) => void>();
155
+ const registrations = new Map<number, { resolve: (token: string) => void; reject: (error: Error) => void }>();
156
+ const open = new Map<string, NativeNotification>();
157
+
158
+ const askPermission = (request: boolean): Promise<NotificationPermissionState> =>
159
+ new Promise((resolve) => {
160
+ const id = nextId++;
161
+ permissionWaits.set(id, resolve);
162
+ native.notificationPermission?.(id, request);
163
+ });
164
+
165
+ class NativeNotification implements NativeNotificationInstance {
166
+ static get permission(): NotificationPermissionState {
167
+ return permission;
168
+ }
169
+
170
+ static requestPermission(): Promise<NotificationPermissionState> {
171
+ return askPermission(true);
172
+ }
173
+
174
+ readonly title: string;
175
+ readonly body: string;
176
+ readonly tag: string;
177
+ readonly data: unknown;
178
+ onclick: NativeNotificationInstance['onclick'] = null;
179
+
180
+ constructor(title: string, options: NativeNotificationOptions = {}) {
181
+ this.title = String(title);
182
+ this.body = options.body ?? '';
183
+ this.tag = options.tag ?? `notification-${nextId++}`;
184
+ this.data = options.data;
185
+ const show: Record<string, unknown> = { id: this.tag, title: this.title, body: this.body, silent: options.silent ?? false };
186
+ if (options.badge !== undefined) show['badge'] = options.badge;
187
+ if (options.data !== undefined && typeof options.data === 'object' && options.data !== null) show['data'] = options.data;
188
+ const problem = native.notificationShow?.(JSON.stringify(show));
189
+ if (problem) throw new TypeError(problem);
190
+ open.set(this.tag, this);
191
+ }
192
+
193
+ close(): void {
194
+ if (open.get(this.tag) === this) open.delete(this.tag);
195
+ native.notificationClose?.(JSON.stringify([this.tag]));
196
+ }
197
+ }
198
+
199
+ const setAppBadge = async (count = 0): Promise<void> => {
200
+ native.setBadge?.(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0);
201
+ };
202
+
203
+ nativeRegister = (options = {}) =>
204
+ new Promise<string>((resolve, reject) => {
205
+ const id = nextId++;
206
+ registrations.set(id, { resolve, reject });
207
+ native.pushRegister?.(id, options.applicationServerKey);
208
+ });
209
+
210
+ // The permission as it stands, so `Notification.permission` reads right
211
+ // from the first render; and the app listens, so what came before arrives.
212
+ void askPermission(false);
213
+ native.notificationsReady?.();
214
+
215
+ return {
216
+ Notification: NativeNotification,
217
+ __sbNotificationPermission: (id, state) => {
218
+ permission = state;
219
+ const resolve = permissionWaits.get(id);
220
+ permissionWaits.delete(id);
221
+ resolve?.(state);
222
+ },
223
+ __sbPushRegistered: (id, result) => {
224
+ const registration = registrations.get(id);
225
+ registrations.delete(id);
226
+ if (!registration) return;
227
+ if (typeof result.token === 'string') registration.resolve(result.token);
228
+ else registration.reject(new Error(result.error ?? 'push: registration failed'));
229
+ },
230
+ __sbNotification: (json) => {
231
+ let event: NotificationEvent;
232
+ try {
233
+ event = JSON.parse(json) as NotificationEvent;
234
+ } catch {
235
+ return;
236
+ }
237
+ if (event.event === 'opened') {
238
+ const notification = open.get(event.id);
239
+ if (notification) {
240
+ open.delete(event.id);
241
+ notification.onclick?.({ notification, action: event.action ?? '' });
242
+ }
243
+ }
244
+ receiveNotification(event);
245
+ },
246
+ setAppBadge,
247
+ clearAppBadge: () => setAppBadge(0),
248
+ registerForPush: nativeRegister,
249
+ };
250
+ }
251
+
252
+ // MARK: The page
253
+
254
+ /** The event a service worker message carries (Web/src/notifications.ts), or null. */
255
+ export function notificationEventOf(data: unknown): NotificationEvent | null {
256
+ if (typeof data !== 'object' || data === null) return null;
257
+ const message = data as { type?: unknown; payload?: unknown; action?: unknown };
258
+ if (message.type !== PUSH_MESSAGE && message.type !== CLICK_MESSAGE) return null;
259
+ const payload = typeof message.payload === 'object' && message.payload !== null ? (message.payload as Record<string, unknown>) : {};
260
+ const aps = typeof payload['aps'] === 'object' && payload['aps'] !== null ? (payload['aps'] as Record<string, unknown>) : {};
261
+ const alert = typeof aps['alert'] === 'object' && aps['alert'] !== null ? (aps['alert'] as Record<string, unknown>) : {};
262
+ const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
263
+ const event: NotificationEvent = {
264
+ event: message.type === CLICK_MESSAGE ? 'opened' : 'received',
265
+ remote: true,
266
+ id: text(payload['id']) ?? text(payload['tag']) ?? '',
267
+ title: text(payload['title']) ?? text(alert['title']) ?? '',
268
+ body: text(payload['body']) ?? text(alert['body']) ?? text(aps['alert']) ?? '',
269
+ data: payload,
270
+ };
271
+ if (message.type === CLICK_MESSAGE) event.action = typeof message.action === 'string' ? message.action : '';
272
+ return event;
273
+ }
274
+
275
+ function listenToPage(): void {
276
+ installed = 'page';
277
+ const container = navigator.serviceWorker;
278
+ if (!container || typeof container.addEventListener !== 'function') return;
279
+ container.addEventListener('message', (message: MessageEvent) => {
280
+ const event = notificationEventOf(message.data);
281
+ if (event) receiveNotification(event);
282
+ });
283
+ }
284
+
285
+ /** A VAPID public key (base64url) as the bytes `subscribe` takes. */
286
+ function keyBytes(key: string): Uint8Array<ArrayBuffer> {
287
+ const base64 = key.replace(/-/g, '+').replace(/_/g, '/');
288
+ const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
289
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length));
290
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
291
+ return bytes;
292
+ }
293
+
294
+ async function registerForWebPush(options: RegisterForPushOptions): Promise<string> {
295
+ if (typeof navigator === 'undefined' || !navigator.serviceWorker || typeof PushManager === 'undefined') {
296
+ throw new Error('push: this browser has no push service');
297
+ }
298
+ if (!options.applicationServerKey) throw new Error('push: no applicationServerKey');
299
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') await Notification.requestPermission();
300
+ if (typeof Notification !== 'undefined' && Notification.permission !== 'granted') throw new Error('push: notifications are not allowed');
301
+ if (installed === null) listenToPage();
302
+ const scope = new URL('./', typeof document !== 'undefined' ? document.baseURI : location.href).href;
303
+ const existing = await navigator.serviceWorker.getRegistration(scope);
304
+ const registration = existing?.active && existing.scope === scope ? existing : await navigator.serviceWorker.register(new URL('sw.js', scope).href, { scope });
305
+ await navigator.serviceWorker.ready;
306
+ const init: PushSubscriptionOptionsInit = { userVisibleOnly: true, applicationServerKey: keyBytes(options.applicationServerKey) };
307
+ const current = await registration.pushManager.getSubscription();
308
+ let subscription: PushSubscription;
309
+ try {
310
+ subscription = current ?? (await registration.pushManager.subscribe(init));
311
+ } catch (error) {
312
+ if (!current) throw error;
313
+ await current.unsubscribe().catch(() => undefined);
314
+ subscription = await registration.pushManager.subscribe(init);
315
+ }
316
+ return JSON.stringify(subscription.toJSON());
317
+ }
package/src/run.ts CHANGED
@@ -11,7 +11,10 @@
11
11
  import { createSwiftUIRoot, type SwiftUIRoot } from '@swiftbrowser/react';
12
12
  import type { Op, RendererEvent } from '@swiftbrowser/web/src/protocol';
13
13
  import type { ReactNode } from 'react';
14
+ import { browserEnvironment } from '@swiftbrowser/web/src/hostRequests';
14
15
  import { nativeBridge, type NativeBridge } from './bridge';
16
+ import { installHostEnvironment } from './hostRequests';
17
+ import { updateMediaEnvironment } from './matchMedia';
15
18
 
16
19
  export interface RunOptions {
17
20
  /** The bridge to render through; the global `__sbNative` by default. */
@@ -41,6 +44,9 @@ export function run(element: ReactNode, options: RunOptions = {}): Running {
41
44
  const native = options.native ?? nativeBridge();
42
45
  if (!native) throw new Error('run(): no native bridge (__sbNative); this entry runs inside a SwiftBrowser Native app');
43
46
  const report = options.onError ?? ((error: unknown) => native.log('error', error instanceof Error ? (error.stack ?? error.message) : String(error)));
47
+ // What `ShareLink`, `fileImporter` and the clipboard ask the browser for,
48
+ // answered by the host where the page has nothing (hostRequests.ts).
49
+ installHostEnvironment(browserEnvironment as unknown as Record<string, unknown>);
44
50
 
45
51
  if (current) {
46
52
  // Fast Refresh (devBuild.ts): the app bundle was evaluated again. Its
@@ -68,7 +74,10 @@ export function run(element: ReactNode, options: RunOptions = {}): Running {
68
74
  if (parsed.type === 'environment') {
69
75
  const fields = parsed as EnvironmentFields;
70
76
  const next: Partial<{ colorScheme: 'light' | 'dark'; dynamicTypeSize: never; horizontalSizeClass: 'compact' | 'regular' }> = {};
71
- if (fields.colorScheme) next.colorScheme = fields.colorScheme;
77
+ if (fields.colorScheme) {
78
+ next.colorScheme = fields.colorScheme;
79
+ updateMediaEnvironment({ colorScheme: fields.colorScheme });
80
+ }
72
81
  if (fields.dynamicTypeSize) (next as Record<string, unknown>)['dynamicTypeSize'] = fields.dynamicTypeSize;
73
82
  if (fields.horizontalSizeClass) next.horizontalSizeClass = fields.horizontalSizeClass;
74
83
  if (Object.keys(next).length > 0) root.setEnvironment(next);