@swiftbrowser/native 0.17.7 → 0.18.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.
- package/README.md +99 -11
- package/dist/blob.d.ts +60 -0
- package/dist/blob.d.ts.map +1 -0
- package/dist/blob.js +153 -0
- package/dist/blob.js.map +1 -0
- package/dist/bridge.d.ts +65 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/fetch.d.ts +11 -3
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +26 -2
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +55 -0
- package/dist/hostRequests.d.ts.map +1 -0
- package/dist/hostRequests.js +196 -0
- package/dist/hostRequests.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/matchMedia.d.ts +43 -0
- package/dist/matchMedia.d.ts.map +1 -0
- package/dist/matchMedia.js +63 -0
- package/dist/matchMedia.js.map +1 -0
- package/dist/notifications.d.ts +108 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +251 -0
- package/dist/notifications.js.map +1 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +23 -2
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +69 -0
- package/dist/runtime.js.map +1 -1
- package/dist/secureStorage.d.ts +20 -0
- package/dist/secureStorage.d.ts.map +1 -0
- package/dist/secureStorage.js +28 -0
- package/dist/secureStorage.js.map +1 -0
- package/dist/site.d.ts +3 -3
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +1 -1
- package/dist/site.js.map +1 -1
- package/dist/subtle.d.ts +38 -0
- package/dist/subtle.d.ts.map +1 -0
- package/dist/subtle.js +72 -0
- package/dist/subtle.js.map +1 -0
- package/dist/undo.d.ts +23 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +44 -0
- package/dist/undo.js.map +1 -0
- package/dist/webSocket.d.ts +73 -0
- package/dist/webSocket.d.ts.map +1 -0
- package/dist/webSocket.js +145 -0
- package/dist/webSocket.js.map +1 -0
- package/package.json +3 -3
- package/src/blob.ts +186 -0
- package/src/bridge.ts +74 -1
- package/src/fetch.ts +29 -3
- package/src/hostRequests.ts +216 -0
- package/src/index.ts +8 -0
- package/src/matchMedia.ts +79 -0
- package/src/notifications.ts +317 -0
- package/src/run.ts +21 -2
- package/src/runtime.ts +71 -0
- package/src/secureStorage.ts +39 -0
- package/src/site.ts +3 -3
- package/src/subtle.ts +87 -0
- package/src/undo.ts +47 -0
- package/src/webSocket.ts +175 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +199 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +154 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +211 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +451 -0
- package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
- package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Host/TextMeasureBridge.swift +139 -0
- package/swift/Sources/SwiftBrowserNative/Host/UndoBridge.swift +170 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +56 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +362 -16
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/DeleteBackward.swift +147 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/KeyboardBar.swift +84 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +116 -11
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +12 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Shapes.swift +30 -9
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +163 -10
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextAndImages.swift +17 -4
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +8 -3
- package/swift/Sources/SwiftBrowserNative/Views/NativeRootView.swift +8 -0
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +41 -0
- package/swift/Sources/SwiftBrowserProtocol/Op.swift +27 -4
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +110 -3
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +47 -0
- package/swift/Sources/SwiftBrowserProtocol/SVGPath.swift +260 -0
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +58 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +22 -2
- package/wasm/SwiftBrowserNative.wasm +2 -2
|
@@ -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
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
* ran and its pass was applied (docs/native.md, "Synchronous calls").
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { createSwiftUIRoot, type SwiftUIRoot } from '@swiftbrowser/react';
|
|
11
|
+
import { createSwiftUIRoot, installTextMeasurer, type SwiftUIRoot, type TextMeasurerFunction } 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,18 @@ 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>);
|
|
50
|
+
// `measureText` answers from the host's text system (HostBridge.swift, `measureText`).
|
|
51
|
+
const measurer: TextMeasurerFunction | null = native.measureText
|
|
52
|
+
? (text, font, width) => {
|
|
53
|
+
const size = native.measureText!(text, JSON.stringify(font), width);
|
|
54
|
+
if (!size) throw new Error('measureText: the native host could not measure the text');
|
|
55
|
+
return size;
|
|
56
|
+
}
|
|
57
|
+
: null;
|
|
58
|
+
if (measurer) installTextMeasurer(measurer);
|
|
44
59
|
|
|
45
60
|
if (current) {
|
|
46
61
|
// Fast Refresh (devBuild.ts): the app bundle was evaluated again. Its
|
|
@@ -68,7 +83,10 @@ export function run(element: ReactNode, options: RunOptions = {}): Running {
|
|
|
68
83
|
if (parsed.type === 'environment') {
|
|
69
84
|
const fields = parsed as EnvironmentFields;
|
|
70
85
|
const next: Partial<{ colorScheme: 'light' | 'dark'; dynamicTypeSize: never; horizontalSizeClass: 'compact' | 'regular' }> = {};
|
|
71
|
-
if (fields.colorScheme)
|
|
86
|
+
if (fields.colorScheme) {
|
|
87
|
+
next.colorScheme = fields.colorScheme;
|
|
88
|
+
updateMediaEnvironment({ colorScheme: fields.colorScheme });
|
|
89
|
+
}
|
|
72
90
|
if (fields.dynamicTypeSize) (next as Record<string, unknown>)['dynamicTypeSize'] = fields.dynamicTypeSize;
|
|
73
91
|
if (fields.horizontalSizeClass) next.horizontalSizeClass = fields.horizontalSizeClass;
|
|
74
92
|
if (Object.keys(next).length > 0) root.setEnvironment(next);
|
|
@@ -90,6 +108,7 @@ export function run(element: ReactNode, options: RunOptions = {}): Running {
|
|
|
90
108
|
dispatch,
|
|
91
109
|
unmount() {
|
|
92
110
|
root.unmount();
|
|
111
|
+
if (measurer) installTextMeasurer(measurer, true);
|
|
93
112
|
if (g['__sbDispatch']) delete g['__sbDispatch'];
|
|
94
113
|
current = null;
|
|
95
114
|
},
|
package/src/runtime.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* `mediaSession`, mediaSession.ts), `Image` (image.ts, on a bridge with `probeImage`) with the
|
|
16
16
|
* bridge's `__sbImageProbed`, `Worker` (worker.ts) with the bridge's
|
|
17
17
|
* `__sbWorkerMessage` and `__sbWorkerError`, `__sbOpenURL` (openURL.ts),
|
|
18
|
+
* `__sbNotificationPermission`, `__sbPushRegistered` and `__sbNotification` (notifications.ts),
|
|
19
|
+
* `__sbUndoCommand` (undo.ts, on a bridge with `undoState`),
|
|
18
20
|
* and the bridge's callbacks `__sbTimerFired` and `__sbFetchResult`. In a
|
|
19
21
|
* worker's own context (its bridge has
|
|
20
22
|
* `workerPostMessage`), `self`, `postMessage`, `addEventListener` and
|
|
@@ -30,7 +32,15 @@ import { createImageGlobals } from './image';
|
|
|
30
32
|
import { SimpleURL, SimpleURLSearchParams } from './url';
|
|
31
33
|
import { installWorkerScope, NativeWorker } from './worker';
|
|
32
34
|
import { createMediaSession, NativeMediaMetadata } from './mediaSession';
|
|
35
|
+
import { createNotificationGlobals } from './notifications';
|
|
33
36
|
import { receiveOpenURL } from './openURL';
|
|
37
|
+
import { objectURL, SimpleBlob, SimpleFile, SimpleFormData } from './blob';
|
|
38
|
+
import { setBodyRandomness } from './fetch';
|
|
39
|
+
import { NativeWebSocket } from './webSocket';
|
|
40
|
+
import { createSubtleCrypto } from './subtle';
|
|
41
|
+
import { matchMedia } from './matchMedia';
|
|
42
|
+
import { createUndoHost, installUndoHost } from './undo';
|
|
43
|
+
import { installHostRequests, receiveHostResponse } from './hostRequests';
|
|
34
44
|
|
|
35
45
|
type AnyGlobal = Record<string, unknown>;
|
|
36
46
|
|
|
@@ -172,6 +182,44 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
172
182
|
define('URL', SimpleURL);
|
|
173
183
|
define('URLSearchParams', SimpleURLSearchParams);
|
|
174
184
|
|
|
185
|
+
// Bytes with a type, files and forms (blob.ts): what `fetch` uploads, and
|
|
186
|
+
// what a picked file or a built image is. `URL.createObjectURL` is a `data:` URL.
|
|
187
|
+
define('Blob', SimpleBlob);
|
|
188
|
+
define('File', SimpleFile);
|
|
189
|
+
define('FormData', SimpleFormData);
|
|
190
|
+
const urlStatics = target['URL'] as { createObjectURL?: unknown; revokeObjectURL?: unknown };
|
|
191
|
+
if (urlStatics && typeof urlStatics.createObjectURL !== 'function') {
|
|
192
|
+
urlStatics.createObjectURL = (blob: SimpleBlob) => objectURL(blob);
|
|
193
|
+
urlStatics.revokeObjectURL = () => {};
|
|
194
|
+
}
|
|
195
|
+
const random = (count: number): Uint8Array => base64Decode(native.randomBytes(count));
|
|
196
|
+
setBodyRandomness(random);
|
|
197
|
+
|
|
198
|
+
// WebSockets (webSocket.ts) over the host's `URLSessionWebSocketTask`.
|
|
199
|
+
if (native.wsOpen && native.wsSend && native.wsClose) {
|
|
200
|
+
NativeWebSocket.install({ wsOpen: native.wsOpen.bind(native), wsSend: native.wsSend.bind(native), wsClose: native.wsClose.bind(native) });
|
|
201
|
+
define('WebSocket', NativeWebSocket);
|
|
202
|
+
define('__sbWebSocketEvent', NativeWebSocket.receive);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// `matchMedia`: the color scheme the host sends (run.ts), nothing else.
|
|
206
|
+
define('matchMedia', matchMedia);
|
|
207
|
+
|
|
208
|
+
// Host requests (hostRequests.ts): the share sheet, the pickers, the pasteboard, a sign-in.
|
|
209
|
+
if (native.hostRequest && !native.workerPostMessage) {
|
|
210
|
+
installHostRequests({ hostRequest: native.hostRequest.bind(native), ...(native.appDirectories ? { appDirectories: native.appDirectories.bind(native) } : {}) });
|
|
211
|
+
define('__sbHostResponse', receiveHostResponse);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The window's undo manager (undo.ts), mirroring the app's: the system's undo comes to `__sbUndoCommand`.
|
|
215
|
+
if (native.undoState && !native.workerPostMessage) {
|
|
216
|
+
const undo = createUndoHost({ undoState: native.undoState.bind(native) });
|
|
217
|
+
installUndoHost(undo.host);
|
|
218
|
+
define('__sbUndoCommand', undo.receive);
|
|
219
|
+
} else {
|
|
220
|
+
installUndoHost(null);
|
|
221
|
+
}
|
|
222
|
+
|
|
175
223
|
// `Image`, for code that loads a picture to learn its size (React's
|
|
176
224
|
// `AsyncImage`): the host reads it through its image loader.
|
|
177
225
|
if (native.probeImage && !native.workerPostMessage) {
|
|
@@ -186,6 +234,8 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
186
234
|
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(bytes.subarray(0, array.byteLength));
|
|
187
235
|
return array;
|
|
188
236
|
},
|
|
237
|
+
// Digests and HMAC on the host's CryptoKit (subtle.ts).
|
|
238
|
+
subtle: createSubtleCrypto(native),
|
|
189
239
|
randomUUID: (): string => {
|
|
190
240
|
const bytes = base64Decode(native.randomBytes(16));
|
|
191
241
|
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
|
@@ -216,15 +266,36 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
216
266
|
onLine: true,
|
|
217
267
|
platform: 'iOS',
|
|
218
268
|
hardwareConcurrency: 1,
|
|
269
|
+
// A haptic for a vibration; the pattern's length does not matter.
|
|
270
|
+
vibrate: (pattern: number | number[]): boolean => {
|
|
271
|
+
if (!native.haptic) return false;
|
|
272
|
+
const length = Array.isArray(pattern) ? pattern.reduce((sum, ms) => sum + Number(ms), 0) : Number(pattern);
|
|
273
|
+
if (!(length > 0)) return true;
|
|
274
|
+
native.haptic(length >= 200 ? 'impact' : 'selection');
|
|
275
|
+
return true;
|
|
276
|
+
},
|
|
219
277
|
};
|
|
220
278
|
// What plays, for the lock screen and the media keys (mediaSession.ts); not in a worker.
|
|
221
279
|
const mediaSession = native.mediaSession && !native.workerPostMessage ? createMediaSession(native) : null;
|
|
222
280
|
if (mediaSession) navigator['mediaSession'] = mediaSession.session;
|
|
281
|
+
// Notifications (notifications.ts): the browser's `Notification` and the
|
|
282
|
+
// badge over UserNotifications, and the push registration; not in a worker.
|
|
283
|
+
const notifications = native.notificationShow && !native.workerPostMessage ? createNotificationGlobals(native) : null;
|
|
284
|
+
if (notifications) {
|
|
285
|
+
navigator['setAppBadge'] = notifications.setAppBadge;
|
|
286
|
+
navigator['clearAppBadge'] = notifications.clearAppBadge;
|
|
287
|
+
}
|
|
223
288
|
define('navigator', navigator);
|
|
224
289
|
if (mediaSession) {
|
|
225
290
|
define('MediaMetadata', NativeMediaMetadata);
|
|
226
291
|
define('__sbMediaSessionCommand', mediaSession.receive);
|
|
227
292
|
}
|
|
293
|
+
if (notifications) {
|
|
294
|
+
define('Notification', notifications.Notification);
|
|
295
|
+
define('__sbNotificationPermission', notifications.__sbNotificationPermission);
|
|
296
|
+
define('__sbPushRegistered', notifications.__sbPushRegistered);
|
|
297
|
+
define('__sbNotification', notifications.__sbNotification);
|
|
298
|
+
}
|
|
228
299
|
|
|
229
300
|
if (native.workerPostMessage) {
|
|
230
301
|
installed.push(...installWorkerScope(native, target));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `secureStorage`: `localStorage`'s shape over the Keychain, for what must
|
|
3
|
+
* not sit in plain text (a session token). The host's `secureGet`,
|
|
4
|
+
* `secureSet` and `secureRemove` are generic-password items of the app's
|
|
5
|
+
* (`kSecAttrAccessibleAfterFirstUnlock`, so a background fetch can read
|
|
6
|
+
* them). In the page (the round trip, or a browser) there is no Keychain:
|
|
7
|
+
* the values go to `localStorage` under a prefix, as a web app keeps them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { nativeBridge, type NativeBridge } from './bridge';
|
|
11
|
+
|
|
12
|
+
export type SecureStorageBridge = Pick<NativeBridge, 'secureGet' | 'secureSet' | 'secureRemove'>;
|
|
13
|
+
|
|
14
|
+
export interface SecureStorage {
|
|
15
|
+
getItem(key: string): string | null;
|
|
16
|
+
setItem(key: string, value: string): void;
|
|
17
|
+
removeItem(key: string): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PREFIX = 'swiftbrowser.secure.';
|
|
21
|
+
|
|
22
|
+
/** Secure storage over `native`, or over `localStorage` when the host has no Keychain. */
|
|
23
|
+
export function createSecureStorage(native: SecureStorageBridge | undefined, fallback: () => Storage | undefined): SecureStorage {
|
|
24
|
+
if (native?.secureGet && native.secureSet && native.secureRemove) {
|
|
25
|
+
return {
|
|
26
|
+
getItem: (key) => native.secureGet!(String(key)),
|
|
27
|
+
setItem: (key, value) => native.secureSet!(String(key), String(value)),
|
|
28
|
+
removeItem: (key) => native.secureRemove!(String(key)),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
getItem: (key) => fallback()?.getItem(PREFIX + String(key)) ?? null,
|
|
33
|
+
setItem: (key, value) => fallback()?.setItem(PREFIX + String(key), String(value)),
|
|
34
|
+
removeItem: (key) => fallback()?.removeItem(PREFIX + String(key)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The app's secure storage: the Keychain in the native app, `localStorage` in the page. */
|
|
39
|
+
export const secureStorage: SecureStorage = createSecureStorage(nativeBridge(), () => (typeof localStorage === 'undefined' ? undefined : localStorage));
|
package/src/site.ts
CHANGED
|
@@ -26,8 +26,8 @@ export interface SiteOptions extends Omit<BuildOptions, 'outfile'> {
|
|
|
26
26
|
/**
|
|
27
27
|
* The site's app name (letters, digits, `_` and `-`): the module's file
|
|
28
28
|
* name and the document title. The entry's project folder's name by default
|
|
29
|
-
* (`
|
|
30
|
-
* app out), else `App`.
|
|
29
|
+
* (`src/index.tsx` → the folder holding `src`, as `native init` lays an
|
|
30
|
+
* app out; `js/src/index.tsx` → the folder holding `js`), else `App`.
|
|
31
31
|
*/
|
|
32
32
|
name?: string;
|
|
33
33
|
/** Shown as the document title and in the web manifest; `name` by default. */
|
|
@@ -59,7 +59,7 @@ export interface SiteResult {
|
|
|
59
59
|
|
|
60
60
|
const APP_NAME = /^[A-Za-z0-9_-]+$/;
|
|
61
61
|
|
|
62
|
-
/** The app name an entry suggests: the folder holding its `js/`
|
|
62
|
+
/** The app name an entry suggests: the folder holding its `src/` (or an older layout's `js/`), else the entry's own folder. */
|
|
63
63
|
export function siteNameFor(entry: string): string {
|
|
64
64
|
const dir = path.dirname(path.resolve(entry));
|
|
65
65
|
const parts = dir.split(path.sep);
|
package/src/subtle.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crypto.subtle` for the native app, as far as apps use it for a PKCE
|
|
3
|
+
* challenge or a signature: `digest` (SHA-1, SHA-256, SHA-384, SHA-512) and
|
|
4
|
+
* HMAC keys with `importKey`, `sign` and `verify`, over the bridge's
|
|
5
|
+
* `digest` and `hmac` (CryptoKit on the Swift side). Both are synchronous
|
|
6
|
+
* there; the promises resolve at once.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { NativeBridge } from './bridge';
|
|
10
|
+
import { base64Decode, base64Encode } from './encoding';
|
|
11
|
+
|
|
12
|
+
export type SubtleBridge = Pick<NativeBridge, 'digest' | 'hmac'>;
|
|
13
|
+
|
|
14
|
+
type Algorithm = string | { name: string; hash?: string | { name: string } };
|
|
15
|
+
|
|
16
|
+
interface HmacKey {
|
|
17
|
+
type: 'secret';
|
|
18
|
+
extractable: boolean;
|
|
19
|
+
algorithm: { name: 'HMAC'; hash: { name: string }; length: number };
|
|
20
|
+
usages: string[];
|
|
21
|
+
/** The key's bytes, kept here: the host signs with them each time. */
|
|
22
|
+
readonly bytes: Uint8Array;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DIGESTS = new Set(['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512']);
|
|
26
|
+
|
|
27
|
+
function algorithmName(algorithm: Algorithm): string {
|
|
28
|
+
return (typeof algorithm === 'string' ? algorithm : algorithm.name).toUpperCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hashName(algorithm: Algorithm): string {
|
|
32
|
+
if (typeof algorithm === 'string') return algorithm.toUpperCase();
|
|
33
|
+
const hash = algorithm.hash;
|
|
34
|
+
return (typeof hash === 'string' ? hash : hash?.name ?? '').toUpperCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function bytesOf(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
38
|
+
return data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toBuffer(base64: string): ArrayBuffer {
|
|
42
|
+
const bytes = base64Decode(base64);
|
|
43
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function notSupported(what: string): Error {
|
|
47
|
+
const error = new Error(`crypto.subtle: ${what} is not supported in the native app`);
|
|
48
|
+
error.name = 'NotSupportedError';
|
|
49
|
+
return error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `crypto.subtle` over the bridge. */
|
|
53
|
+
export function createSubtleCrypto(native: SubtleBridge) {
|
|
54
|
+
return {
|
|
55
|
+
async digest(algorithm: Algorithm, data: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer> {
|
|
56
|
+
const name = algorithmName(algorithm);
|
|
57
|
+
if (!DIGESTS.has(name)) throw notSupported(`the ${name} digest`);
|
|
58
|
+
const result = native.digest?.(name, base64Encode(bytesOf(data)));
|
|
59
|
+
if (!result) throw notSupported(`the ${name} digest`);
|
|
60
|
+
return toBuffer(result);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
async importKey(format: string, keyData: ArrayBuffer | ArrayBufferView, algorithm: Algorithm, extractable: boolean, usages: string[]): Promise<HmacKey> {
|
|
64
|
+
if (format !== 'raw' || algorithmName(algorithm) !== 'HMAC') throw notSupported(`importing a ${format} ${algorithmName(algorithm)} key`);
|
|
65
|
+
const hash = hashName(algorithm);
|
|
66
|
+
if (!DIGESTS.has(hash)) throw notSupported(`HMAC with ${hash}`);
|
|
67
|
+
const bytes = bytesOf(keyData);
|
|
68
|
+
return { type: 'secret', extractable, algorithm: { name: 'HMAC', hash: { name: hash }, length: bytes.byteLength * 8 }, usages: [...usages], bytes };
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async sign(algorithm: Algorithm, key: HmacKey, data: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer> {
|
|
72
|
+
if (algorithmName(algorithm) !== 'HMAC' || key.algorithm?.name !== 'HMAC') throw notSupported(`signing with ${algorithmName(algorithm)}`);
|
|
73
|
+
const result = native.hmac?.(key.algorithm.hash.name, base64Encode(key.bytes), base64Encode(bytesOf(data)));
|
|
74
|
+
if (!result) throw notSupported('HMAC');
|
|
75
|
+
return toBuffer(result);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async verify(algorithm: Algorithm, key: HmacKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise<boolean> {
|
|
79
|
+
const expected = new Uint8Array(await this.sign(algorithm, key, data));
|
|
80
|
+
const given = bytesOf(signature);
|
|
81
|
+
if (expected.byteLength !== given.byteLength) return false;
|
|
82
|
+
let difference = 0;
|
|
83
|
+
for (let i = 0; i < expected.byteLength; i += 1) difference |= expected[i]! ^ given[i]!;
|
|
84
|
+
return difference === 0;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|