@swiftbrowser/web 0.17.6 → 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.
- package/node/dist/node/apps.d.ts +5 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +3 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +2 -0
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +2 -0
- package/node/dist/node/config.js.map +1 -1
- package/package.json +1 -1
- package/public/_headers +5 -0
- package/src/hostRequests.ts +20 -1
- package/src/main.ts +15 -3
- package/src/media.ts +23 -1
- package/src/mediaSession.ts +163 -0
- package/src/notifications.ts +134 -1
- package/src/protocol.ts +32 -3
- package/src/renderer.ts +8 -3
- package/src/vite-env.d.ts +2 -0
package/node/dist/node/apps.d.ts
CHANGED
|
@@ -51,6 +51,11 @@ export interface AppSpec {
|
|
|
51
51
|
* page's callback page (docs/ops-protocol.md, "Signing in").
|
|
52
52
|
*/
|
|
53
53
|
urlSchemes?: string[];
|
|
54
|
+
/**
|
|
55
|
+
* The Web Push application server key (a VAPID public key, base64url) the
|
|
56
|
+
* app's `registerForRemoteNotifications` subscribes with (src/notifications.ts).
|
|
57
|
+
*/
|
|
58
|
+
webPushKey?: string;
|
|
54
59
|
/**
|
|
55
60
|
* A CORS proxy (packages/cors-proxy): the page retries a cross-origin GET or
|
|
56
61
|
* HEAD the browser refused through `<corsProxy>?url=<target>` (src/corsProxy.ts).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apps.d.ts","sourceRoot":"","sources":["../../apps.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,WAAW,OAAO;IACtB;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0CAA0C;IAC1C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,gHAAgH;IAChH,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,0FAA0F;IAC1F,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,gHAAgH;IAChH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,yEAAyE;AACzE,eAAO,MAAM,QAAQ,QAAqB,CAAC;AAE3C,sCAAsC;AACtC,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,aAAa,CAAC,GAAG,MAAM,CAEjF;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,OAAO,EAAE,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"apps.d.ts","sourceRoot":"","sources":["../../apps.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,WAAW,OAAO;IACtB;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0CAA0C;IAC1C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,gHAAgH;IAChH,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,0FAA0F;IAC1F,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,gHAAgH;IAChH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,yEAAyE;AACzE,eAAO,MAAM,QAAQ,QAAqB,CAAC;AAE3C,sCAAsC;AACtC,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,aAAa,CAAC,GAAG,MAAM,CAEjF;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,OAAO,EAAE,GAAG,IAAI,CAkDhE"}
|
package/node/dist/node/apps.js
CHANGED
|
@@ -58,6 +58,9 @@ export function validateAppSpecs(specs) {
|
|
|
58
58
|
if (spec.corsProxy !== undefined && (typeof spec.corsProxy !== 'string' || !/^https?:\/\/[^\s]+$/.test(spec.corsProxy))) {
|
|
59
59
|
throw new Error(`${label}: "corsProxy" must be an http(s) URL`);
|
|
60
60
|
}
|
|
61
|
+
if (spec.webPushKey !== undefined && (typeof spec.webPushKey !== 'string' || !/^[A-Za-z0-9_-]{80,96}$/.test(spec.webPushKey))) {
|
|
62
|
+
throw new Error(`${label}: "webPushKey" must be a VAPID public key (base64url, 65 bytes)`);
|
|
63
|
+
}
|
|
61
64
|
if (spec.blockedHosts !== undefined && (!Array.isArray(spec.blockedHosts) || !spec.blockedHosts.every((h) => typeof h === 'string' && /^[A-Za-z0-9.-]+$/.test(h)))) {
|
|
62
65
|
throw new Error(`${label}: "blockedHosts" must be an array of host names`);
|
|
63
66
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apps.js","sourceRoot":"","sources":["../../apps.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"apps.js","sourceRoot":"","sources":["../../apps.ts"],"names":[],"mappings":"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"}
|
|
@@ -67,6 +67,8 @@ export interface LandingApp {
|
|
|
67
67
|
urlSchemes?: string[];
|
|
68
68
|
/** The app's CORS proxy (`AppSpec.corsProxy`); the page retries refused cross-origin reads through it. */
|
|
69
69
|
corsProxy?: string;
|
|
70
|
+
/** The app's Web Push application server key (`AppSpec.webPushKey`); the page passes it to the app as `SB_WEB_PUSH_KEY`. */
|
|
71
|
+
webPushKey?: string;
|
|
70
72
|
/** Hosts the app's requests may not reach (`AppSpec.blockedHosts`). */
|
|
71
73
|
blockedHosts?: string[];
|
|
72
74
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAKvC,OAAO,EAAmD,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAChI,OAAO,EAAE,KAAK,iBAAiB,EAAyC,MAAM,0BAA0B,CAAC;AAEzG,OAAO,EAAmC,KAAK,OAAO,EAAE,MAAM,WAAW,CAAC;AAE1E,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,sGAAsG;IACtG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB;;;OAGG;IACH,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,kEAAkE;AAClE,wBAAgB,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC,GAAG,UAAU,CAG9F;AAED,qGAAqG;AACrG,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oHAAoH;IACpH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,yGAAyG;IACzG,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,0GAA0G;IAC1G,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAOD;;;;;GAKG;AACH,wBAAgB,OAAO,IAAI,MAAM,CAoBhC;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,OAAO,EAAE,GAAG,UAAU,EAAE,
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAKvC,OAAO,EAAmD,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAChI,OAAO,EAAE,KAAK,iBAAiB,EAAyC,MAAM,0BAA0B,CAAC;AAEzG,OAAO,EAAmC,KAAK,OAAO,EAAE,MAAM,WAAW,CAAC;AAE1E,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,sGAAsG;IACtG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB;;;OAGG;IACH,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,kEAAkE;AAClE,wBAAgB,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC,GAAG,UAAU,CAG9F;AAED,qGAAqG;AACrG,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oHAAoH;IACpH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,yGAAyG;IACzG,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,0GAA0G;IAC1G,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4HAA4H;IAC5H,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAOD;;;;;GAKG;AACH,wBAAgB,OAAO,IAAI,MAAM,CAoBhC;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,OAAO,EAAE,GAAG,UAAU,EAAE,CAYnE;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,GAAG,UAAU,CAoEjE"}
|
package/node/dist/node/config.js
CHANGED
|
@@ -76,6 +76,8 @@ export function landingApps(specs) {
|
|
|
76
76
|
entry.urlSchemes = [...spec.urlSchemes];
|
|
77
77
|
if (spec.corsProxy)
|
|
78
78
|
entry.corsProxy = spec.corsProxy;
|
|
79
|
+
if (spec.webPushKey)
|
|
80
|
+
entry.webPushKey = spec.webPushKey;
|
|
79
81
|
if (spec.blockedHosts?.length)
|
|
80
82
|
entry.blockedHosts = [...spec.blockedHosts];
|
|
81
83
|
return entry;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAC3E,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAqD,MAAM,2BAA2B,CAAC;AAChI,OAAO,EAA0B,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AACzG,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAgB,MAAM,WAAW,CAAC;AAuC1E,kEAAkE;AAClE,MAAM,UAAU,UAAU,CAAC,OAAuD;IAChF,IAAI,OAAO,CAAC,MAAM;QAAE,OAAO,OAAO,CAAC,MAAM,CAAC;IAC1C,OAAO,OAAO,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AACjF,CAAC;
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../config.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/public/_headers
CHANGED
|
@@ -28,3 +28,8 @@
|
|
|
28
28
|
# plugins/asset-catalogs.ts). Not content-hashed: always revalidate.
|
|
29
29
|
/catalog/*
|
|
30
30
|
Cache-Control: no-cache
|
|
31
|
+
|
|
32
|
+
# The service worker (Web Push): always revalidate, and its scope is the site.
|
|
33
|
+
/sw.js
|
|
34
|
+
Cache-Control: no-cache
|
|
35
|
+
Service-Worker-Allowed: /
|
package/src/hostRequests.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { base64Decode, base64Encode } from './base64';
|
|
21
|
-
import { browserNotificationHost, performNotification, type NotificationHost, type NotificationResult } from './notifications';
|
|
21
|
+
import { browserNotificationHost, browserPushHost, performNotification, type NotificationHost, type NotificationResult } from './notifications';
|
|
22
22
|
import type {
|
|
23
23
|
BitmapRecipe,
|
|
24
24
|
FetchRequestParams,
|
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
OpenURLRequestParams,
|
|
33
33
|
WebAuthRequestParams,
|
|
34
34
|
MediaRequestParams,
|
|
35
|
+
MediaSessionRequestParams,
|
|
35
36
|
DownloadRequestParams,
|
|
36
37
|
ClipboardRequestParams,
|
|
37
38
|
TranslateRequestParams,
|
|
@@ -40,6 +41,7 @@ import type {
|
|
|
40
41
|
} from './protocol';
|
|
41
42
|
import { browserClipboardHost, performClipboard, type ClipboardHost } from './clipboard';
|
|
42
43
|
import { browserMediaHost, performMedia, type MediaHost } from './media';
|
|
44
|
+
import { browserMediaSessionHost, MediaSessionBridge, performMediaSession } from './mediaSession';
|
|
43
45
|
import { browserScannerHost, performScanner, type ScannerHost, type ScannerResult } from './scanner';
|
|
44
46
|
import { browserTranslationHost, performTranslate, type TranslationHost } from './translation';
|
|
45
47
|
import { chooseColor, performPickColor, type ColorChooser } from './colorPicker';
|
|
@@ -123,6 +125,8 @@ export interface HostRequestEnvironment {
|
|
|
123
125
|
webAuth?: WebAuthSessions;
|
|
124
126
|
/** AVFoundation's media files and probes, QuickLook's previews (`media`, media.ts). */
|
|
125
127
|
media?: MediaHost;
|
|
128
|
+
/** MediaPlayer's now-playing info and remote commands (src/mediaSession.ts); absent without the Media Session API. */
|
|
129
|
+
mediaSession?: MediaSessionBridge;
|
|
126
130
|
/** The system clipboard (`clipboard`, clipboard.ts). */
|
|
127
131
|
clipboard?: ClipboardHost;
|
|
128
132
|
/** The browser's built-in translator (`translate`, translation.ts), where it has one. */
|
|
@@ -174,9 +178,21 @@ function downloadBlob(blob: Blob, filename: string): void {
|
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
const notificationHost = browserNotificationHost();
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Gives the notifications Web Push through the service worker at `workerURL`
|
|
184
|
+
* with `scope` (the site's base, which the page knows: main.ts), where the
|
|
185
|
+
* browser has a push service.
|
|
186
|
+
*/
|
|
187
|
+
export function installPushService(workerURL: string, scope: string): void {
|
|
188
|
+
if (!notificationHost || notificationHost.push) return;
|
|
189
|
+
const push = browserPushHost(workerURL, scope);
|
|
190
|
+
if (push) notificationHost.push = push;
|
|
191
|
+
}
|
|
177
192
|
const scannerHost = browserScannerHost();
|
|
178
193
|
const webAuthHost = browserWebAuthHost();
|
|
179
194
|
const mediaHost = browserMediaHost(typeof document !== 'undefined' ? downloadBlob : undefined);
|
|
195
|
+
const mediaSessionHost = browserMediaSessionHost();
|
|
180
196
|
const clipboardHost = browserClipboardHost();
|
|
181
197
|
const translationHost = browserTranslationHost();
|
|
182
198
|
|
|
@@ -209,6 +225,7 @@ export const browserEnvironment: HostRequestEnvironment = {
|
|
|
209
225
|
: {}),
|
|
210
226
|
...(webAuthHost ? { webAuth: new WebAuthSessions(webAuthHost) } : {}),
|
|
211
227
|
...(mediaHost ? { media: mediaHost } : {}),
|
|
228
|
+
...(mediaSessionHost ? { mediaSession: new MediaSessionBridge(mediaSessionHost) } : {}),
|
|
212
229
|
...(clipboardHost ? { clipboard: clipboardHost } : {}),
|
|
213
230
|
...(translationHost ? { translation: translationHost } : {}),
|
|
214
231
|
...(typeof document !== 'undefined' ? { pickColor: chooseColor } : {}),
|
|
@@ -256,6 +273,8 @@ export async function performHostRequest(
|
|
|
256
273
|
case 'media':
|
|
257
274
|
// `open` is called before any await, while the tap's activation may still count.
|
|
258
275
|
return performMedia(op.params as MediaRequestParams, env.media);
|
|
276
|
+
case 'mediaSession':
|
|
277
|
+
return performMediaSession(op.params as MediaSessionRequestParams, env.mediaSession);
|
|
259
278
|
case 'download':
|
|
260
279
|
return performDownload(op.params as DownloadRequestParams, env);
|
|
261
280
|
case 'clipboard': {
|
package/src/main.ts
CHANGED
|
@@ -18,7 +18,8 @@ import { BrowserHistory } from './browserHistory';
|
|
|
18
18
|
import { Renderer, RendererError } from './renderer';
|
|
19
19
|
import { withBlockedHosts } from './blockedHosts';
|
|
20
20
|
import { withCORSProxy } from './corsProxy';
|
|
21
|
-
import { browserEnvironment } from './hostRequests';
|
|
21
|
+
import { browserEnvironment, installPushService } from './hostRequests';
|
|
22
|
+
import { locateBundleFiles } from './media';
|
|
22
23
|
import { callbackPageURL } from './webAuth';
|
|
23
24
|
import { bitmapStore } from './bitmaps';
|
|
24
25
|
import { DEFAULT_TYPE_SIZE, applyTypeSizeVariables } from './typography';
|
|
@@ -393,6 +394,11 @@ function launchEnvironment(): string[] {
|
|
|
393
394
|
const schemes = registeredApp()?.urlSchemes;
|
|
394
395
|
if (schemes?.length) env.push(`SB_URL_SCHEMES=${schemes.join(',')}`);
|
|
395
396
|
env.push(`SB_WEB_AUTH_CALLBACK=${callbackPageURL(new URL(import.meta.env.BASE_URL, location.href).href)}`);
|
|
397
|
+
// Web Push (notifications.ts): the service worker next to the page, and
|
|
398
|
+
// the key `registerForRemoteNotifications` subscribes with.
|
|
399
|
+
installPushService(`${import.meta.env.BASE_URL}sw.js`, import.meta.env.BASE_URL);
|
|
400
|
+
const webPushKey = registeredApp()?.webPushKey;
|
|
401
|
+
if (webPushKey) env.push(`SB_WEB_PUSH_KEY=${webPushKey}`);
|
|
396
402
|
return env;
|
|
397
403
|
}
|
|
398
404
|
|
|
@@ -460,6 +466,11 @@ function saveHome(home: ReturnType<typeof homeDirectory>): void {
|
|
|
460
466
|
files?.apply(changes).catch((error: unknown) => console.warn('[sb] could not save the app\'s files to IndexedDB', error));
|
|
461
467
|
}
|
|
462
468
|
|
|
469
|
+
/** Where the page serves the resource at `path` (inside the bundle) of `app`. */
|
|
470
|
+
function bundleFileURL(app: string, path: string): string {
|
|
471
|
+
return `${import.meta.env.BASE_URL}bundle/${app}/${path.split('/').map(encodeURIComponent).join('/')}`;
|
|
472
|
+
}
|
|
473
|
+
|
|
463
474
|
/** The app's resources (`/bundle/<App>.json` and its files), for the WASI `/bundle` mount; empty without a manifest. */
|
|
464
475
|
async function loadBundle(app: string): Promise<Map<string, Uint8Array>> {
|
|
465
476
|
const files = new Map<string, Uint8Array>();
|
|
@@ -470,8 +481,7 @@ async function loadBundle(app: string): Promise<Map<string, Uint8Array>> {
|
|
|
470
481
|
if (!Array.isArray(manifest.files)) return files;
|
|
471
482
|
await Promise.all(
|
|
472
483
|
manifest.files.map(async (file) => {
|
|
473
|
-
const
|
|
474
|
-
const data = await fetch(url, { cache: 'no-cache' });
|
|
484
|
+
const data = await fetch(bundleFileURL(app, file.path), { cache: 'no-cache' });
|
|
475
485
|
if (data.ok) files.set(file.path, new Uint8Array(await data.arrayBuffer()));
|
|
476
486
|
else console.warn(`[sb] resource ${file.path} of ${app}: HTTP ${data.status}`);
|
|
477
487
|
}),
|
|
@@ -601,6 +611,8 @@ async function boot(): Promise<void> {
|
|
|
601
611
|
console.info(`[sb] restoring state for ${requestedApp}: ${restored}`);
|
|
602
612
|
}
|
|
603
613
|
const bundle = await loadBundle(requestedApp);
|
|
614
|
+
// A player given a bundle file streams it from here rather than through a copy of its bytes (src/media.ts).
|
|
615
|
+
locateBundleFiles((path) => (bundle.has(path) ? bundleFileURL(requestedApp, path) : null));
|
|
604
616
|
const stores = launchValue(await loadSwiftData());
|
|
605
617
|
if (stores !== null) env.push(`SB_SWIFTDATA=${stores}`);
|
|
606
618
|
savedHome = await loadHome();
|
package/src/media.ts
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* AVFoundation's files and metadata probes, and QuickLook's previews.
|
|
4
4
|
*
|
|
5
5
|
* - `file` (`name`, `type`, `data` base64): a blob of the bytes; answers
|
|
6
|
-
* `{ url }`, an object URL a `video` element can play.
|
|
6
|
+
* `{ url }`, an object URL a `video` element can play. With `path` in
|
|
7
|
+
* place of `data` (a resource of the app's bundle), the URL the page
|
|
8
|
+
* serves that resource at, so the browser streams it and nothing is
|
|
9
|
+
* copied; fails `unsupported: …` when the page has no such resource,
|
|
10
|
+
* and the app sends the bytes instead.
|
|
7
11
|
* - `probe` (`url`): reads the media's metadata in a detached `<video>`;
|
|
8
12
|
* answers `{ duration, width, height }` (`duration` null for a stream),
|
|
9
13
|
* fails (`network: …`) when the browser cannot load or decode it.
|
|
@@ -37,12 +41,29 @@ export interface MediaHost {
|
|
|
37
41
|
/** Opens `url` in a new tab; false when the browser blocked it. */
|
|
38
42
|
open(url: string): boolean;
|
|
39
43
|
download?(blob: Blob, filename: string): void;
|
|
44
|
+
/** The URL the page serves the bundle resource at `path` from, or null for a resource it did not load. */
|
|
45
|
+
bundleURL?(path: string): string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let bundleLocator: ((path: string) => string | null) | null = null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Tells the media host where the app's bundle resources are served from
|
|
52
|
+
* (src/main.ts, once the bundle is loaded), for `file` requests by `path`.
|
|
53
|
+
*/
|
|
54
|
+
export function locateBundleFiles(locator: ((path: string) => string | null) | null): void {
|
|
55
|
+
bundleLocator = locator;
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
export async function performMedia(params: MediaRequestParams, host: MediaHost | undefined): Promise<Record<string, unknown>> {
|
|
43
59
|
if (!host) throw new Error('unsupported: this page has no media host');
|
|
44
60
|
switch (params.action) {
|
|
45
61
|
case 'file': {
|
|
62
|
+
if (params.path !== undefined) {
|
|
63
|
+
const url = host.bundleURL?.(params.path) ?? null;
|
|
64
|
+
if (url === null) throw new Error(`unsupported: the page has no bundle resource ${params.path}`);
|
|
65
|
+
return { url };
|
|
66
|
+
}
|
|
46
67
|
const blob = new Blob([decode(params.data ?? '') as BlobPart], { type: params.type ?? '' });
|
|
47
68
|
return { url: host.objectURL(blob) };
|
|
48
69
|
}
|
|
@@ -128,5 +149,6 @@ export function browserMediaHost(download?: (blob: Blob, filename: string) => vo
|
|
|
128
149
|
return true;
|
|
129
150
|
},
|
|
130
151
|
...(download ? { download } : {}),
|
|
152
|
+
bundleURL: (path) => bundleLocator?.(path) ?? null,
|
|
131
153
|
};
|
|
132
154
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mediaSession` host requests (docs/ops-protocol.md, "Now playing"):
|
|
3
|
+
* MediaPlayer's now-playing info and remote commands over the browser's
|
|
4
|
+
* Media Session API, which puts what plays on the lock screen and the
|
|
5
|
+
* system's media controls and routes their buttons (and hardware media
|
|
6
|
+
* keys) back to the page.
|
|
7
|
+
*
|
|
8
|
+
* - `update` (`metadata`, `playbackState`, `position`, `commands`, `skip`)
|
|
9
|
+
* sets what the session shows and which commands the app handles; the
|
|
10
|
+
* browser extrapolates the position at the rate, so the app sends it
|
|
11
|
+
* only when it changes (a play, a pause, a seek). Answers `{}`.
|
|
12
|
+
* - `receive` answers the next command the viewer gave: `{ command }`, with
|
|
13
|
+
* `position` for `seekTo` (seconds) and `interval` for the skips (the
|
|
14
|
+
* offset the system asked for, else the app's preferred one). Commands
|
|
15
|
+
* that arrive with nobody receiving wait in a queue, as WebSocket
|
|
16
|
+
* messages do (src/webSocket.ts). Fails `unsupported: …` on a page with
|
|
17
|
+
* no Media Session API, which ends the app's listening.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { MediaSessionCommand, MediaSessionRequestParams } from './protocol';
|
|
21
|
+
|
|
22
|
+
/** The part of `navigator.mediaSession` the requests use; tests substitute a fake. */
|
|
23
|
+
export interface MediaSessionHost {
|
|
24
|
+
setMetadata(metadata: { title: string; artist: string; album: string; artwork: { src: string }[] } | null): void;
|
|
25
|
+
setPlaybackState(state: 'none' | 'playing' | 'paused'): void;
|
|
26
|
+
setPositionState(state: { duration: number; playbackRate: number; position: number } | null): void;
|
|
27
|
+
/** Registers (or, with null, removes) the handler of a Media Session action; may throw for an action the browser lacks. */
|
|
28
|
+
setActionHandler(action: MediaSessionAction, handler: ((details: MediaSessionActionDetails) => void) | null): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A command the viewer gave, as `receive` answers it. */
|
|
32
|
+
export interface ReceivedCommand {
|
|
33
|
+
command: MediaSessionCommand;
|
|
34
|
+
position?: number;
|
|
35
|
+
interval?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The Media Session action each command listens to. */
|
|
39
|
+
const ACTIONS: Record<MediaSessionCommand, MediaSessionAction> = {
|
|
40
|
+
play: 'play',
|
|
41
|
+
pause: 'pause',
|
|
42
|
+
stop: 'stop',
|
|
43
|
+
nextTrack: 'nexttrack',
|
|
44
|
+
previousTrack: 'previoustrack',
|
|
45
|
+
seekTo: 'seekto',
|
|
46
|
+
skipForward: 'seekforward',
|
|
47
|
+
skipBackward: 'seekbackward',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** One page's media session: what it shows, the commands it listens to, and the ones received. */
|
|
51
|
+
export class MediaSessionBridge {
|
|
52
|
+
private readonly registered = new Set<MediaSessionCommand>();
|
|
53
|
+
private readonly queue: ReceivedCommand[] = [];
|
|
54
|
+
private readonly waiting: ((command: ReceivedCommand) => void)[] = [];
|
|
55
|
+
private skip: { forward?: number; backward?: number } = {};
|
|
56
|
+
|
|
57
|
+
constructor(private readonly host: MediaSessionHost) {}
|
|
58
|
+
|
|
59
|
+
perform(params: MediaSessionRequestParams): Promise<Record<string, unknown>> {
|
|
60
|
+
switch (params.action) {
|
|
61
|
+
case 'update':
|
|
62
|
+
this.update(params);
|
|
63
|
+
return Promise.resolve({});
|
|
64
|
+
case 'receive':
|
|
65
|
+
return this.receive();
|
|
66
|
+
default:
|
|
67
|
+
return Promise.reject(new Error(`unsupported: mediaSession action ${String((params as { action: unknown }).action)}`));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private update(params: MediaSessionRequestParams): void {
|
|
72
|
+
if (params.metadata !== undefined) {
|
|
73
|
+
const metadata = params.metadata;
|
|
74
|
+
this.host.setMetadata(
|
|
75
|
+
metadata === null
|
|
76
|
+
? null
|
|
77
|
+
: { title: metadata.title ?? '', artist: metadata.artist ?? '', album: metadata.album ?? '', artwork: metadata.artwork ? [{ src: metadata.artwork }] : [] },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (params.playbackState !== undefined) this.host.setPlaybackState(params.playbackState);
|
|
81
|
+
if (params.position !== undefined) {
|
|
82
|
+
const position = params.position;
|
|
83
|
+
this.host.setPositionState(position === null ? null : { duration: position.duration, playbackRate: position.rate, position: position.position });
|
|
84
|
+
}
|
|
85
|
+
if (params.skip !== undefined) this.skip = params.skip;
|
|
86
|
+
if (params.commands !== undefined) this.listen(new Set(params.commands));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Listens to exactly `wanted`: handlers for the new ones, none for the dropped ones. */
|
|
90
|
+
private listen(wanted: Set<MediaSessionCommand>): void {
|
|
91
|
+
for (const command of this.registered) {
|
|
92
|
+
if (wanted.has(command)) continue;
|
|
93
|
+
this.setHandler(command, null);
|
|
94
|
+
this.registered.delete(command);
|
|
95
|
+
}
|
|
96
|
+
for (const command of wanted) {
|
|
97
|
+
if (this.registered.has(command)) continue;
|
|
98
|
+
this.setHandler(command, (details) => this.deliver(this.received(command, details)));
|
|
99
|
+
this.registered.add(command);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private setHandler(command: MediaSessionCommand, handler: ((details: MediaSessionActionDetails) => void) | null): void {
|
|
104
|
+
try {
|
|
105
|
+
this.host.setActionHandler(ACTIONS[command], handler);
|
|
106
|
+
} catch {
|
|
107
|
+
// An action this browser does not know (`seekto` on an older Safari): the button never shows.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private received(command: MediaSessionCommand, details: MediaSessionActionDetails): ReceivedCommand {
|
|
112
|
+
const received: ReceivedCommand = { command };
|
|
113
|
+
if (command === 'seekTo' && typeof details.seekTime === 'number') received.position = details.seekTime;
|
|
114
|
+
if (command === 'skipForward' || command === 'skipBackward') {
|
|
115
|
+
const preferred = command === 'skipForward' ? this.skip.forward : this.skip.backward;
|
|
116
|
+
const interval = typeof details.seekOffset === 'number' ? details.seekOffset : preferred;
|
|
117
|
+
if (interval !== undefined) received.interval = interval;
|
|
118
|
+
}
|
|
119
|
+
return received;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private deliver(command: ReceivedCommand): void {
|
|
123
|
+
const wake = this.waiting.shift();
|
|
124
|
+
if (wake) wake(command);
|
|
125
|
+
else this.queue.push(command);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private receive(): Promise<Record<string, unknown>> {
|
|
129
|
+
const queued = this.queue.shift();
|
|
130
|
+
if (queued) return Promise.resolve({ ...queued });
|
|
131
|
+
return new Promise((resolve) => this.waiting.push((command) => resolve({ ...command })));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function performMediaSession(params: MediaSessionRequestParams, bridge: MediaSessionBridge | undefined): Promise<Record<string, unknown>> {
|
|
136
|
+
if (!bridge) {
|
|
137
|
+
// No Media Session API: nothing to show, and no command will ever come.
|
|
138
|
+
if (params.action === 'update') return {};
|
|
139
|
+
throw new Error('unsupported: this browser has no Media Session');
|
|
140
|
+
}
|
|
141
|
+
return bridge.perform(params);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `navigator.mediaSession`, where the browser has it. */
|
|
145
|
+
export function browserMediaSessionHost(): MediaSessionHost | undefined {
|
|
146
|
+
if (typeof navigator === 'undefined' || !('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return undefined;
|
|
147
|
+
const session = navigator.mediaSession;
|
|
148
|
+
return {
|
|
149
|
+
setMetadata: (metadata) => {
|
|
150
|
+
session.metadata = metadata === null ? null : new MediaMetadata(metadata);
|
|
151
|
+
},
|
|
152
|
+
setPlaybackState: (state) => {
|
|
153
|
+
session.playbackState = state;
|
|
154
|
+
},
|
|
155
|
+
setPositionState: (state) => {
|
|
156
|
+
if (typeof session.setPositionState !== 'function') return;
|
|
157
|
+
if (state === null) session.setPositionState();
|
|
158
|
+
// A position past the duration, or a duration that is not a finite number, throws.
|
|
159
|
+
else if (Number.isFinite(state.duration) && state.duration >= 0) session.setPositionState({ ...state, position: Math.min(Math.max(state.position, 0), state.duration) });
|
|
160
|
+
},
|
|
161
|
+
setActionHandler: (action, handler) => session.setActionHandler(action, handler),
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/notifications.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
* comes due; the page only answers for the permission and shows what it is
|
|
5
5
|
* told to, with the browser's Notification API. Nothing is scheduled here, so
|
|
6
6
|
* nothing fires once the page is closed.
|
|
7
|
+
*
|
|
8
|
+
* Remote notifications are Web Push ("Push notifications" there): the app's
|
|
9
|
+
* `registerForRemoteNotifications` becomes `pushSubscribe`, which registers
|
|
10
|
+
* the site's service worker (public/sw.js) and subscribes through it; the
|
|
11
|
+
* subscription is the app's device token. The worker shows each push and
|
|
12
|
+
* reports pushes and clicks to the page, which hands them to the app one
|
|
13
|
+
* `receive` request at a time.
|
|
7
14
|
*/
|
|
8
15
|
|
|
9
16
|
import type { NotificationRequestParams } from './protocol';
|
|
@@ -11,11 +18,26 @@ import type { NotificationRequestParams } from './protocol';
|
|
|
11
18
|
/** `NotificationPermission`, or no Notification API at all. */
|
|
12
19
|
export type NotificationPermissionState = NotificationPermission | 'unsupported';
|
|
13
20
|
|
|
21
|
+
/** A push subscription as `PushSubscription.toJSON()` gives it: what a server sends to. */
|
|
22
|
+
export interface PushSubscriptionInfo {
|
|
23
|
+
endpoint: string;
|
|
24
|
+
keys: { p256dh: string; auth: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the service worker reports: a push that arrived, or a click on a notification it showed. */
|
|
28
|
+
export type PushEvent = { type: 'push'; payload: unknown } | { type: 'click'; payload: unknown; action: string };
|
|
29
|
+
|
|
14
30
|
export interface NotificationResult {
|
|
15
31
|
/** `status` and `request`. */
|
|
16
32
|
permission?: NotificationPermissionState;
|
|
17
33
|
/** `show`: whether a notification appeared. */
|
|
18
34
|
shown?: boolean;
|
|
35
|
+
/** `pushSubscribe`. */
|
|
36
|
+
subscription?: PushSubscriptionInfo;
|
|
37
|
+
/** `receive`: the event's fields. */
|
|
38
|
+
type?: PushEvent['type'];
|
|
39
|
+
payload?: unknown;
|
|
40
|
+
action?: string;
|
|
19
41
|
}
|
|
20
42
|
|
|
21
43
|
/** The browser's notification facilities; tests substitute them. */
|
|
@@ -28,6 +50,17 @@ export interface NotificationHost {
|
|
|
28
50
|
close(tags: readonly string[]): void;
|
|
29
51
|
/** `navigator.setAppBadge` (0 clears), where the browser has it. */
|
|
30
52
|
setBadge?(count: number): Promise<void>;
|
|
53
|
+
/** Web Push through the service worker, where the browser has it. */
|
|
54
|
+
push?: PushHost;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The push service, as the service worker reaches it; tests substitute it. */
|
|
58
|
+
export interface PushHost {
|
|
59
|
+
/** Subscribes with the key (registering the worker first); the subscription. */
|
|
60
|
+
subscribe(applicationServerKey: string): Promise<PushSubscriptionInfo>;
|
|
61
|
+
unsubscribe(): Promise<void>;
|
|
62
|
+
/** Resolves with the next push or click the worker reported. */
|
|
63
|
+
receive(): Promise<PushEvent>;
|
|
31
64
|
}
|
|
32
65
|
|
|
33
66
|
export async function performNotification(params: NotificationRequestParams, host: NotificationHost | undefined): Promise<NotificationResult> {
|
|
@@ -55,13 +88,29 @@ export async function performNotification(params: NotificationRequestParams, hos
|
|
|
55
88
|
case 'badge':
|
|
56
89
|
await host?.setBadge?.(params.badge ?? 0).catch(() => undefined);
|
|
57
90
|
return {};
|
|
91
|
+
case 'pushSubscribe': {
|
|
92
|
+
if (!host?.push) throw new Error('unsupported: this browser has no push service');
|
|
93
|
+
if (!params.applicationServerKey) throw new Error('push: no application server key (webPushKey in swiftbrowser.json)');
|
|
94
|
+
// A subscription needs the permission: asked for here when it has not been.
|
|
95
|
+
const granted = permission === 'default' ? await host.requestPermission() : permission;
|
|
96
|
+
if (granted !== 'granted') throw new Error('denied: notifications are not allowed');
|
|
97
|
+
return { subscription: await host.push.subscribe(params.applicationServerKey) };
|
|
98
|
+
}
|
|
99
|
+
case 'pushUnsubscribe':
|
|
100
|
+
await host?.push?.unsubscribe();
|
|
101
|
+
return {};
|
|
102
|
+
case 'receive': {
|
|
103
|
+
if (!host?.push) throw new Error('unsupported: this browser has no push service');
|
|
104
|
+
const event = await host.push.receive();
|
|
105
|
+
return event.type === 'click' ? { type: 'click', payload: event.payload, action: event.action } : { type: 'push', payload: event.payload };
|
|
106
|
+
}
|
|
58
107
|
default:
|
|
59
108
|
throw new Error(`unsupported: notification action ${String((params as { action: unknown }).action)}`);
|
|
60
109
|
}
|
|
61
110
|
}
|
|
62
111
|
|
|
63
112
|
/** The Notification API, or nothing where the browser has none (an iPhone tab not on the Home Screen). */
|
|
64
|
-
export function browserNotificationHost(): NotificationHost | undefined {
|
|
113
|
+
export function browserNotificationHost(push?: PushHost): NotificationHost | undefined {
|
|
65
114
|
if (typeof window === 'undefined' || typeof Notification === 'undefined') return undefined;
|
|
66
115
|
const open = new Map<string, Notification>();
|
|
67
116
|
const badgeNavigator = navigator as Navigator & { setAppBadge?: (n?: number) => Promise<void>; clearAppBadge?: () => Promise<void> };
|
|
@@ -105,5 +154,89 @@ export function browserNotificationHost(): NotificationHost | undefined {
|
|
|
105
154
|
count > 0 ? badgeNavigator.setAppBadge!(count) : (badgeNavigator.clearAppBadge?.() ?? Promise.resolve()),
|
|
106
155
|
}
|
|
107
156
|
: {}),
|
|
157
|
+
...(push ? { push } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The message types the service worker (public/sw.js) posts to the page. */
|
|
162
|
+
export const PUSH_MESSAGE = 'swiftbrowser:push';
|
|
163
|
+
export const CLICK_MESSAGE = 'swiftbrowser:notificationclick';
|
|
164
|
+
|
|
165
|
+
/** The push event a service worker message carries, or null when `data` is not one. */
|
|
166
|
+
export function pushEventOf(data: unknown): PushEvent | null {
|
|
167
|
+
if (typeof data !== 'object' || data === null) return null;
|
|
168
|
+
const message = data as { type?: unknown; payload?: unknown; action?: unknown };
|
|
169
|
+
if (message.type === PUSH_MESSAGE) return { type: 'push', payload: message.payload ?? {} };
|
|
170
|
+
if (message.type === CLICK_MESSAGE) return { type: 'click', payload: message.payload ?? {}, action: typeof message.action === 'string' ? message.action : '' };
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A VAPID public key (base64url, as servers print it) as the bytes `subscribe` takes. */
|
|
175
|
+
export function applicationServerKeyBytes(key: string): Uint8Array<ArrayBuffer> {
|
|
176
|
+
const base64 = key.replace(/-/g, '+').replace(/_/g, '/');
|
|
177
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
178
|
+
const binary = atob(padded);
|
|
179
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
180
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
181
|
+
return bytes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The subscription's `toJSON()`, checked for the fields a server needs. */
|
|
185
|
+
function subscriptionInfo(subscription: PushSubscription): PushSubscriptionInfo {
|
|
186
|
+
const json = subscription.toJSON();
|
|
187
|
+
const keys = json.keys ?? {};
|
|
188
|
+
if (!json.endpoint || typeof keys['p256dh'] !== 'string' || typeof keys['auth'] !== 'string') throw new Error('push: the subscription has no keys');
|
|
189
|
+
return { endpoint: json.endpoint, keys: { p256dh: keys['p256dh'], auth: keys['auth'] } };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Web Push through the service worker at `workerURL` with `scope` (the
|
|
194
|
+
* site's base), or nothing where the browser has no push service (Safari
|
|
195
|
+
* before 16.4; an iPhone tab not on the Home Screen). Listens for the
|
|
196
|
+
* worker's messages from the start: a click that opened the page is posted
|
|
197
|
+
* before the app asks for it.
|
|
198
|
+
*/
|
|
199
|
+
export function browserPushHost(workerURL: string, scope: string): PushHost | undefined {
|
|
200
|
+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator) || !navigator.serviceWorker || typeof PushManager === 'undefined') return undefined;
|
|
201
|
+
const container = navigator.serviceWorker;
|
|
202
|
+
const queue: PushEvent[] = [];
|
|
203
|
+
const waiting: ((event: PushEvent) => void)[] = [];
|
|
204
|
+
container.addEventListener('message', (message: MessageEvent) => {
|
|
205
|
+
const event = pushEventOf(message.data);
|
|
206
|
+
if (!event) return;
|
|
207
|
+
const wake = waiting.shift();
|
|
208
|
+
if (wake) wake(event);
|
|
209
|
+
else queue.push(event);
|
|
210
|
+
});
|
|
211
|
+
const registration = async (): Promise<ServiceWorkerRegistration> => {
|
|
212
|
+
const existing = await container.getRegistration(scope);
|
|
213
|
+
if (existing?.active && existing.scope === new URL(scope, location.href).href) return existing;
|
|
214
|
+
return container.register(workerURL, { scope });
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
subscribe: async (key) => {
|
|
218
|
+
const worker = await registration();
|
|
219
|
+
await container.ready;
|
|
220
|
+
const options: PushSubscriptionOptionsInit = { userVisibleOnly: true, applicationServerKey: applicationServerKeyBytes(key) };
|
|
221
|
+
const current = await worker.pushManager.getSubscription();
|
|
222
|
+
try {
|
|
223
|
+
return subscriptionInfo(current ?? (await worker.pushManager.subscribe(options)));
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// A subscription under another key stands in the way: replaced.
|
|
226
|
+
if (!current) throw error;
|
|
227
|
+
await current.unsubscribe().catch(() => undefined);
|
|
228
|
+
return subscriptionInfo(await worker.pushManager.subscribe(options));
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
unsubscribe: async () => {
|
|
232
|
+
const worker = await container.getRegistration(scope);
|
|
233
|
+
const current = await worker?.pushManager.getSubscription();
|
|
234
|
+
await current?.unsubscribe();
|
|
235
|
+
},
|
|
236
|
+
receive: () => {
|
|
237
|
+
const queued = queue.shift();
|
|
238
|
+
if (queued) return Promise.resolve(queued);
|
|
239
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
240
|
+
},
|
|
108
241
|
};
|
|
109
242
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -1076,7 +1076,7 @@ export interface DefaultsOp {
|
|
|
1076
1076
|
export interface RequestOp {
|
|
1077
1077
|
op: 'request';
|
|
1078
1078
|
id: number;
|
|
1079
|
-
kind: 'fetch' | 'image' | 'share' | 'pickFiles' | 'notification' | 'scanner' | 'websocket' | 'openURL' | 'webAuth' | 'media' | 'download' | 'clipboard' | 'translate' | 'pickColor';
|
|
1079
|
+
kind: 'fetch' | 'image' | 'share' | 'pickFiles' | 'notification' | 'scanner' | 'websocket' | 'openURL' | 'webAuth' | 'media' | 'download' | 'clipboard' | 'translate' | 'pickColor' | 'mediaSession';
|
|
1080
1080
|
params:
|
|
1081
1081
|
| FetchRequestParams
|
|
1082
1082
|
| ImageRequestParams
|
|
@@ -1091,7 +1091,8 @@ export interface RequestOp {
|
|
|
1091
1091
|
| DownloadRequestParams
|
|
1092
1092
|
| ClipboardRequestParams
|
|
1093
1093
|
| TranslateRequestParams
|
|
1094
|
-
| PickColorRequestParams
|
|
1094
|
+
| PickColorRequestParams
|
|
1095
|
+
| MediaSessionRequestParams;
|
|
1095
1096
|
}
|
|
1096
1097
|
/**
|
|
1097
1098
|
* `translate` (Translation; src/translation.ts): `availability` of a pair
|
|
@@ -1228,7 +1229,7 @@ export interface PickFilesRequestParams {
|
|
|
1228
1229
|
* response value is a `NotificationResult`.
|
|
1229
1230
|
*/
|
|
1230
1231
|
export interface NotificationRequestParams {
|
|
1231
|
-
action: 'status' | 'request' | 'show' | 'close' | 'badge';
|
|
1232
|
+
action: 'status' | 'request' | 'show' | 'close' | 'badge' | 'pushSubscribe' | 'pushUnsubscribe' | 'receive';
|
|
1232
1233
|
/** `show`: the request's identifier, the notification's `tag`. */
|
|
1233
1234
|
id?: string;
|
|
1234
1235
|
title?: string;
|
|
@@ -1239,6 +1240,8 @@ export interface NotificationRequestParams {
|
|
|
1239
1240
|
badge?: number;
|
|
1240
1241
|
/** `close`: the identifiers to close. */
|
|
1241
1242
|
ids?: string[];
|
|
1243
|
+
/** `pushSubscribe`: the Web Push application server key (a VAPID public key, base64url). */
|
|
1244
|
+
applicationServerKey?: string;
|
|
1242
1245
|
}
|
|
1243
1246
|
|
|
1244
1247
|
/**
|
|
@@ -2127,6 +2130,8 @@ export interface SlideEvent {
|
|
|
2127
2130
|
type: 'slide';
|
|
2128
2131
|
id: ElementId;
|
|
2129
2132
|
value: number;
|
|
2133
|
+
/** `onEditingChanged`: true with the first value of a drag, false with its last; absent for the values between. */
|
|
2134
|
+
editing?: boolean;
|
|
2130
2135
|
}
|
|
2131
2136
|
/** A `datepicker` changed: seconds since 1970 (an instant; the zone only shaped the reading). */
|
|
2132
2137
|
export interface DateEvent {
|
|
@@ -2556,10 +2561,34 @@ export interface VideoEvent {
|
|
|
2556
2561
|
}
|
|
2557
2562
|
|
|
2558
2563
|
/** `media` requests (src/media.ts): AVFoundation's files and probes, QuickLook's previews. */
|
|
2564
|
+
/** A remote command MediaPlayer's `MPRemoteCommandCenter` handles (docs/ops-protocol.md, "Now playing"). */
|
|
2565
|
+
export type MediaSessionCommand = 'play' | 'pause' | 'stop' | 'nextTrack' | 'previousTrack' | 'seekTo' | 'skipForward' | 'skipBackward';
|
|
2566
|
+
|
|
2567
|
+
/**
|
|
2568
|
+
* `mediaSession` (src/mediaSession.ts): MediaPlayer's now-playing info and
|
|
2569
|
+
* remote commands over the browser's Media Session API. `update` sets what
|
|
2570
|
+
* the session shows and which commands the app handles (a field left out
|
|
2571
|
+
* stays as it was); `receive` answers the next command the viewer gave.
|
|
2572
|
+
*/
|
|
2573
|
+
export interface MediaSessionRequestParams {
|
|
2574
|
+
action: 'update' | 'receive';
|
|
2575
|
+
/** `update`: what plays, for the lock screen and the media controls (`artwork` a URL); null clears it. */
|
|
2576
|
+
metadata?: { title?: string; artist?: string; album?: string; artwork?: string } | null;
|
|
2577
|
+
playbackState?: 'none' | 'playing' | 'paused';
|
|
2578
|
+
/** `update`: the position in seconds, which the browser extrapolates at `rate`; null clears it. */
|
|
2579
|
+
position?: { duration: number; position: number; rate: number } | null;
|
|
2580
|
+
/** `update`: the commands the app handles; the others get no button. */
|
|
2581
|
+
commands?: MediaSessionCommand[];
|
|
2582
|
+
/** `update`: the app's preferred skip intervals in seconds, answered with a skip the system gives no offset for. */
|
|
2583
|
+
skip?: { forward?: number; backward?: number };
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2559
2586
|
export interface MediaRequestParams {
|
|
2560
2587
|
action: 'file' | 'probe' | 'release' | 'open';
|
|
2561
2588
|
url?: string;
|
|
2562
2589
|
name?: string;
|
|
2563
2590
|
type?: string;
|
|
2564
2591
|
data?: string;
|
|
2592
|
+
/** `file`: a resource's path inside the app's bundle, in place of `data`: the page answers the URL it serves the resource at. */
|
|
2593
|
+
path?: string;
|
|
2565
2594
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -786,7 +786,7 @@ export class Renderer {
|
|
|
786
786
|
const id = Number(slider.dataset[ID_ATTR]);
|
|
787
787
|
if (!track || !Number.isInteger(id)) return;
|
|
788
788
|
event.preventDefault();
|
|
789
|
-
const drag: SliderDrag = { slider, track, id, pointerId: event.pointerId, pending: null, sent: null, frame: null };
|
|
789
|
+
const drag: SliderDrag = { slider, track, id, pointerId: event.pointerId, pending: null, sent: null, frame: null, starting: true };
|
|
790
790
|
this.sliderDrag = drag;
|
|
791
791
|
window.addEventListener('pointermove', this.onSliderMove);
|
|
792
792
|
window.addEventListener('pointerup', this.onSliderUp);
|
|
@@ -817,9 +817,11 @@ export class Renderer {
|
|
|
817
817
|
}
|
|
818
818
|
|
|
819
819
|
private flushSlide(drag: SliderDrag): void {
|
|
820
|
-
if (drag.pending === null || drag.pending === drag.sent) return;
|
|
820
|
+
if (drag.pending === null || (drag.pending === drag.sent && !drag.starting)) return;
|
|
821
821
|
drag.sent = drag.pending;
|
|
822
|
-
|
|
822
|
+
// The drag's first value says editing began (`onEditingChanged(true)`); the last, on release, that it ended.
|
|
823
|
+
this.onEvent(drag.starting ? { type: 'slide', id: drag.id, value: drag.pending, editing: true } : { type: 'slide', id: drag.id, value: drag.pending });
|
|
824
|
+
drag.starting = false;
|
|
823
825
|
}
|
|
824
826
|
|
|
825
827
|
private readonly onSliderMove = (event: PointerEvent): void => {
|
|
@@ -838,6 +840,7 @@ export class Renderer {
|
|
|
838
840
|
if (drag.frame !== null) window.cancelAnimationFrame(drag.frame);
|
|
839
841
|
drag.frame = null;
|
|
840
842
|
this.flushSlide(drag);
|
|
843
|
+
if (drag.sent !== null) this.onEvent({ type: 'slide', id: drag.id, value: drag.sent, editing: false });
|
|
841
844
|
// The click that follows is the slide itself, not a tap on an enclosing control.
|
|
842
845
|
this.swallowNextClick();
|
|
843
846
|
};
|
|
@@ -3351,6 +3354,8 @@ interface SliderDrag {
|
|
|
3351
3354
|
/** The last value sent, so an unchanged frame sends nothing. */
|
|
3352
3355
|
sent: number | null;
|
|
3353
3356
|
frame: number | null;
|
|
3357
|
+
/** Whether the drag's first `slide` (with `editing: true`) is still to be sent. */
|
|
3358
|
+
starting: boolean;
|
|
3354
3359
|
}
|
|
3355
3360
|
|
|
3356
3361
|
function applyAlert(el: HTMLElement, props: AlertProps): void {
|
package/src/vite-env.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ interface SBLandingApp {
|
|
|
18
18
|
urlSchemes?: string[];
|
|
19
19
|
/** A CORS proxy (packages/cors-proxy) through which a refused cross-origin GET is retried (src/corsProxy.ts). */
|
|
20
20
|
corsProxy?: string;
|
|
21
|
+
/** The Web Push application server key the app subscribes with (`SB_WEB_PUSH_KEY`, src/notifications.ts). */
|
|
22
|
+
webPushKey?: string;
|
|
21
23
|
/** Hosts the app's requests may not reach (src/blockedHosts.ts). */
|
|
22
24
|
blockedHosts?: string[];
|
|
23
25
|
}
|