@timmo001/groundwork-pwa 0.0.0-stage → 0.0.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/LICENSE +201 -0
- package/README.md +30 -2
- package/package.json +49 -4
- package/src/build.ts +234 -0
- package/src/config.ts +177 -0
- package/src/pairing.ts +142 -0
- package/src/push.ts +122 -0
- package/src/view.ts +258 -0
- package/src/worker.ts +345 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An app's `pwa.config`: what makes a Groundwork web app installable and
|
|
3
|
+
* usable as one on a phone, each piece on unless the config turns it off.
|
|
4
|
+
* Shared by the build, which writes the manifest, and the page.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One of the manifest's icons. */
|
|
8
|
+
export interface PwaIcon {
|
|
9
|
+
readonly src: string;
|
|
10
|
+
readonly sizes: string;
|
|
11
|
+
readonly type?: string;
|
|
12
|
+
readonly purpose?: "any" | "maskable" | "monochrome";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A shortcut in the launcher's long-press menu. */
|
|
16
|
+
export interface PwaShortcut {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly url: string;
|
|
19
|
+
readonly shortName?: string;
|
|
20
|
+
readonly description?: string;
|
|
21
|
+
readonly icons?: readonly PwaIcon[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Where shares arrive. */
|
|
25
|
+
export interface ShareTargetConfig {
|
|
26
|
+
/** The page shares open, in the app's scope. Defaults to `startUrl`. */
|
|
27
|
+
readonly action?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The service worker that keeps the app shell for offline starts. */
|
|
31
|
+
export interface OfflineConfig {
|
|
32
|
+
/** The worker's script, in the app's scope. Defaults to `sw.js`. */
|
|
33
|
+
readonly worker?: string;
|
|
34
|
+
/** Pages and files cached when the worker installs. Defaults to `startUrl`. */
|
|
35
|
+
readonly shell?: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The modes a manifest's colors can come from. */
|
|
39
|
+
export type PwaMode = "light" | "dark" | "darker";
|
|
40
|
+
|
|
41
|
+
/** A value a manifest member can hold. */
|
|
42
|
+
export type JsonValue =
|
|
43
|
+
| string
|
|
44
|
+
| number
|
|
45
|
+
| boolean
|
|
46
|
+
| null
|
|
47
|
+
| readonly JsonValue[]
|
|
48
|
+
| { readonly [key: string]: JsonValue };
|
|
49
|
+
|
|
50
|
+
/** What a web app turns on. Only the name is needed. */
|
|
51
|
+
export interface PwaConfig {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
/** The name under the launcher icon. Defaults to `name`. */
|
|
54
|
+
readonly shortName?: string;
|
|
55
|
+
readonly description?: string;
|
|
56
|
+
/** The Groundwork theme the colors come from. Defaults to `base`. */
|
|
57
|
+
readonly theme?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The mode the manifest's colors come from, for the splash screen and task
|
|
60
|
+
* switcher, which take one color. The page's own `theme-color` follows the
|
|
61
|
+
* system's mode. Defaults to `light`.
|
|
62
|
+
*/
|
|
63
|
+
readonly mode?: PwaMode;
|
|
64
|
+
/** The browser's bar color. Defaults to the theme's `surface-default`. */
|
|
65
|
+
readonly themeColor?: string;
|
|
66
|
+
/** The splash screen's color. Defaults to the theme's `surface-default`. */
|
|
67
|
+
readonly backgroundColor?: string;
|
|
68
|
+
/** The app's identity across installs. Defaults to `startUrl`. */
|
|
69
|
+
readonly id?: string;
|
|
70
|
+
/** Defaults to `./`, the page the manifest sits beside. */
|
|
71
|
+
readonly startUrl?: string;
|
|
72
|
+
/** Defaults to `./`. */
|
|
73
|
+
readonly scope?: string;
|
|
74
|
+
/** Defaults to `standalone`. */
|
|
75
|
+
readonly display?: "standalone" | "minimal-ui" | "fullscreen" | "browser";
|
|
76
|
+
/** Defaults to `icons/icon-192.png`, `icon-512.png` and `maskable-512.png`. */
|
|
77
|
+
readonly icons?: readonly PwaIcon[];
|
|
78
|
+
readonly shortcuts?: readonly PwaShortcut[];
|
|
79
|
+
/** `<meta name="color-scheme" content="dark light">` and `theme-color` per mode. */
|
|
80
|
+
readonly colorScheme?: boolean;
|
|
81
|
+
/** Holds the browser's install prompt for the app to offer when it chooses. */
|
|
82
|
+
readonly install?: boolean;
|
|
83
|
+
/** Takes shares from other apps, delivered as a `gw-share` event. */
|
|
84
|
+
readonly shareTarget?: boolean | ShareTargetConfig;
|
|
85
|
+
/** A service worker that caches the app shell for offline starts. */
|
|
86
|
+
readonly offline?: boolean | OfflineConfig;
|
|
87
|
+
/**
|
|
88
|
+
* Manifest members to add or replace, written over the generated ones, such
|
|
89
|
+
* as `orientation`, `categories` or `screenshots`.
|
|
90
|
+
*/
|
|
91
|
+
readonly manifest?: { readonly [key: string]: JsonValue };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Every option filled in. */
|
|
95
|
+
export interface ResolvedPwaConfig {
|
|
96
|
+
readonly name: string;
|
|
97
|
+
readonly shortName: string;
|
|
98
|
+
readonly description: string;
|
|
99
|
+
readonly theme: string;
|
|
100
|
+
readonly mode: PwaMode;
|
|
101
|
+
readonly id: string;
|
|
102
|
+
readonly startUrl: string;
|
|
103
|
+
readonly scope: string;
|
|
104
|
+
readonly display: NonNullable<PwaConfig["display"]>;
|
|
105
|
+
readonly icons: readonly PwaIcon[];
|
|
106
|
+
readonly shortcuts: readonly PwaShortcut[];
|
|
107
|
+
readonly colorScheme: boolean;
|
|
108
|
+
readonly install: boolean;
|
|
109
|
+
readonly shareTarget: false | Required<ShareTargetConfig>;
|
|
110
|
+
readonly offline: false | Required<OfflineConfig>;
|
|
111
|
+
readonly themeColor: string | undefined;
|
|
112
|
+
readonly backgroundColor: string | undefined;
|
|
113
|
+
readonly manifest: { readonly [key: string]: JsonValue };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A share from another app, from the share target or the Android shell. */
|
|
117
|
+
export interface Share {
|
|
118
|
+
readonly title?: string;
|
|
119
|
+
readonly text?: string;
|
|
120
|
+
readonly url?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The query parameters the share target sends, kept apart from the app's own. */
|
|
124
|
+
export const shareParams = {
|
|
125
|
+
title: "share-title",
|
|
126
|
+
text: "share-text",
|
|
127
|
+
url: "share-url",
|
|
128
|
+
} as const satisfies Record<keyof Share, string>;
|
|
129
|
+
|
|
130
|
+
/** Types a config in the app's `pwa.config.ts`. */
|
|
131
|
+
export const definePwaConfig = (config: PwaConfig): PwaConfig => config;
|
|
132
|
+
|
|
133
|
+
/** Fills in the defaults. */
|
|
134
|
+
export const resolveConfig = (config: PwaConfig): ResolvedPwaConfig => {
|
|
135
|
+
const startUrl = config.startUrl ?? "./";
|
|
136
|
+
const share = config.shareTarget ?? true;
|
|
137
|
+
const offline = config.offline ?? true;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
name: config.name,
|
|
141
|
+
shortName: config.shortName ?? config.name,
|
|
142
|
+
description: config.description ?? "",
|
|
143
|
+
theme: config.theme ?? "base",
|
|
144
|
+
mode: config.mode ?? "light",
|
|
145
|
+
id: config.id ?? startUrl,
|
|
146
|
+
startUrl,
|
|
147
|
+
scope: config.scope ?? "./",
|
|
148
|
+
display: config.display ?? "standalone",
|
|
149
|
+
icons: config.icons ?? [
|
|
150
|
+
{ src: "icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
|
151
|
+
{ src: "icons/icon-512.png", sizes: "512x512", type: "image/png" },
|
|
152
|
+
{
|
|
153
|
+
src: "icons/maskable-512.png",
|
|
154
|
+
sizes: "512x512",
|
|
155
|
+
type: "image/png",
|
|
156
|
+
purpose: "maskable",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
shortcuts: config.shortcuts ?? [],
|
|
160
|
+
colorScheme: config.colorScheme ?? true,
|
|
161
|
+
install: config.install ?? true,
|
|
162
|
+
shareTarget:
|
|
163
|
+
share === false
|
|
164
|
+
? false
|
|
165
|
+
: { action: (share === true ? undefined : share.action) ?? startUrl },
|
|
166
|
+
offline:
|
|
167
|
+
offline === false
|
|
168
|
+
? false
|
|
169
|
+
: {
|
|
170
|
+
worker: (offline === true ? undefined : offline.worker) ?? "sw.js",
|
|
171
|
+
shell: (offline === true ? undefined : offline.shell) ?? [startUrl],
|
|
172
|
+
},
|
|
173
|
+
themeColor: config.themeColor,
|
|
174
|
+
backgroundColor: config.backgroundColor,
|
|
175
|
+
manifest: config.manifest ?? {},
|
|
176
|
+
};
|
|
177
|
+
};
|
package/src/pairing.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pairing contract: connecting a phone to a service without typing an
|
|
3
|
+
* address or a credential. The service shows a one-time link as text and a
|
|
4
|
+
* QR code; the app opens or scans it, trades the code for a token and keeps
|
|
5
|
+
* that. Documented on the Behavior page, beside push.
|
|
6
|
+
*/
|
|
7
|
+
import { Option, Schema } from "effect";
|
|
8
|
+
|
|
9
|
+
import { PushClient } from "./push.ts";
|
|
10
|
+
|
|
11
|
+
/** The service's route, under its own base URL. */
|
|
12
|
+
export const pairingRoutes = {
|
|
13
|
+
/** `POST` a {@link PairingRequest}, answered with a {@link Pairing}. */
|
|
14
|
+
pair: "pair",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/** The query parameter the link carries its one-time code in. */
|
|
18
|
+
export const pairingParam = "pair";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A pairing link: the service's base URL with the code, such as
|
|
22
|
+
* `https://bridge.local:9170/?pair=4f9c…`. It works once, and the service
|
|
23
|
+
* forgets it after `expires`, a few minutes after it was made.
|
|
24
|
+
*/
|
|
25
|
+
export const PairingLink = Schema.Struct({
|
|
26
|
+
service: Schema.String,
|
|
27
|
+
code: Schema.String,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export interface PairingLink extends Schema.Schema.Type<typeof PairingLink> {}
|
|
31
|
+
|
|
32
|
+
/** What the app sends to trade the code: the code and who it is. */
|
|
33
|
+
export const PairingRequest = Schema.Struct({
|
|
34
|
+
code: Schema.String,
|
|
35
|
+
client: PushClient,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export interface PairingRequest extends Schema.Schema.Type<
|
|
39
|
+
typeof PairingRequest
|
|
40
|
+
> {}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The service's answer: a token the app sends as `Authorization: Bearer`
|
|
44
|
+
* from then on, and the name to show for the service. The service keeps the
|
|
45
|
+
* client, when it paired and when it was last seen, and lists them so one
|
|
46
|
+
* can be removed, which ends its token.
|
|
47
|
+
*/
|
|
48
|
+
export const Pairing = Schema.Struct({
|
|
49
|
+
token: Schema.String,
|
|
50
|
+
service: Schema.Struct({ id: Schema.String, name: Schema.String }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export interface Pairing extends Schema.Schema.Type<typeof Pairing> {}
|
|
54
|
+
|
|
55
|
+
/** The link for a service and a code, for the text and the QR code. */
|
|
56
|
+
export const pairingUrl = ({ service, code }: PairingLink): string => {
|
|
57
|
+
const url = new URL(service);
|
|
58
|
+
|
|
59
|
+
url.searchParams.set(pairingParam, code);
|
|
60
|
+
|
|
61
|
+
return url.href;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Reads a pairing link, or `undefined` when the text isn't one. */
|
|
65
|
+
export const parsePairingLink = (text: string): PairingLink | undefined => {
|
|
66
|
+
if (!URL.canParse(text)) return undefined;
|
|
67
|
+
|
|
68
|
+
const url = new URL(text);
|
|
69
|
+
const code = url.searchParams.get(pairingParam);
|
|
70
|
+
|
|
71
|
+
if (code === null || code === "") return undefined;
|
|
72
|
+
|
|
73
|
+
url.searchParams.delete(pairingParam);
|
|
74
|
+
|
|
75
|
+
return { service: url.href, code };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const decodePairing = Schema.decodeUnknownOption(Pairing);
|
|
79
|
+
|
|
80
|
+
/** Trades a link's code for a token, or `undefined` when the service refuses. */
|
|
81
|
+
export const pair = async (
|
|
82
|
+
link: PairingLink,
|
|
83
|
+
client: PushClient,
|
|
84
|
+
): Promise<Pairing | undefined> => {
|
|
85
|
+
const request: PairingRequest = { code: link.code, client };
|
|
86
|
+
|
|
87
|
+
const response = await fetch(new URL(pairingRoutes.pair, link.service), {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "content-type": "application/json" },
|
|
90
|
+
body: JSON.stringify(request),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!response.ok) return undefined;
|
|
94
|
+
|
|
95
|
+
return Option.getOrUndefined(decodePairing(await response.json()));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** The parts of the Barcode Detection API scanning uses. */
|
|
99
|
+
interface QrDetector {
|
|
100
|
+
readonly detect: (
|
|
101
|
+
source: HTMLVideoElement,
|
|
102
|
+
) => Promise<readonly { readonly rawValue: string }[]>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
declare global {
|
|
106
|
+
var BarcodeDetector:
|
|
107
|
+
| (new (options: { readonly formats: readonly string[] }) => QrDetector)
|
|
108
|
+
| undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True where the browser can read QR codes, as Chromium on Android can. */
|
|
112
|
+
export const canScan = (): boolean => globalThis.BarcodeDetector !== undefined;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reads QR codes from a playing camera video until one is a pairing link,
|
|
116
|
+
* or the signal aborts. The page starts the camera, so it owns the
|
|
117
|
+
* permission prompt; the Android shell passes it through.
|
|
118
|
+
*/
|
|
119
|
+
export const scanPairingLink = async (
|
|
120
|
+
video: HTMLVideoElement,
|
|
121
|
+
signal: AbortSignal,
|
|
122
|
+
): Promise<PairingLink | undefined> => {
|
|
123
|
+
const Detector = globalThis.BarcodeDetector;
|
|
124
|
+
|
|
125
|
+
if (Detector === undefined) return undefined;
|
|
126
|
+
|
|
127
|
+
const detector = new Detector({ formats: ["qr_code"] });
|
|
128
|
+
|
|
129
|
+
while (!signal.aborted) {
|
|
130
|
+
const codes = await detector.detect(video).catch(() => []);
|
|
131
|
+
|
|
132
|
+
const link = codes
|
|
133
|
+
.map(({ rawValue }) => parsePairingLink(rawValue))
|
|
134
|
+
.find((found) => found !== undefined);
|
|
135
|
+
|
|
136
|
+
if (link !== undefined) return link;
|
|
137
|
+
|
|
138
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return undefined;
|
|
142
|
+
};
|
package/src/push.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The push contract: how a service keeps its clients' push subscriptions and
|
|
3
|
+
* what it sends them. One shape for the PWA's Web Push, the Android shell's
|
|
4
|
+
* UnifiedPush and every service that pushes, documented on the Behavior page.
|
|
5
|
+
*/
|
|
6
|
+
import { Option, Schema } from "effect";
|
|
7
|
+
|
|
8
|
+
/** The service's routes, under its own base URL. */
|
|
9
|
+
export const pushRoutes = {
|
|
10
|
+
/** `GET`: the VAPID public key, as {@link VapidKey}. */
|
|
11
|
+
vapidKey: "push/vapid-key",
|
|
12
|
+
/** `POST` a {@link PushSubscription}; `DELETE` with `{ endpoint }` to stop. */
|
|
13
|
+
subscriptions: "push/subscriptions",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
/** The VAPID public key, base64url, for `pushManager.subscribe`. */
|
|
17
|
+
export const VapidKey = Schema.Struct({ publicKey: Schema.String });
|
|
18
|
+
|
|
19
|
+
export interface VapidKey extends Schema.Schema.Type<typeof VapidKey> {}
|
|
20
|
+
|
|
21
|
+
/** The client a subscription belongs to, kept so one client has one. */
|
|
22
|
+
export const PushClient = Schema.Struct({
|
|
23
|
+
/** Stable per install, such as a random id kept in storage. */
|
|
24
|
+
id: Schema.String,
|
|
25
|
+
/** What people see in the service's list of clients, such as "Pixel 9". */
|
|
26
|
+
name: Schema.String,
|
|
27
|
+
platform: Schema.Literals(["web", "android"]),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export interface PushClient extends Schema.Schema.Type<typeof PushClient> {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A subscription, as `PushSubscription.toJSON()` gives it in the browser and
|
|
34
|
+
* the shell sends a UnifiedPush endpoint. Posting one for a known client
|
|
35
|
+
* replaces that client's last.
|
|
36
|
+
*/
|
|
37
|
+
export const PushSubscription = Schema.Struct({
|
|
38
|
+
endpoint: Schema.String,
|
|
39
|
+
keys: Schema.Struct({ p256dh: Schema.String, auth: Schema.String }),
|
|
40
|
+
client: PushClient,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export interface PushSubscription extends Schema.Schema.Type<
|
|
44
|
+
typeof PushSubscription
|
|
45
|
+
> {}
|
|
46
|
+
|
|
47
|
+
/** Ongoing work, shown as a Live Update on Android 16 and a plain one below. */
|
|
48
|
+
export const PushOngoing = Schema.Struct({
|
|
49
|
+
/** Replaces the last update with the same id, and ends it when `done`. */
|
|
50
|
+
id: Schema.String,
|
|
51
|
+
status: Schema.String,
|
|
52
|
+
progress: Schema.optionalKey(
|
|
53
|
+
Schema.Struct({ value: Schema.Number, max: Schema.Number }),
|
|
54
|
+
),
|
|
55
|
+
done: Schema.optionalKey(Schema.Boolean),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export interface PushOngoing extends Schema.Schema.Type<typeof PushOngoing> {}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a push carries. The notification points at a page: `link` opens it,
|
|
62
|
+
* relative to the app's scope. `data` is the app's own, passed through.
|
|
63
|
+
*/
|
|
64
|
+
export const PushPayload = Schema.Struct({
|
|
65
|
+
title: Schema.String,
|
|
66
|
+
body: Schema.optionalKey(Schema.String),
|
|
67
|
+
tone: Schema.optionalKey(
|
|
68
|
+
Schema.Literals([
|
|
69
|
+
"neutral",
|
|
70
|
+
"accent",
|
|
71
|
+
"info",
|
|
72
|
+
"success",
|
|
73
|
+
"warning",
|
|
74
|
+
"danger",
|
|
75
|
+
"important",
|
|
76
|
+
]),
|
|
77
|
+
),
|
|
78
|
+
link: Schema.optionalKey(Schema.String),
|
|
79
|
+
/** Like notifications share a tag, so a new one replaces the last. */
|
|
80
|
+
tag: Schema.optionalKey(Schema.String),
|
|
81
|
+
/** An alert shows on screen; a passive one only goes to the shade. */
|
|
82
|
+
level: Schema.optionalKey(Schema.Literals(["alert", "passive"])),
|
|
83
|
+
ongoing: Schema.optionalKey(PushOngoing),
|
|
84
|
+
data: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export interface PushPayload extends Schema.Schema.Type<typeof PushPayload> {}
|
|
88
|
+
|
|
89
|
+
const decodePayloadJson = Schema.decodeUnknownOption(
|
|
90
|
+
Schema.fromJsonString(PushPayload),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
/** Reads a push's text, or `undefined` when it isn't a payload. */
|
|
94
|
+
export const decodePushPayload = (text: string): PushPayload | undefined =>
|
|
95
|
+
Option.getOrUndefined(decodePayloadJson(text));
|
|
96
|
+
|
|
97
|
+
/** What `DELETE push/subscriptions` takes. */
|
|
98
|
+
export const PushUnsubscribe = Schema.Struct({ endpoint: Schema.String });
|
|
99
|
+
|
|
100
|
+
export interface PushUnsubscribe extends Schema.Schema.Type<
|
|
101
|
+
typeof PushUnsubscribe
|
|
102
|
+
> {}
|
|
103
|
+
|
|
104
|
+
const decodeSubscriptionJson = Schema.decodeUnknownOption(
|
|
105
|
+
Schema.fromJsonString(PushSubscription),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const decodeUnsubscribeJson = Schema.decodeUnknownOption(
|
|
109
|
+
Schema.fromJsonString(PushUnsubscribe),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/** For a service: reads a posted subscription, or `undefined`. */
|
|
113
|
+
export const decodePushSubscription = (
|
|
114
|
+
text: string,
|
|
115
|
+
): PushSubscription | undefined =>
|
|
116
|
+
Option.getOrUndefined(decodeSubscriptionJson(text));
|
|
117
|
+
|
|
118
|
+
/** For a service: reads a `DELETE` body, or `undefined`. */
|
|
119
|
+
export const decodePushUnsubscribe = (
|
|
120
|
+
text: string,
|
|
121
|
+
): PushUnsubscribe | undefined =>
|
|
122
|
+
Option.getOrUndefined(decodeUnsubscribeJson(text));
|
package/src/view.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page half of `@timmo001/groundwork-pwa`: registers the service worker,
|
|
3
|
+
* holds the browser's install prompt for the app to offer, and hands shares
|
|
4
|
+
* from the share target to the app as a `gw-share` event, the same event the
|
|
5
|
+
* Android shell sends for a received share.
|
|
6
|
+
*
|
|
7
|
+
* import config from "../pwa.config.ts";
|
|
8
|
+
*
|
|
9
|
+
* const pwa = connectPwa(config, { onShare: (share) => openDraft(share) });
|
|
10
|
+
*
|
|
11
|
+
* if (pwa.canInstall()) showInstallButton(() => pwa.install());
|
|
12
|
+
*/
|
|
13
|
+
import { Option, Schema } from "effect";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
resolveConfig,
|
|
17
|
+
shareParams,
|
|
18
|
+
type PwaConfig,
|
|
19
|
+
type ResolvedPwaConfig,
|
|
20
|
+
type Share,
|
|
21
|
+
} from "./config.ts";
|
|
22
|
+
import {
|
|
23
|
+
pushRoutes,
|
|
24
|
+
VapidKey,
|
|
25
|
+
type PushClient,
|
|
26
|
+
type PushSubscription as ContractSubscription,
|
|
27
|
+
} from "./push.ts";
|
|
28
|
+
|
|
29
|
+
export * from "./config.ts";
|
|
30
|
+
|
|
31
|
+
/** Where the app subscribes to a service's pushes. */
|
|
32
|
+
export interface PushServiceOptions {
|
|
33
|
+
/** The service's base URL, which serves the push contract's routes. */
|
|
34
|
+
readonly service: string;
|
|
35
|
+
/** This install, the same client the pairing sent. */
|
|
36
|
+
readonly client: PushClient;
|
|
37
|
+
/** Sent with each request, such as the pairing's `Authorization`. */
|
|
38
|
+
readonly headers?: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where push stands: off in this browser, not asked, refused, or on. */
|
|
42
|
+
export type PushState = "unsupported" | "default" | "denied" | "subscribed";
|
|
43
|
+
|
|
44
|
+
const decodeVapidKey = Schema.decodeUnknownOption(VapidKey);
|
|
45
|
+
|
|
46
|
+
/** Whether this browser can push and whether the app is subscribed. */
|
|
47
|
+
export const pushState = async (
|
|
48
|
+
registration: ServiceWorkerRegistration | undefined,
|
|
49
|
+
): Promise<PushState> => {
|
|
50
|
+
if (registration === undefined || !("PushManager" in window))
|
|
51
|
+
return "unsupported";
|
|
52
|
+
|
|
53
|
+
if (Notification.permission === "denied") return "denied";
|
|
54
|
+
|
|
55
|
+
const existing = await registration.pushManager.getSubscription();
|
|
56
|
+
|
|
57
|
+
return existing === null ? "default" : "subscribed";
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Asks for notifications, subscribes with the service's VAPID key and posts
|
|
62
|
+
* the subscription. Only a tap or click may ask, so call it from one, as the
|
|
63
|
+
* install offer is. Returns the state it ends in.
|
|
64
|
+
*/
|
|
65
|
+
export const subscribePush = async (
|
|
66
|
+
registration: ServiceWorkerRegistration | undefined,
|
|
67
|
+
{ service, client, headers = {} }: PushServiceOptions,
|
|
68
|
+
): Promise<PushState> => {
|
|
69
|
+
if (registration === undefined || !("PushManager" in window))
|
|
70
|
+
return "unsupported";
|
|
71
|
+
|
|
72
|
+
const permission = await Notification.requestPermission();
|
|
73
|
+
|
|
74
|
+
// Dismissing the prompt leaves it at "default", to ask again later
|
|
75
|
+
if (permission !== "granted")
|
|
76
|
+
return permission === "denied" ? "denied" : "default";
|
|
77
|
+
|
|
78
|
+
const key = await fetch(new URL(pushRoutes.vapidKey, service), { headers })
|
|
79
|
+
.then((response) => response.json())
|
|
80
|
+
.then((body) => Option.getOrUndefined(decodeVapidKey(body)));
|
|
81
|
+
|
|
82
|
+
if (key === undefined) return "default";
|
|
83
|
+
|
|
84
|
+
// One made with another key, such as the service's last, can't be reused
|
|
85
|
+
await (await registration.pushManager.getSubscription())?.unsubscribe();
|
|
86
|
+
|
|
87
|
+
const subscription = await registration.pushManager.subscribe({
|
|
88
|
+
userVisibleOnly: true,
|
|
89
|
+
applicationServerKey: key.publicKey,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const { endpoint, keys } = subscription.toJSON();
|
|
93
|
+
const p256dh = keys?.["p256dh"];
|
|
94
|
+
const auth = keys?.["auth"];
|
|
95
|
+
|
|
96
|
+
if (endpoint === undefined || p256dh === undefined || auth === undefined)
|
|
97
|
+
return "default";
|
|
98
|
+
|
|
99
|
+
const body: ContractSubscription = {
|
|
100
|
+
endpoint,
|
|
101
|
+
keys: { p256dh, auth },
|
|
102
|
+
client,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const response = await fetch(new URL(pushRoutes.subscriptions, service), {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
108
|
+
body: JSON.stringify(body),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return response.ok ? "subscribed" : "default";
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Stops the app's pushes, here and at the service. */
|
|
115
|
+
export const unsubscribePush = async (
|
|
116
|
+
registration: ServiceWorkerRegistration | undefined,
|
|
117
|
+
{ service, headers = {} }: PushServiceOptions,
|
|
118
|
+
): Promise<void> => {
|
|
119
|
+
const subscription = await registration?.pushManager.getSubscription();
|
|
120
|
+
|
|
121
|
+
if (subscription === null || subscription === undefined) return;
|
|
122
|
+
|
|
123
|
+
await fetch(new URL(pushRoutes.subscriptions, service), {
|
|
124
|
+
method: "DELETE",
|
|
125
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
126
|
+
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
|
127
|
+
});
|
|
128
|
+
await subscription.unsubscribe();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Chromium's `beforeinstallprompt` event, which other engines don't send. */
|
|
132
|
+
interface InstallPromptEvent extends Event {
|
|
133
|
+
readonly prompt: () => Promise<void>;
|
|
134
|
+
readonly userChoice: Promise<{ readonly outcome: "accepted" | "dismissed" }>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const isInstallPrompt = (event: Event): event is InstallPromptEvent =>
|
|
138
|
+
"prompt" in event && "userChoice" in event;
|
|
139
|
+
|
|
140
|
+
/** What `connectPwa` sets up. */
|
|
141
|
+
export interface ConnectPwaOptions {
|
|
142
|
+
/** Called with each share, from the share target or the Android shell. */
|
|
143
|
+
readonly onShare?: (share: Share) => void;
|
|
144
|
+
/** Called when the app can offer to install itself, and again when it can't. */
|
|
145
|
+
readonly onInstallChange?: (canInstall: boolean) => void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The answer to an install offer. */
|
|
149
|
+
export type InstallOutcome = "accepted" | "dismissed" | "unavailable";
|
|
150
|
+
|
|
151
|
+
/** The page's PWA connection. */
|
|
152
|
+
export interface Pwa {
|
|
153
|
+
readonly config: ResolvedPwaConfig;
|
|
154
|
+
/** True when the app runs installed, in its own window. */
|
|
155
|
+
readonly installed: () => boolean;
|
|
156
|
+
/** True while the browser has an install prompt to show. */
|
|
157
|
+
readonly canInstall: () => boolean;
|
|
158
|
+
/** Shows the browser's install prompt, which only a tap or click may do. */
|
|
159
|
+
readonly install: () => Promise<InstallOutcome>;
|
|
160
|
+
/** The service worker's registration, once it's registered. */
|
|
161
|
+
readonly worker: Promise<ServiceWorkerRegistration | undefined>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Reads a share from the page's address and takes its parameters off it. */
|
|
165
|
+
const takeShare = (): Share | undefined => {
|
|
166
|
+
const url = new URL(location.href);
|
|
167
|
+
|
|
168
|
+
const share = Object.fromEntries(
|
|
169
|
+
Object.entries(shareParams).flatMap(([key, param]) => {
|
|
170
|
+
const value = url.searchParams.get(param);
|
|
171
|
+
|
|
172
|
+
url.searchParams.delete(param);
|
|
173
|
+
|
|
174
|
+
return value === null || value === "" ? [] : [[key, value]];
|
|
175
|
+
}),
|
|
176
|
+
) satisfies Share;
|
|
177
|
+
|
|
178
|
+
if (Object.keys(share).length === 0) return undefined;
|
|
179
|
+
|
|
180
|
+
// A reload shouldn't deliver the same share twice
|
|
181
|
+
history.replaceState(history.state, "", url);
|
|
182
|
+
|
|
183
|
+
return share;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Sets up what the config leaves on. */
|
|
187
|
+
export const connectPwa = (
|
|
188
|
+
config: PwaConfig,
|
|
189
|
+
options: ConnectPwaOptions = {},
|
|
190
|
+
): Pwa => {
|
|
191
|
+
const pwa = resolveConfig(config);
|
|
192
|
+
let prompt: InstallPromptEvent | undefined;
|
|
193
|
+
|
|
194
|
+
const installed = () =>
|
|
195
|
+
matchMedia("(display-mode: standalone)").matches ||
|
|
196
|
+
matchMedia("(display-mode: fullscreen)").matches;
|
|
197
|
+
|
|
198
|
+
const setPrompt = (event: InstallPromptEvent | undefined) => {
|
|
199
|
+
prompt = event;
|
|
200
|
+
options.onInstallChange?.(event !== undefined);
|
|
201
|
+
window.dispatchEvent(
|
|
202
|
+
new CustomEvent("gw-install-change", {
|
|
203
|
+
detail: { canInstall: event !== undefined },
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (pwa.install) {
|
|
209
|
+
window.addEventListener("beforeinstallprompt", (event) => {
|
|
210
|
+
if (!isInstallPrompt(event)) return;
|
|
211
|
+
// Kept for the app's own button, rather than the browser's banner
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
setPrompt(event);
|
|
214
|
+
});
|
|
215
|
+
window.addEventListener("appinstalled", () => setPrompt(undefined));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
window.addEventListener("gw-share", (event) => {
|
|
219
|
+
if (event instanceof CustomEvent) options.onShare?.(event.detail);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (pwa.shareTarget !== false) {
|
|
223
|
+
const share = takeShare();
|
|
224
|
+
|
|
225
|
+
// After the app's own listeners, which it adds once this returns
|
|
226
|
+
if (share !== undefined)
|
|
227
|
+
queueMicrotask(() =>
|
|
228
|
+
window.dispatchEvent(new CustomEvent("gw-share", { detail: share })),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const worker =
|
|
233
|
+
pwa.offline !== false && "serviceWorker" in navigator
|
|
234
|
+
? navigator.serviceWorker
|
|
235
|
+
.register(pwa.offline.worker, { scope: pwa.scope })
|
|
236
|
+
.catch((error: Error) => {
|
|
237
|
+
console.warn("The service worker didn't register", error);
|
|
238
|
+
|
|
239
|
+
return undefined;
|
|
240
|
+
})
|
|
241
|
+
: Promise.resolve(undefined);
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
config: pwa,
|
|
245
|
+
installed,
|
|
246
|
+
canInstall: () => prompt !== undefined,
|
|
247
|
+
install: async () => {
|
|
248
|
+
if (prompt === undefined) return "unavailable";
|
|
249
|
+
const event = prompt;
|
|
250
|
+
|
|
251
|
+
setPrompt(undefined);
|
|
252
|
+
await event.prompt();
|
|
253
|
+
|
|
254
|
+
return (await event.userChoice).outcome;
|
|
255
|
+
},
|
|
256
|
+
worker,
|
|
257
|
+
};
|
|
258
|
+
};
|