@tristan2828/ui-foundation 4.16.0 → 4.17.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 +1 -1
- package/conventions/AGENTS.md +7 -0
- package/conventions/docs/reminders.md +85 -0
- package/dist/api/gateway/push.d.ts +8 -0
- package/dist/api/gateway/push.js +25 -0
- package/dist/api/schema.d.ts +148 -0
- package/dist/components/app/allow-reminders.d.ts +9 -0
- package/dist/components/app/allow-reminders.js +11 -0
- package/dist/hooks/use-push-permission.d.ts +72 -0
- package/dist/hooks/use-push-permission.js +154 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mocks/index.d.ts +1 -0
- package/dist/mocks/index.js +1 -0
- package/dist/mocks/push.d.ts +9 -0
- package/dist/mocks/push.js +40 -0
- package/openapi/foundation.yaml +99 -3
- package/package.json +1 -1
- package/vite/index.d.ts +27 -0
- package/vite/index.js +2 -0
- package/vite/push-sw.js +46 -0
- package/vite/pwa.d.ts +18 -0
- package/vite/pwa.js +230 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ than installing this by hand:
|
|
|
18
18
|
|
|
19
19
|
| Import | What |
|
|
20
20
|
|---|---|
|
|
21
|
-
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec`. For timed screens (`docs/foundation/timed-screens.md`): `SessionView`, `useSessionClock`, `useWakeLock`, `useMuted`, `usePrefersReducedMotion`, and the audio cues `unlockAudio`, `chime`, `sequence`, `say`, `hush`, `vibrate` |
|
|
21
|
+
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec`. For timed screens (`docs/foundation/timed-screens.md`): `SessionView`, `useSessionClock`, `useWakeLock`, `useMuted`, `usePrefersReducedMotion`, and the audio cues `unlockAudio`, `chime`, `sequence`, `say`, `hush`, `vibrate`. For reminders (`docs/foundation/reminders.md`): `AllowReminders`, `usePushPermission` |
|
|
22
22
|
| `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, checkbox, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
|
|
23
23
|
| `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
|
|
24
24
|
| `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `environmentHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
|
package/conventions/AGENTS.md
CHANGED
|
@@ -96,6 +96,13 @@ routine) is built from the package's parts: `SessionView`,
|
|
|
96
96
|
`docs/foundation/timed-screens.md`, including its Playwright recipe
|
|
97
97
|
(`clock.install()` before `goto`, then `fastForward`, never `runFor`).
|
|
98
98
|
|
|
99
|
+
## Reminders
|
|
100
|
+
A notification at a time the user chose, with the app closed, is Web
|
|
101
|
+
Push: `pwa()` in `vite.config.ts`, `AllowReminders` with
|
|
102
|
+
`usePushPermission()`, and the backend's `send_push` and reminders job.
|
|
103
|
+
Follow `docs/foundation/reminders.md`. Never ask for notification
|
|
104
|
+
permission on page load: only from the click on "Allow reminders".
|
|
105
|
+
|
|
99
106
|
## Scope and Stopping
|
|
100
107
|
- Do the task you were given and nothing beyond it. A task is done when
|
|
101
108
|
`npm run verify` passes — not when it feels complete.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Reminders
|
|
2
|
+
|
|
3
|
+
A notification at a time the user chose ("time for your morning routine",
|
|
4
|
+
"a short break at 3 pm", a habit, a medication) that reaches them with the
|
|
5
|
+
app closed. It's Web Push: the browser subscribes with its push service
|
|
6
|
+
(Google's, Mozilla's, Apple's, Microsoft's), the backend encrypts a message
|
|
7
|
+
to that subscription and posts it there, and the push service delivers it
|
|
8
|
+
to the device, where the app's service worker shows it.
|
|
9
|
+
|
|
10
|
+
| Part | Where | What it does |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `pwa({ name })` | `vite.config.ts`, from `@tristan2828/ui-foundation/vite` | The app installable: a manifest, icons in the theme's colours, and `/push-sw.js`, the service worker that shows pushes and opens their path |
|
|
13
|
+
| `usePushPermission()` | the package | Whether this device gets reminders, and `enable`/`disable` |
|
|
14
|
+
| `AllowReminders` | the package | The control: says what reminders are, then asks, on a click |
|
|
15
|
+
| `/push/public-key`, `/push/subscriptions` | `openapi.yaml`, the foundation's part (optional) | The VAPID key to subscribe with; this device's subscription saved or dropped |
|
|
16
|
+
| `app/push.py` | the reference backend | `send_push(session, user_id, title, body, url)`: encrypted (RFC 8291), signed (VAPID), to every device the user allowed |
|
|
17
|
+
| `app/reminders.py`, `reminders` | the reference backend | A reminder row (when, which days, the text, the path), and the `send-reminders` job that sends each one when it's due |
|
|
18
|
+
|
|
19
|
+
Off until the backend turns it on (`PUSH_ENABLED`, `docs/deploy.md`
|
|
20
|
+
"Reminders (Web Push)"). An app without reminders leaves `/push/*` out of
|
|
21
|
+
`openapi.yaml` and `pwa()` out of its config.
|
|
22
|
+
|
|
23
|
+
## Allowing reminders
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { AllowReminders, usePushPermission } from '@tristan2828/ui-foundation'
|
|
27
|
+
|
|
28
|
+
const push = usePushPermission()
|
|
29
|
+
<AllowReminders push={push} description="We'll remind you when it's time for your routine." />
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **Never ask on page load.** A prompt nobody expected gets refused, and a
|
|
33
|
+
refusal is final: only the browser's site settings undo it. The button
|
|
34
|
+
says what reminders are first; `enable()` asks then, from the click.
|
|
35
|
+
- **Put it where reminders are set**: the top of the reminders list, or a
|
|
36
|
+
settings page. One per device: each phone or browser allows its own.
|
|
37
|
+
- **Every state says what to do next.** `AllowReminders` shows them all
|
|
38
|
+
(the package's Storybook, `app/AllowReminders`):
|
|
39
|
+
|
|
40
|
+
| `status` | Means | Shows |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `off` | Can be turned on | The description and "Allow reminders" |
|
|
43
|
+
| `on` | This device gets them | "Reminders are on for this device", and "Turn off" |
|
|
44
|
+
| `needs-install` | iPhone or iPad in a browser tab | How to add the app to the Home Screen |
|
|
45
|
+
| `blocked` | The person said no | Where to allow it again |
|
|
46
|
+
| `unsupported` | No push in this browser | That it can't |
|
|
47
|
+
| `mock` | The app runs on MSW | That reminders need the real backend |
|
|
48
|
+
|
|
49
|
+
## On iPhone and iPad
|
|
50
|
+
|
|
51
|
+
Push works only in the app added to the Home Screen (Share, then Add to
|
|
52
|
+
Home Screen) and opened from there, on iOS 16.4 or later. In a Safari tab
|
|
53
|
+
there is no push at all, so `usePushPermission` says `needs-install` and
|
|
54
|
+
`AllowReminders` shows the steps. `pwa()`'s manifest (`display:
|
|
55
|
+
standalone`) is what makes the Home Screen app an app.
|
|
56
|
+
|
|
57
|
+
## A reminder is the app's own row
|
|
58
|
+
|
|
59
|
+
The reference backend has a `reminders` table and the job that sends it;
|
|
60
|
+
what a reminder means is the app's. Give it an API and screens like any
|
|
61
|
+
entity (`add-an-entity.md`): its fields are `title`, `body`, `url` (a
|
|
62
|
+
path in the app, opened when it's tapped), `timeOfDay` ("07:30"), `days`
|
|
63
|
+
(some of mon,tue,wed,thu,fri,sat,sun), `timeZone` (the person's IANA
|
|
64
|
+
zone: `Intl.DateTimeFormat().resolvedOptions().timeZone`) and `enabled`.
|
|
65
|
+
Scope it to the signed-in user, as Widgets are.
|
|
66
|
+
|
|
67
|
+
- **The job** (`send-reminders`, every minute, with `JOBS_ENABLED`)
|
|
68
|
+
sends a reminder when today is one of its days in its time zone and the
|
|
69
|
+
time has come, once a day at most. More than 15 minutes late (the
|
|
70
|
+
backend was down) and it's skipped for the day rather than sent late.
|
|
71
|
+
- **Something else to send?** Call `send_push` from the app's own code or
|
|
72
|
+
job: a message when a long task finishes, a daily summary. The `url` is
|
|
73
|
+
always the app's own path.
|
|
74
|
+
- **Time zones** come from the system's time zone database. On Windows
|
|
75
|
+
that's the `tzdata` package, which the backend doesn't install: a
|
|
76
|
+
Windows dev machine reads only `UTC` until it's added.
|
|
77
|
+
|
|
78
|
+
## Testing
|
|
79
|
+
|
|
80
|
+
Under MSW the page is held by MSW's service worker, so `usePushPermission`
|
|
81
|
+
reads `mock` and never subscribes: screens show the `mock` state, and a
|
|
82
|
+
Playwright spec checks it the same way. The rest is the backend's: its
|
|
83
|
+
tests decrypt every message as a browser would. To try it end to end, run
|
|
84
|
+
the real backend with push on, allow reminders in the browser, then
|
|
85
|
+
`backend/scripts/send_test_push.py <email>`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { components } from "../schema.js";
|
|
2
|
+
export type PushSubscriptionRequest = components["schemas"]["PushSubscriptionRequest"];
|
|
3
|
+
/** The key to subscribe with. A 404 is a backend with push turned off. */
|
|
4
|
+
export declare function getPushPublicKey(): Promise<string>;
|
|
5
|
+
/** Saves the subscription; the same device again answers the same id. */
|
|
6
|
+
export declare function savePushSubscription(subscription: PushSubscriptionRequest): Promise<number>;
|
|
7
|
+
/** Already gone (404) counts as done. */
|
|
8
|
+
export declare function deletePushSubscription(id: number): Promise<void>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { safeFetch, toAppError } from "./errors.js";
|
|
2
|
+
/** The key to subscribe with. A 404 is a backend with push turned off. */
|
|
3
|
+
export async function getPushPublicKey() {
|
|
4
|
+
const res = await safeFetch("/push/public-key");
|
|
5
|
+
if (res.status !== 200)
|
|
6
|
+
throw toAppError(res.status, res.body, res.headers);
|
|
7
|
+
return res.body.publicKey;
|
|
8
|
+
}
|
|
9
|
+
/** Saves the subscription; the same device again answers the same id. */
|
|
10
|
+
export async function savePushSubscription(subscription) {
|
|
11
|
+
const res = await safeFetch("/push/subscriptions", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "content-type": "application/json" },
|
|
14
|
+
body: JSON.stringify(subscription),
|
|
15
|
+
});
|
|
16
|
+
if (res.status !== 201)
|
|
17
|
+
throw toAppError(res.status, res.body, res.headers);
|
|
18
|
+
return res.body.id;
|
|
19
|
+
}
|
|
20
|
+
/** Already gone (404) counts as done. */
|
|
21
|
+
export async function deletePushSubscription(id) {
|
|
22
|
+
const res = await safeFetch(`/push/subscriptions/${id}`, { method: "DELETE" });
|
|
23
|
+
if (res.status !== 204 && res.status !== 404)
|
|
24
|
+
throw toAppError(res.status, res.body, res.headers);
|
|
25
|
+
}
|
package/dist/api/schema.d.ts
CHANGED
|
@@ -131,6 +131,63 @@ export interface paths {
|
|
|
131
131
|
patch?: never;
|
|
132
132
|
trace?: never;
|
|
133
133
|
};
|
|
134
|
+
"/push/public-key": {
|
|
135
|
+
parameters: {
|
|
136
|
+
query?: never;
|
|
137
|
+
header?: never;
|
|
138
|
+
path?: never;
|
|
139
|
+
cookie?: never;
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* The app's VAPID public key, which a browser subscribes to push with
|
|
143
|
+
* @description For usePushPermission. 404 when the backend has push turned off.
|
|
144
|
+
*/
|
|
145
|
+
get: operations["getPushPublicKey"];
|
|
146
|
+
put?: never;
|
|
147
|
+
post?: never;
|
|
148
|
+
delete?: never;
|
|
149
|
+
options?: never;
|
|
150
|
+
head?: never;
|
|
151
|
+
patch?: never;
|
|
152
|
+
trace?: never;
|
|
153
|
+
};
|
|
154
|
+
"/push/subscriptions": {
|
|
155
|
+
parameters: {
|
|
156
|
+
query?: never;
|
|
157
|
+
header?: never;
|
|
158
|
+
path?: never;
|
|
159
|
+
cookie?: never;
|
|
160
|
+
};
|
|
161
|
+
get?: never;
|
|
162
|
+
put?: never;
|
|
163
|
+
/**
|
|
164
|
+
* Save this device's push subscription for the signed-in user
|
|
165
|
+
* @description The browser's PushSubscription.toJSON(). The same endpoint again replaces its keys and owner and answers the same id. The endpoint must be a push service's (422 otherwise): the backend POSTs to it.
|
|
166
|
+
*/
|
|
167
|
+
post: operations["savePushSubscription"];
|
|
168
|
+
delete?: never;
|
|
169
|
+
options?: never;
|
|
170
|
+
head?: never;
|
|
171
|
+
patch?: never;
|
|
172
|
+
trace?: never;
|
|
173
|
+
};
|
|
174
|
+
"/push/subscriptions/{id}": {
|
|
175
|
+
parameters: {
|
|
176
|
+
query?: never;
|
|
177
|
+
header?: never;
|
|
178
|
+
path?: never;
|
|
179
|
+
cookie?: never;
|
|
180
|
+
};
|
|
181
|
+
get?: never;
|
|
182
|
+
put?: never;
|
|
183
|
+
post?: never;
|
|
184
|
+
/** Stop sending to one of the signed-in user's devices */
|
|
185
|
+
delete: operations["deletePushSubscription"];
|
|
186
|
+
options?: never;
|
|
187
|
+
head?: never;
|
|
188
|
+
patch?: never;
|
|
189
|
+
trace?: never;
|
|
190
|
+
};
|
|
134
191
|
}
|
|
135
192
|
export type webhooks = Record<string, never>;
|
|
136
193
|
export interface components {
|
|
@@ -185,6 +242,23 @@ export interface components {
|
|
|
185
242
|
/** @description The frontend build (its commit). */
|
|
186
243
|
release?: string;
|
|
187
244
|
};
|
|
245
|
+
PushPublicKey: {
|
|
246
|
+
/** @description The P-256 public key, base64url (applicationServerKey). */
|
|
247
|
+
publicKey: string;
|
|
248
|
+
};
|
|
249
|
+
PushSubscriptionRequest: {
|
|
250
|
+
/** @description The push service's URL for this device. */
|
|
251
|
+
endpoint: string;
|
|
252
|
+
/** @description Milliseconds since the epoch, sent by some browsers, ignored. */
|
|
253
|
+
expirationTime?: number | null;
|
|
254
|
+
keys: {
|
|
255
|
+
p256dh: string;
|
|
256
|
+
auth: string;
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
PushSubscriptionCreated: {
|
|
260
|
+
id: number;
|
|
261
|
+
};
|
|
188
262
|
/** @description FastAPI's default 422 shape (RequestValidationError). */
|
|
189
263
|
ValidationErrorBody: {
|
|
190
264
|
detail: {
|
|
@@ -442,4 +516,78 @@ export interface operations {
|
|
|
442
516
|
500: components["responses"]["ServerError"];
|
|
443
517
|
};
|
|
444
518
|
};
|
|
519
|
+
getPushPublicKey: {
|
|
520
|
+
parameters: {
|
|
521
|
+
query?: never;
|
|
522
|
+
header?: never;
|
|
523
|
+
path?: never;
|
|
524
|
+
cookie?: never;
|
|
525
|
+
};
|
|
526
|
+
requestBody?: never;
|
|
527
|
+
responses: {
|
|
528
|
+
/** @description OK. */
|
|
529
|
+
200: {
|
|
530
|
+
headers: {
|
|
531
|
+
[name: string]: unknown;
|
|
532
|
+
};
|
|
533
|
+
content: {
|
|
534
|
+
"application/json": components["schemas"]["PushPublicKey"];
|
|
535
|
+
};
|
|
536
|
+
};
|
|
537
|
+
401: components["responses"]["UnauthorizedError"];
|
|
538
|
+
404: components["responses"]["NotFoundError"];
|
|
539
|
+
500: components["responses"]["ServerError"];
|
|
540
|
+
};
|
|
541
|
+
};
|
|
542
|
+
savePushSubscription: {
|
|
543
|
+
parameters: {
|
|
544
|
+
query?: never;
|
|
545
|
+
header?: never;
|
|
546
|
+
path?: never;
|
|
547
|
+
cookie?: never;
|
|
548
|
+
};
|
|
549
|
+
requestBody: {
|
|
550
|
+
content: {
|
|
551
|
+
"application/json": components["schemas"]["PushSubscriptionRequest"];
|
|
552
|
+
};
|
|
553
|
+
};
|
|
554
|
+
responses: {
|
|
555
|
+
/** @description Saved. */
|
|
556
|
+
201: {
|
|
557
|
+
headers: {
|
|
558
|
+
[name: string]: unknown;
|
|
559
|
+
};
|
|
560
|
+
content: {
|
|
561
|
+
"application/json": components["schemas"]["PushSubscriptionCreated"];
|
|
562
|
+
};
|
|
563
|
+
};
|
|
564
|
+
401: components["responses"]["UnauthorizedError"];
|
|
565
|
+
404: components["responses"]["NotFoundError"];
|
|
566
|
+
422: components["responses"]["ValidationError"];
|
|
567
|
+
500: components["responses"]["ServerError"];
|
|
568
|
+
};
|
|
569
|
+
};
|
|
570
|
+
deletePushSubscription: {
|
|
571
|
+
parameters: {
|
|
572
|
+
query?: never;
|
|
573
|
+
header?: never;
|
|
574
|
+
path: {
|
|
575
|
+
id: number;
|
|
576
|
+
};
|
|
577
|
+
cookie?: never;
|
|
578
|
+
};
|
|
579
|
+
requestBody?: never;
|
|
580
|
+
responses: {
|
|
581
|
+
/** @description Deleted. */
|
|
582
|
+
204: {
|
|
583
|
+
headers: {
|
|
584
|
+
[name: string]: unknown;
|
|
585
|
+
};
|
|
586
|
+
content?: never;
|
|
587
|
+
};
|
|
588
|
+
401: components["responses"]["UnauthorizedError"];
|
|
589
|
+
404: components["responses"]["NotFoundError"];
|
|
590
|
+
500: components["responses"]["ServerError"];
|
|
591
|
+
};
|
|
592
|
+
};
|
|
445
593
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { PushPermission } from '../../hooks/use-push-permission.js';
|
|
3
|
+
export type AllowRemindersProps = {
|
|
4
|
+
push: PushPermission;
|
|
5
|
+
/** What reminders are for, in the app's words, shown before asking. */
|
|
6
|
+
description?: ReactNode;
|
|
7
|
+
className?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function AllowReminders({ push, description, className }: AllowRemindersProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { BellIcon, BellOffIcon } from 'lucide-react';
|
|
3
|
+
import { cn } from 'cn';
|
|
4
|
+
import { Button } from '../ui/button.js';
|
|
5
|
+
import { Skeleton } from '../ui/skeleton.js';
|
|
6
|
+
import { Notice } from './notice.js';
|
|
7
|
+
const DEFAULT_DESCRIPTION = 'Get a notification on this device at the times you set, even when the app is closed. Your browser will ask first.';
|
|
8
|
+
export function AllowReminders({ push, description = DEFAULT_DESCRIPTION, className }) {
|
|
9
|
+
const { status, pending, error } = push;
|
|
10
|
+
return (_jsxs("div", { "data-slot": "allow-reminders", className: cn('flex flex-col gap-3', className), "aria-busy": status === undefined || pending, children: [status === undefined && _jsx(Skeleton, { className: "h-9 w-full max-w-md" }), status === 'off' && (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm text-foreground", children: description }), _jsx("div", { children: _jsxs(Button, { onClick: push.enable, disabled: pending, children: [_jsx(BellIcon, { "aria-hidden": "true" }), pending ? 'Turning on…' : 'Allow reminders'] }) })] })), status === 'on' && (_jsx(Notice, { tone: "success", icon: BellIcon, action: _jsxs(Button, { size: "sm", variant: "outline", onClick: push.disable, disabled: pending, children: [_jsx(BellOffIcon, { "aria-hidden": "true" }), pending ? 'Turning off…' : 'Turn off'] }), children: "Reminders are on for this device." })), status === 'needs-install' && (_jsx(Notice, { title: "Add the app to your Home Screen first", children: "On iPhone and iPad, reminders work in the app from the Home Screen. Tap Share, then Add to Home Screen, open the app from there and allow reminders." })), status === 'blocked' && (_jsx(Notice, { tone: "warning", title: "Notifications are blocked", icon: BellOffIcon, children: "This browser was told not to show this app\u2019s notifications. Allow them in the site\u2019s settings (the icon beside the address), then reload." })), status === 'unsupported' && (_jsx("p", { className: "text-sm text-muted-foreground", children: "This browser can\u2019t show reminders." })), status === 'mock' && (_jsx("p", { className: "text-sm text-muted-foreground", children: "Reminders need the real backend: with mock data nothing can send them." })), error && (_jsx("p", { role: "alert", className: "text-sm text-destructive-text", children: error }))] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type PushSubscriptionRequest } from '../api/gateway/push.js';
|
|
2
|
+
/**
|
|
3
|
+
* - `unsupported`: this browser has no push.
|
|
4
|
+
* - `needs-install`: iPhone or iPad in a browser tab; push works once the
|
|
5
|
+
* app is added to the Home Screen and opened from there.
|
|
6
|
+
* - `mock`: the page runs on MSW; reminders need the real backend.
|
|
7
|
+
* - `blocked`: the person said no; only the browser's site settings undo it.
|
|
8
|
+
* - `off`: can be turned on. `on`: this device gets reminders.
|
|
9
|
+
*/
|
|
10
|
+
export type PushStatus = 'unsupported' | 'needs-install' | 'mock' | 'blocked' | 'off' | 'on';
|
|
11
|
+
type PushRegistration = {
|
|
12
|
+
active: {
|
|
13
|
+
scriptURL: string;
|
|
14
|
+
} | null;
|
|
15
|
+
pushManager: Pick<PushManager, 'getSubscription' | 'subscribe'>;
|
|
16
|
+
};
|
|
17
|
+
/** What createPushController needs from the browser and the backend. */
|
|
18
|
+
export type PushEnvironment = {
|
|
19
|
+
notification: {
|
|
20
|
+
permission: NotificationPermission;
|
|
21
|
+
requestPermission: () => Promise<NotificationPermission>;
|
|
22
|
+
} | undefined;
|
|
23
|
+
serviceWorker: {
|
|
24
|
+
register: (url: string, options: RegistrationOptions) => Promise<unknown>;
|
|
25
|
+
getRegistration: () => Promise<PushRegistration | undefined>;
|
|
26
|
+
ready: () => Promise<PushRegistration>;
|
|
27
|
+
/** The script of the worker controlling this page, if any. */
|
|
28
|
+
controllerScript: () => string | undefined;
|
|
29
|
+
} | undefined;
|
|
30
|
+
/** `'PushManager' in window`. */
|
|
31
|
+
pushSupported: boolean;
|
|
32
|
+
/** Opened from the Home Screen (display-mode: standalone). */
|
|
33
|
+
standalone: boolean;
|
|
34
|
+
/** An iPhone or iPad, whatever the browser says it is. */
|
|
35
|
+
appleMobile: boolean;
|
|
36
|
+
api: {
|
|
37
|
+
getPublicKey: () => Promise<string>;
|
|
38
|
+
save: (subscription: PushSubscriptionRequest) => Promise<number>;
|
|
39
|
+
remove: (id: number) => Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export type PushController = {
|
|
43
|
+
status: () => Promise<PushStatus>;
|
|
44
|
+
/** Asks the browser, subscribes, and saves it. Call it straight from a click. */
|
|
45
|
+
enable: () => Promise<PushStatus>;
|
|
46
|
+
/** Stops sending to this device, and unsubscribes it. */
|
|
47
|
+
disable: () => Promise<PushStatus>;
|
|
48
|
+
};
|
|
49
|
+
export type PushControllerOptions = {
|
|
50
|
+
/** Where pwa() serves the worker. Default `/push-sw.js`. */
|
|
51
|
+
serviceWorkerUrl?: string;
|
|
52
|
+
};
|
|
53
|
+
export declare function createPushController(env: PushEnvironment, options?: PushControllerOptions): PushController;
|
|
54
|
+
export declare const PUSH_STATUS_QUERY_KEY: readonly ["ui-foundation", "push-status"];
|
|
55
|
+
/** What usePushPermission returns, and what AllowReminders shows. */
|
|
56
|
+
export type PushPermission = {
|
|
57
|
+
/** Undefined while it's being worked out. */
|
|
58
|
+
status: PushStatus | undefined;
|
|
59
|
+
enable: () => void;
|
|
60
|
+
disable: () => void;
|
|
61
|
+
/** Turning on or off is under way. */
|
|
62
|
+
pending: boolean;
|
|
63
|
+
/** Why the last enable or disable failed, in words to show. */
|
|
64
|
+
error: string | null;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Whether this device gets reminders, and `enable`/`disable` for one
|
|
68
|
+
* button (AllowReminders is that button, with the words around it).
|
|
69
|
+
* `enable` asks the browser for permission: only ever call it from a click.
|
|
70
|
+
*/
|
|
71
|
+
export declare function usePushPermission(options?: PushControllerOptions): PushPermission;
|
|
72
|
+
export {};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { deletePushSubscription, getPushPublicKey, savePushSubscription, } from '../api/gateway/push.js';
|
|
3
|
+
const isMockWorker = (script) => !!script && script.includes('mockServiceWorker');
|
|
4
|
+
function base64UrlToBytes(value) {
|
|
5
|
+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4);
|
|
6
|
+
const binary = atob(base64);
|
|
7
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
8
|
+
for (let i = 0; i < binary.length; i++)
|
|
9
|
+
bytes[i] = binary.charCodeAt(i);
|
|
10
|
+
return bytes;
|
|
11
|
+
}
|
|
12
|
+
function sameKey(current, wanted) {
|
|
13
|
+
if (!current)
|
|
14
|
+
return false;
|
|
15
|
+
const bytes = new Uint8Array(current);
|
|
16
|
+
return bytes.length === wanted.length && bytes.every((byte, i) => byte === wanted[i]);
|
|
17
|
+
}
|
|
18
|
+
function toRequest(subscription) {
|
|
19
|
+
const json = subscription.toJSON();
|
|
20
|
+
const p256dh = json.keys?.p256dh;
|
|
21
|
+
const auth = json.keys?.auth;
|
|
22
|
+
if (!json.endpoint || !p256dh || !auth)
|
|
23
|
+
throw new Error('The browser gave a subscription without its keys.');
|
|
24
|
+
return { endpoint: json.endpoint, expirationTime: json.expirationTime ?? null, keys: { p256dh, auth } };
|
|
25
|
+
}
|
|
26
|
+
export function createPushController(env, options = {}) {
|
|
27
|
+
const workerUrl = options.serviceWorkerUrl ?? '/push-sw.js';
|
|
28
|
+
// What can be told without waiting on anything, so enable() can ask
|
|
29
|
+
// the browser in the same task as the click.
|
|
30
|
+
const knownNow = () => {
|
|
31
|
+
if (env.appleMobile && !env.standalone)
|
|
32
|
+
return 'needs-install';
|
|
33
|
+
if (!env.notification || !env.serviceWorker || !env.pushSupported)
|
|
34
|
+
return 'unsupported';
|
|
35
|
+
if (isMockWorker(env.serviceWorker.controllerScript()))
|
|
36
|
+
return 'mock';
|
|
37
|
+
if (env.notification.permission === 'denied')
|
|
38
|
+
return 'blocked';
|
|
39
|
+
return null;
|
|
40
|
+
};
|
|
41
|
+
const ours = (registration) => registration?.active && !isMockWorker(registration.active.scriptURL) ? registration : undefined;
|
|
42
|
+
return {
|
|
43
|
+
async status() {
|
|
44
|
+
const known = knownNow();
|
|
45
|
+
if (known)
|
|
46
|
+
return known;
|
|
47
|
+
const registration = await env.serviceWorker.getRegistration();
|
|
48
|
+
if (isMockWorker(registration?.active?.scriptURL))
|
|
49
|
+
return 'mock';
|
|
50
|
+
if (env.notification.permission !== 'granted')
|
|
51
|
+
return 'off';
|
|
52
|
+
const subscription = await ours(registration)?.pushManager.getSubscription();
|
|
53
|
+
return subscription ? 'on' : 'off';
|
|
54
|
+
},
|
|
55
|
+
async enable() {
|
|
56
|
+
const known = knownNow();
|
|
57
|
+
if (known)
|
|
58
|
+
return known;
|
|
59
|
+
const permission = await env.notification.requestPermission();
|
|
60
|
+
if (permission !== 'granted')
|
|
61
|
+
return permission === 'denied' ? 'blocked' : 'off';
|
|
62
|
+
await env.serviceWorker.register(workerUrl, { scope: '/' });
|
|
63
|
+
const registration = await env.serviceWorker.ready();
|
|
64
|
+
const key = base64UrlToBytes(await env.api.getPublicKey());
|
|
65
|
+
let subscription = await registration.pushManager.getSubscription();
|
|
66
|
+
// Made with another key (the backend's was replaced): it can't be
|
|
67
|
+
// sent to any more, so start again.
|
|
68
|
+
if (subscription && !sameKey(subscription.options.applicationServerKey, key)) {
|
|
69
|
+
await subscription.unsubscribe();
|
|
70
|
+
subscription = null;
|
|
71
|
+
}
|
|
72
|
+
subscription ??= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
|
|
73
|
+
await env.api.save(toRequest(subscription));
|
|
74
|
+
return 'on';
|
|
75
|
+
},
|
|
76
|
+
async disable() {
|
|
77
|
+
const subscription = await ours(await env.serviceWorker?.getRegistration())?.pushManager.getSubscription();
|
|
78
|
+
if (subscription) {
|
|
79
|
+
// Saving again is how the device learns its id (the same endpoint
|
|
80
|
+
// answers the same one), so nothing is kept on the device.
|
|
81
|
+
const id = await env.api.save(toRequest(subscription));
|
|
82
|
+
await env.api.remove(id);
|
|
83
|
+
await subscription.unsubscribe();
|
|
84
|
+
}
|
|
85
|
+
return 'off';
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function browserEnvironment() {
|
|
90
|
+
const hasWindow = typeof window !== 'undefined';
|
|
91
|
+
const container = hasWindow && 'serviceWorker' in navigator ? navigator.serviceWorker : undefined;
|
|
92
|
+
const ua = hasWindow ? navigator.userAgent : '';
|
|
93
|
+
return {
|
|
94
|
+
notification: hasWindow && 'Notification' in window ? Notification : undefined,
|
|
95
|
+
serviceWorker: container && {
|
|
96
|
+
register: (url, options) => container.register(url, options),
|
|
97
|
+
getRegistration: () => container.getRegistration(),
|
|
98
|
+
ready: () => container.ready,
|
|
99
|
+
controllerScript: () => container.controller?.scriptURL,
|
|
100
|
+
},
|
|
101
|
+
pushSupported: hasWindow && 'PushManager' in window,
|
|
102
|
+
standalone: hasWindow &&
|
|
103
|
+
(window.matchMedia?.('(display-mode: standalone)').matches ||
|
|
104
|
+
navigator.standalone === true),
|
|
105
|
+
// iPadOS says it's a Mac; a Mac has no touch screen.
|
|
106
|
+
appleMobile: /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && hasWindow && navigator.maxTouchPoints > 1),
|
|
107
|
+
api: { getPublicKey: getPushPublicKey, save: savePushSubscription, remove: deletePushSubscription },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function messageOf(error) {
|
|
111
|
+
const appError = error;
|
|
112
|
+
if (appError && typeof appError.kind === 'string') {
|
|
113
|
+
if (appError.kind === 'notfound')
|
|
114
|
+
return "This app's backend doesn't send reminders yet.";
|
|
115
|
+
if (appError.kind === 'network')
|
|
116
|
+
return "Couldn't reach the app. Check the connection and try again.";
|
|
117
|
+
return appError.message ?? 'Something went wrong. Try again.';
|
|
118
|
+
}
|
|
119
|
+
return "The browser couldn't turn on reminders. Try again, or check its notification settings.";
|
|
120
|
+
}
|
|
121
|
+
export const PUSH_STATUS_QUERY_KEY = ['ui-foundation', 'push-status'];
|
|
122
|
+
/**
|
|
123
|
+
* Whether this device gets reminders, and `enable`/`disable` for one
|
|
124
|
+
* button (AllowReminders is that button, with the words around it).
|
|
125
|
+
* `enable` asks the browser for permission: only ever call it from a click.
|
|
126
|
+
*/
|
|
127
|
+
export function usePushPermission(options = {}) {
|
|
128
|
+
const queryClient = useQueryClient();
|
|
129
|
+
const controller = createPushController(browserEnvironment(), options);
|
|
130
|
+
const { data: status } = useQuery({
|
|
131
|
+
queryKey: PUSH_STATUS_QUERY_KEY,
|
|
132
|
+
queryFn: () => controller.status(),
|
|
133
|
+
staleTime: Infinity,
|
|
134
|
+
});
|
|
135
|
+
const settle = (next) => queryClient.setQueryData(PUSH_STATUS_QUERY_KEY, next);
|
|
136
|
+
// Each takes the run already started: enable() must ask the browser in
|
|
137
|
+
// the click itself, and mutate() may call its function a tick later.
|
|
138
|
+
const enable = useMutation({ mutationFn: (run) => run, onSuccess: settle });
|
|
139
|
+
const disable = useMutation({ mutationFn: (run) => run, onSuccess: settle });
|
|
140
|
+
const failed = enable.error ?? disable.error;
|
|
141
|
+
return {
|
|
142
|
+
status,
|
|
143
|
+
enable: () => {
|
|
144
|
+
disable.reset();
|
|
145
|
+
enable.mutate(controller.enable());
|
|
146
|
+
},
|
|
147
|
+
disable: () => {
|
|
148
|
+
enable.reset();
|
|
149
|
+
disable.mutate(controller.disable());
|
|
150
|
+
},
|
|
151
|
+
pending: enable.isPending || disable.isPending,
|
|
152
|
+
error: failed ? messageOf(failed) : null,
|
|
153
|
+
};
|
|
154
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
|
22
22
|
export { PillFilter, type PillFilterOption, type PillFilterProps } from './components/app/pill-filter.js';
|
|
23
23
|
export { SecondaryFilters, type ActiveFilter, type SecondaryFiltersProps } from './components/app/secondary-filters.js';
|
|
24
24
|
export { SessionView, type SessionViewProps } from './components/app/session-view.js';
|
|
25
|
+
export { AllowReminders, type AllowRemindersProps } from './components/app/allow-reminders.js';
|
|
25
26
|
export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
|
|
26
27
|
export { Notice, type NoticeProps, type NoticeTone } from './components/app/notice.js';
|
|
27
28
|
export { SourceCredit, type Source, type SourceCreditProps } from './components/app/source-credit.js';
|
|
@@ -50,5 +51,6 @@ export { useWakeLock } from './hooks/use-wake-lock.js';
|
|
|
50
51
|
export { useSessionClock, type SessionClock, type SessionClockOptions, type SessionClockState, type SessionStatus, } from './hooks/use-session-clock.js';
|
|
51
52
|
export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
52
53
|
export { useMuted } from './hooks/use-muted.js';
|
|
54
|
+
export { createPushController, usePushPermission, type PushController, type PushControllerOptions, type PushEnvironment, type PushPermission, type PushStatus, } from './hooks/use-push-permission.js';
|
|
53
55
|
export { cn } from './lib/utils.js';
|
|
54
56
|
export { chime, hush, say, sequence, unlockAudio, vibrate, type ChimeOptions, type SayOptions, type SequenceOptions, } from './lib/audio-cues.js';
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,7 @@ export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
|
27
27
|
export { PillFilter } from './components/app/pill-filter.js';
|
|
28
28
|
export { SecondaryFilters } from './components/app/secondary-filters.js';
|
|
29
29
|
export { SessionView } from './components/app/session-view.js';
|
|
30
|
+
export { AllowReminders } from './components/app/allow-reminders.js';
|
|
30
31
|
export { StageCircle, } from './components/app/stage-circle.js';
|
|
31
32
|
export { Notice } from './components/app/notice.js';
|
|
32
33
|
export { SourceCredit } from './components/app/source-credit.js';
|
|
@@ -55,6 +56,7 @@ export { useWakeLock } from './hooks/use-wake-lock.js';
|
|
|
55
56
|
export { useSessionClock, } from './hooks/use-session-clock.js';
|
|
56
57
|
export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
57
58
|
export { useMuted } from './hooks/use-muted.js';
|
|
59
|
+
export { createPushController, usePushPermission, } from './hooks/use-push-permission.js';
|
|
58
60
|
// Utilities
|
|
59
61
|
export { cn } from './lib/utils.js';
|
|
60
62
|
export { chime, hush, say, sequence, unlockAudio, vibrate, } from './lib/audio-cues.js';
|
package/dist/mocks/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_RECOVERY_CODE, MOCK_TOTP_CODE, MOCK_TOTP_USER, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
2
2
|
export { clientErrorHandlers, getMockClientErrors, resetMockClientErrors } from './client-errors.js';
|
|
3
3
|
export { environmentHandlers } from './environment.js';
|
|
4
|
+
export { getMockPushSubscriptions, MOCK_PUSH_PUBLIC_KEY, pushHandlers, resetMockPush } from './push.js';
|
|
4
5
|
export { exposeMswForE2E } from './e2e-hooks.js';
|
|
5
6
|
export type { MswHandle, MswOverride } from './override.js';
|
package/dist/mocks/index.js
CHANGED
|
@@ -4,4 +4,5 @@
|
|
|
4
4
|
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_RECOVERY_CODE, MOCK_TOTP_CODE, MOCK_TOTP_USER, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
5
5
|
export { clientErrorHandlers, getMockClientErrors, resetMockClientErrors } from './client-errors.js';
|
|
6
6
|
export { environmentHandlers } from './environment.js';
|
|
7
|
+
export { getMockPushSubscriptions, MOCK_PUSH_PUBLIC_KEY, pushHandlers, resetMockPush } from './push.js';
|
|
7
8
|
export { exposeMswForE2E } from './e2e-hooks.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { components } from '../api/schema.js';
|
|
2
|
+
type PushSubscriptionRequest = components['schemas']['PushSubscriptionRequest'];
|
|
3
|
+
/** RFC 8291's example key: a valid P-256 point, and nobody's real one. */
|
|
4
|
+
export declare const MOCK_PUSH_PUBLIC_KEY = "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8";
|
|
5
|
+
/** What POST /push/subscriptions has saved since the last reset. */
|
|
6
|
+
export declare function getMockPushSubscriptions(): readonly PushSubscriptionRequest[];
|
|
7
|
+
export declare function resetMockPush(): void;
|
|
8
|
+
export declare const pushHandlers: import("msw").HttpHandler[];
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// MSW handlers for /push/* (openapi/foundation.yaml), for an app whose
|
|
2
|
+
// contract has them. Like the reference backend they need the mock
|
|
3
|
+
// session, and a device subscribing again gets its id back. Nothing is
|
|
4
|
+
// ever sent: under MSW, usePushPermission reads 'mock' and never
|
|
5
|
+
// subscribes, since MSW's own worker holds the page.
|
|
6
|
+
import { http, HttpResponse } from 'msw';
|
|
7
|
+
import { isMockAuthenticated } from './auth.js';
|
|
8
|
+
/** RFC 8291's example key: a valid P-256 point, and nobody's real one. */
|
|
9
|
+
export const MOCK_PUSH_PUBLIC_KEY = 'BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8';
|
|
10
|
+
let subscriptions = new Map();
|
|
11
|
+
let nextId = 1;
|
|
12
|
+
/** What POST /push/subscriptions has saved since the last reset. */
|
|
13
|
+
export function getMockPushSubscriptions() {
|
|
14
|
+
return [...subscriptions.values()].map(({ request }) => request);
|
|
15
|
+
}
|
|
16
|
+
export function resetMockPush() {
|
|
17
|
+
subscriptions = new Map();
|
|
18
|
+
nextId = 1;
|
|
19
|
+
}
|
|
20
|
+
const unauthorized = () => HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 });
|
|
21
|
+
export const pushHandlers = [
|
|
22
|
+
http.get('*/api/push/public-key', () => isMockAuthenticated() ? HttpResponse.json({ publicKey: MOCK_PUSH_PUBLIC_KEY }) : unauthorized()),
|
|
23
|
+
http.post('*/api/push/subscriptions', async ({ request }) => {
|
|
24
|
+
if (!isMockAuthenticated())
|
|
25
|
+
return unauthorized();
|
|
26
|
+
const body = (await request.json());
|
|
27
|
+
const id = subscriptions.get(body.endpoint)?.id ?? nextId++;
|
|
28
|
+
subscriptions.set(body.endpoint, { id, request: body });
|
|
29
|
+
return HttpResponse.json({ id }, { status: 201 });
|
|
30
|
+
}),
|
|
31
|
+
http.delete('*/api/push/subscriptions/:id', ({ params }) => {
|
|
32
|
+
if (!isMockAuthenticated())
|
|
33
|
+
return unauthorized();
|
|
34
|
+
const entry = [...subscriptions.entries()].find(([, value]) => String(value.id) === params.id);
|
|
35
|
+
if (!entry)
|
|
36
|
+
return HttpResponse.json({ detail: 'No such subscription.' }, { status: 404 });
|
|
37
|
+
subscriptions.delete(entry[0]);
|
|
38
|
+
return new HttpResponse(null, { status: 204 });
|
|
39
|
+
}),
|
|
40
|
+
];
|
package/openapi/foundation.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
openapi: 3.1.0
|
|
2
2
|
info:
|
|
3
3
|
title: UI Foundation — the contract every app shares
|
|
4
|
-
version: "4.
|
|
4
|
+
version: "4.17.0"
|
|
5
5
|
description: >
|
|
6
6
|
The part of an app's API contract that the foundation package's own code
|
|
7
7
|
calls: session-cookie auth under /auth/*, and the two error envelopes the
|
|
@@ -13,8 +13,8 @@ info:
|
|
|
13
13
|
|
|
14
14
|
A path marked `x-optional: true` may be left out of an app's spec (an
|
|
15
15
|
app without self-service sign-up omits /auth/register, an app that
|
|
16
|
-
doesn't render DataEnvironmentBanner omits /environment
|
|
17
|
-
present it must match.
|
|
16
|
+
doesn't render DataEnvironmentBanner omits /environment, an app without
|
|
17
|
+
reminders omits /push/*), but if present it must match.
|
|
18
18
|
|
|
19
19
|
servers:
|
|
20
20
|
- url: /api
|
|
@@ -161,6 +161,68 @@ paths:
|
|
|
161
161
|
"422": { $ref: "#/components/responses/ValidationError" }
|
|
162
162
|
"500": { $ref: "#/components/responses/ServerError" }
|
|
163
163
|
|
|
164
|
+
/push/public-key:
|
|
165
|
+
x-optional: true
|
|
166
|
+
get:
|
|
167
|
+
operationId: getPushPublicKey
|
|
168
|
+
summary: The app's VAPID public key, which a browser subscribes to push with
|
|
169
|
+
description: >
|
|
170
|
+
For usePushPermission. 404 when the backend has push turned off.
|
|
171
|
+
tags: [push]
|
|
172
|
+
responses:
|
|
173
|
+
"200":
|
|
174
|
+
description: OK.
|
|
175
|
+
content:
|
|
176
|
+
application/json:
|
|
177
|
+
schema: { $ref: "#/components/schemas/PushPublicKey" }
|
|
178
|
+
"401": { $ref: "#/components/responses/UnauthorizedError" }
|
|
179
|
+
"404": { $ref: "#/components/responses/NotFoundError" }
|
|
180
|
+
"500": { $ref: "#/components/responses/ServerError" }
|
|
181
|
+
|
|
182
|
+
/push/subscriptions:
|
|
183
|
+
x-optional: true
|
|
184
|
+
post:
|
|
185
|
+
operationId: savePushSubscription
|
|
186
|
+
summary: Save this device's push subscription for the signed-in user
|
|
187
|
+
description: >
|
|
188
|
+
The browser's PushSubscription.toJSON(). The same endpoint again
|
|
189
|
+
replaces its keys and owner and answers the same id. The endpoint
|
|
190
|
+
must be a push service's (422 otherwise): the backend POSTs to it.
|
|
191
|
+
tags: [push]
|
|
192
|
+
requestBody:
|
|
193
|
+
required: true
|
|
194
|
+
content:
|
|
195
|
+
application/json:
|
|
196
|
+
schema: { $ref: "#/components/schemas/PushSubscriptionRequest" }
|
|
197
|
+
responses:
|
|
198
|
+
"201":
|
|
199
|
+
description: Saved.
|
|
200
|
+
content:
|
|
201
|
+
application/json:
|
|
202
|
+
schema: { $ref: "#/components/schemas/PushSubscriptionCreated" }
|
|
203
|
+
"401": { $ref: "#/components/responses/UnauthorizedError" }
|
|
204
|
+
"404": { $ref: "#/components/responses/NotFoundError" }
|
|
205
|
+
"422": { $ref: "#/components/responses/ValidationError" }
|
|
206
|
+
"500": { $ref: "#/components/responses/ServerError" }
|
|
207
|
+
|
|
208
|
+
/push/subscriptions/{id}:
|
|
209
|
+
x-optional: true
|
|
210
|
+
delete:
|
|
211
|
+
operationId: deletePushSubscription
|
|
212
|
+
summary: Stop sending to one of the signed-in user's devices
|
|
213
|
+
tags: [push]
|
|
214
|
+
parameters:
|
|
215
|
+
- name: id
|
|
216
|
+
in: path
|
|
217
|
+
required: true
|
|
218
|
+
schema: { type: integer }
|
|
219
|
+
responses:
|
|
220
|
+
"204":
|
|
221
|
+
description: Deleted.
|
|
222
|
+
"401": { $ref: "#/components/responses/UnauthorizedError" }
|
|
223
|
+
"404": { $ref: "#/components/responses/NotFoundError" }
|
|
224
|
+
"500": { $ref: "#/components/responses/ServerError" }
|
|
225
|
+
|
|
164
226
|
components:
|
|
165
227
|
schemas:
|
|
166
228
|
DataEnvironment:
|
|
@@ -235,6 +297,40 @@ components:
|
|
|
235
297
|
status: { type: integer, minimum: 100, maximum: 599, description: The HTTP status of a failed request. }
|
|
236
298
|
release: { type: string, maxLength: 100, description: The frontend build (its commit). }
|
|
237
299
|
|
|
300
|
+
PushPublicKey:
|
|
301
|
+
type: object
|
|
302
|
+
required: [publicKey]
|
|
303
|
+
properties:
|
|
304
|
+
publicKey:
|
|
305
|
+
type: string
|
|
306
|
+
minLength: 1
|
|
307
|
+
description: The P-256 public key, base64url (applicationServerKey).
|
|
308
|
+
|
|
309
|
+
PushSubscriptionRequest:
|
|
310
|
+
type: object
|
|
311
|
+
required: [endpoint, keys]
|
|
312
|
+
properties:
|
|
313
|
+
endpoint:
|
|
314
|
+
type: string
|
|
315
|
+
minLength: 1
|
|
316
|
+
maxLength: 2048
|
|
317
|
+
description: The push service's URL for this device.
|
|
318
|
+
expirationTime:
|
|
319
|
+
type: ["number", "null"]
|
|
320
|
+
description: Milliseconds since the epoch, sent by some browsers, ignored.
|
|
321
|
+
keys:
|
|
322
|
+
type: object
|
|
323
|
+
required: [p256dh, auth]
|
|
324
|
+
properties:
|
|
325
|
+
p256dh: { type: string, minLength: 1, maxLength: 100 }
|
|
326
|
+
auth: { type: string, minLength: 1, maxLength: 32 }
|
|
327
|
+
|
|
328
|
+
PushSubscriptionCreated:
|
|
329
|
+
type: object
|
|
330
|
+
required: [id]
|
|
331
|
+
properties:
|
|
332
|
+
id: { type: integer }
|
|
333
|
+
|
|
238
334
|
ValidationErrorBody:
|
|
239
335
|
type: object
|
|
240
336
|
description: FastAPI's default 422 shape (RequestValidationError).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.17.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/vite/index.d.ts
CHANGED
|
@@ -24,3 +24,30 @@ export type BuildInfoOptions = {
|
|
|
24
24
|
* `BUILD_REPO_URL`.
|
|
25
25
|
*/
|
|
26
26
|
export default function buildInfo(options?: BuildInfoOptions): Plugin
|
|
27
|
+
|
|
28
|
+
export type PwaOptions = {
|
|
29
|
+
/** The app's name, as the Home Screen and the install prompt show it. */
|
|
30
|
+
name: string
|
|
31
|
+
/** A shorter one for under the icon. Defaults to `name`; its first letter is the SVG icon's. */
|
|
32
|
+
shortName?: string
|
|
33
|
+
description?: string
|
|
34
|
+
/** `#rrggbb`. Defaults to the package's `--primary` token. */
|
|
35
|
+
themeColor?: string
|
|
36
|
+
/** The letter's colour on the SVG icon, `#rrggbb`. Defaults to `--primary-foreground`. */
|
|
37
|
+
themeForegroundColor?: string
|
|
38
|
+
/** The splash screen's, `#rrggbb`. Defaults to `--background`. */
|
|
39
|
+
backgroundColor?: string
|
|
40
|
+
/**
|
|
41
|
+
* The app's own icon, a square PNG of at least 512×512 (path from the Vite
|
|
42
|
+
* root). Replaces the generated ones everywhere.
|
|
43
|
+
*/
|
|
44
|
+
icon?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Makes the app installable and receives reminders: serves and builds
|
|
49
|
+
* `/manifest.webmanifest`, `/icons/*` and `/push-sw.js` (the service worker
|
|
50
|
+
* usePushPermission registers), and adds the manifest, theme-color and
|
|
51
|
+
* apple-touch-icon tags to index.html.
|
|
52
|
+
*/
|
|
53
|
+
export function pwa(options: PwaOptions): Plugin
|
package/vite/index.js
CHANGED
package/vite/push-sw.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The app's service worker, written by pwa() (./index.js) as /push-sw.js.
|
|
2
|
+
// Push and notification clicks only: no fetch handler, so it never caches
|
|
3
|
+
// the app or stands between it and Vite's hashed files. Registered by
|
|
4
|
+
// usePushPermission's enable(), not on load. ICON is set by pwa().
|
|
5
|
+
/* global self, clients */
|
|
6
|
+
const ICON = '__ICON__'
|
|
7
|
+
|
|
8
|
+
self.addEventListener('install', () => self.skipWaiting())
|
|
9
|
+
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))
|
|
10
|
+
|
|
11
|
+
// app/push.py sends {title, body, url, tag}. Anything else still shows.
|
|
12
|
+
self.addEventListener('push', (event) => {
|
|
13
|
+
let data = {}
|
|
14
|
+
try {
|
|
15
|
+
data = event.data ? event.data.json() : {}
|
|
16
|
+
} catch {
|
|
17
|
+
data = { body: event.data ? event.data.text() : '' }
|
|
18
|
+
}
|
|
19
|
+
const options = { body: data.body || '', icon: ICON, data: { url: data.url || '/' } }
|
|
20
|
+
if (data.tag) options.tag = data.tag
|
|
21
|
+
event.waitUntil(self.registration.showNotification(data.title || 'Reminder', options))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Opens the message's path in the app: in a window already open on the
|
|
25
|
+
// app if there is one, else a new one. Never another site.
|
|
26
|
+
self.addEventListener('notificationclick', (event) => {
|
|
27
|
+
event.notification.close()
|
|
28
|
+
const target = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin)
|
|
29
|
+
if (target.origin !== self.location.origin) return
|
|
30
|
+
event.waitUntil(
|
|
31
|
+
(async () => {
|
|
32
|
+
const windows = await clients.matchAll({ type: 'window', includeUncontrolled: true })
|
|
33
|
+
const open = windows.find((client) => new URL(client.url).origin === target.origin)
|
|
34
|
+
if (open) {
|
|
35
|
+
await open.focus()
|
|
36
|
+
try {
|
|
37
|
+
await open.navigate(target.href)
|
|
38
|
+
return
|
|
39
|
+
} catch {
|
|
40
|
+
// An uncontrolled window can't be navigated from here.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
await clients.openWindow(target.href)
|
|
44
|
+
})(),
|
|
45
|
+
)
|
|
46
|
+
})
|
package/vite/pwa.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The helpers behind pwa(), typed for the package's own tests. Apps use
|
|
2
|
+
// `pwa` from `@tristan2828/ui-foundation/vite` (./index.d.ts).
|
|
3
|
+
import type { HtmlTagDescriptor, Plugin } from 'vite'
|
|
4
|
+
import type { PwaOptions } from './index'
|
|
5
|
+
|
|
6
|
+
export function themeToken(css: string, name: string): string
|
|
7
|
+
export function oklchToHex(value: string): string
|
|
8
|
+
export function solidPng(size: number, hex: string): Buffer
|
|
9
|
+
export function letterSvg(name: string, background: string, foreground: string): string
|
|
10
|
+
export function pwaFiles(
|
|
11
|
+
options: PwaOptions,
|
|
12
|
+
base?: string,
|
|
13
|
+
root?: string,
|
|
14
|
+
): {
|
|
15
|
+
files: Map<string, { type: string; source: string | Buffer }>
|
|
16
|
+
tags: (HtmlTagDescriptor & { attrs: Record<string, string> })[]
|
|
17
|
+
}
|
|
18
|
+
export function pwa(options: PwaOptions): Plugin
|
package/vite/pwa.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// `pwa()` from `@tristan2828/ui-foundation/vite`: the app installable (a web
|
|
2
|
+
// manifest and icons) and the service worker reminders arrive through
|
|
3
|
+
// (issue #167). An app's vite.config.ts:
|
|
4
|
+
//
|
|
5
|
+
// import buildInfo, { pwa } from '@tristan2828/ui-foundation/vite'
|
|
6
|
+
// plugins: [react(), tailwindcss(), buildInfo(), pwa({ name: 'Morning Routines', shortName: 'Routines' })]
|
|
7
|
+
//
|
|
8
|
+
// Serves and builds, at the site root:
|
|
9
|
+
// - /manifest.webmanifest: the name, `display: standalone` (which iOS needs
|
|
10
|
+
// before push works there), and the theme's colours.
|
|
11
|
+
// - /icons/: an SVG with the name's first letter (Chrome, Android) and
|
|
12
|
+
// plain PNGs in the theme colour (iOS's Home Screen icon must be a PNG,
|
|
13
|
+
// and drawing a letter into one needs a font rasteriser the package
|
|
14
|
+
// doesn't carry). `icon` replaces them all with the app's own PNG.
|
|
15
|
+
// - /push-sw.js: ./push-sw.js, showing pushes and opening their path.
|
|
16
|
+
// And adds the manifest, theme-color and apple-touch-icon tags to
|
|
17
|
+
// index.html. Registering the worker is usePushPermission's, on a click.
|
|
18
|
+
//
|
|
19
|
+
// Colours default to the package's tokens (styles/theme.css: --primary,
|
|
20
|
+
// --primary-foreground, --background), converted to hex as a manifest
|
|
21
|
+
// needs. An app that changed its tokens passes its own.
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import zlib from 'node:zlib'
|
|
27
|
+
|
|
28
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
const THEME_CSS = path.join(HERE, '..', 'styles', 'theme.css')
|
|
30
|
+
const WORKER_SOURCE = path.join(HERE, 'push-sw.js')
|
|
31
|
+
const HEX = /^#[0-9a-f]{6}$/i
|
|
32
|
+
|
|
33
|
+
// ------------------------------------------------------------- colours
|
|
34
|
+
|
|
35
|
+
/** The value of `--name` in theme.css's :root block, var() followed. */
|
|
36
|
+
export function themeToken(css, name, seen = new Set()) {
|
|
37
|
+
const root = css.slice(css.indexOf(':root'), css.indexOf('.dark'))
|
|
38
|
+
const match = new RegExp(`--${name}:\\s*([^;]+);`).exec(root)
|
|
39
|
+
if (!match) throw new Error(`pwa: no --${name} in the theme.`)
|
|
40
|
+
const value = match[1].trim()
|
|
41
|
+
const reference = /^var\(--([\w-]+)\)$/.exec(value)
|
|
42
|
+
if (!reference) return value
|
|
43
|
+
if (seen.has(name)) throw new Error(`pwa: --${name} refers to itself.`)
|
|
44
|
+
seen.add(name)
|
|
45
|
+
return themeToken(css, reference[1], seen)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `oklch(L C H)` → `#rrggbb` (sRGB, clipped). */
|
|
49
|
+
export function oklchToHex(value) {
|
|
50
|
+
const match = /^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:deg)?\s*\)$/.exec(value.trim())
|
|
51
|
+
if (!match) throw new Error(`pwa: can't read ${value} as oklch(L C H); pass the colour as #rrggbb.`)
|
|
52
|
+
const l = Number(match[1]) / (match[2] ? 100 : 1)
|
|
53
|
+
const c = Number(match[3])
|
|
54
|
+
const h = (Number(match[4]) * Math.PI) / 180
|
|
55
|
+
const a = c * Math.cos(h)
|
|
56
|
+
const b = c * Math.sin(h)
|
|
57
|
+
// OKLab → LMS (cubed) → linear sRGB (Björn Ottosson's matrices).
|
|
58
|
+
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3
|
|
59
|
+
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3
|
|
60
|
+
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3
|
|
61
|
+
const linear = [
|
|
62
|
+
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
|
|
63
|
+
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
|
|
64
|
+
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
|
|
65
|
+
]
|
|
66
|
+
const toByte = (x) => {
|
|
67
|
+
const clipped = Math.min(1, Math.max(0, x))
|
|
68
|
+
const gamma = clipped <= 0.0031308 ? 12.92 * clipped : 1.055 * clipped ** (1 / 2.4) - 0.055
|
|
69
|
+
return Math.round(gamma * 255)
|
|
70
|
+
}
|
|
71
|
+
return `#${linear.map((x) => toByte(x).toString(16).padStart(2, '0')).join('')}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function colour(value, token) {
|
|
75
|
+
if (value !== undefined) {
|
|
76
|
+
if (!HEX.test(value)) throw new Error(`pwa: ${token} must be #rrggbb, not ${value}.`)
|
|
77
|
+
return value.toLowerCase()
|
|
78
|
+
}
|
|
79
|
+
return oklchToHex(themeToken(fs.readFileSync(THEME_CSS, 'utf8'), token))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------- icons
|
|
83
|
+
|
|
84
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
85
|
+
let c = n
|
|
86
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
87
|
+
return c >>> 0
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
function crc32(bytes) {
|
|
91
|
+
let crc = 0xffffffff
|
|
92
|
+
for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
|
|
93
|
+
return (crc ^ 0xffffffff) >>> 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function chunk(type, data) {
|
|
97
|
+
const length = Buffer.alloc(4)
|
|
98
|
+
length.writeUInt32BE(data.length)
|
|
99
|
+
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
|
|
100
|
+
const crc = Buffer.alloc(4)
|
|
101
|
+
crc.writeUInt32BE(crc32(body))
|
|
102
|
+
return Buffer.concat([length, body, crc])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A `size`×`size` PNG of one colour (#rrggbb). */
|
|
106
|
+
export function solidPng(size, hex) {
|
|
107
|
+
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
|
|
108
|
+
const header = Buffer.alloc(13)
|
|
109
|
+
header.writeUInt32BE(size, 0)
|
|
110
|
+
header.writeUInt32BE(size, 4)
|
|
111
|
+
header.set([8, 2, 0, 0, 0], 8) // 8-bit RGB, no interlace
|
|
112
|
+
const row = Buffer.alloc(1 + size * 3) // filter byte 0, then the pixels
|
|
113
|
+
for (let x = 0; x < size; x++) row.set(rgb, 1 + x * 3)
|
|
114
|
+
const pixels = zlib.deflateSync(Buffer.concat(Array.from({ length: size }, () => row)))
|
|
115
|
+
return Buffer.concat([
|
|
116
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
117
|
+
chunk('IHDR', header),
|
|
118
|
+
chunk('IDAT', pixels),
|
|
119
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
120
|
+
])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const escapeXml = (text) =>
|
|
124
|
+
text.replace(/[<>&'"]/g, (ch) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[ch])
|
|
125
|
+
|
|
126
|
+
/** A square SVG: the name's first letter on the theme colour. */
|
|
127
|
+
export function letterSvg(name, background, foreground) {
|
|
128
|
+
const letter = escapeXml([...name.trim()][0]?.toUpperCase() ?? '')
|
|
129
|
+
return (
|
|
130
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">` +
|
|
131
|
+
`<rect width="512" height="512" fill="${background}"/>` +
|
|
132
|
+
`<text x="256" y="256" dominant-baseline="central" text-anchor="middle" ` +
|
|
133
|
+
`font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="288" font-weight="600" ` +
|
|
134
|
+
`fill="${foreground}">${letter}</text></svg>\n`
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --------------------------------------------------------------- plugin
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Everything pwa() serves, by path from the site root, for `base`.
|
|
142
|
+
* @param {import('./index').PwaOptions} options
|
|
143
|
+
*/
|
|
144
|
+
export function pwaFiles(options, base = '/', root = process.cwd()) {
|
|
145
|
+
if (!options?.name?.trim()) throw new Error('pwa: `name` is required (the app as the Home Screen names it).')
|
|
146
|
+
const name = options.name.trim()
|
|
147
|
+
const shortName = options.shortName?.trim() || name
|
|
148
|
+
const theme = colour(options.themeColor, 'primary')
|
|
149
|
+
const onTheme = colour(options.themeForegroundColor, 'primary-foreground')
|
|
150
|
+
const background = colour(options.backgroundColor, 'background')
|
|
151
|
+
const at = (file) => `${base}${file}`
|
|
152
|
+
|
|
153
|
+
/** @type {Map<string, { type: string, source: string | Buffer }>} */
|
|
154
|
+
const files = new Map()
|
|
155
|
+
let icons
|
|
156
|
+
let touchIcon
|
|
157
|
+
if (options.icon) {
|
|
158
|
+
const own = path.resolve(root, options.icon)
|
|
159
|
+
if (!own.endsWith('.png') || !fs.existsSync(own)) throw new Error(`pwa: icon must be a PNG file; ${own} isn't one.`)
|
|
160
|
+
files.set('icons/icon.png', { type: 'image/png', source: fs.readFileSync(own) })
|
|
161
|
+
icons = [{ src: at('icons/icon.png'), sizes: '512x512', type: 'image/png', purpose: 'any' }]
|
|
162
|
+
touchIcon = 'icons/icon.png'
|
|
163
|
+
} else {
|
|
164
|
+
files.set('icons/icon.svg', { type: 'image/svg+xml', source: letterSvg(shortName, theme, onTheme) })
|
|
165
|
+
for (const size of [192, 512]) files.set(`icons/icon-${size}.png`, { type: 'image/png', source: solidPng(size, theme) })
|
|
166
|
+
files.set('icons/apple-touch-icon.png', { type: 'image/png', source: solidPng(180, theme) })
|
|
167
|
+
icons = [
|
|
168
|
+
{ src: at('icons/icon.svg'), sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
|
|
169
|
+
{ src: at('icons/icon-192.png'), sizes: '192x192', type: 'image/png', purpose: 'any' },
|
|
170
|
+
{ src: at('icons/icon-512.png'), sizes: '512x512', type: 'image/png', purpose: 'any' },
|
|
171
|
+
// One colour edge to edge: safe to crop to any mask shape.
|
|
172
|
+
{ src: at('icons/icon-512.png'), sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
|
173
|
+
]
|
|
174
|
+
touchIcon = 'icons/apple-touch-icon.png'
|
|
175
|
+
}
|
|
176
|
+
const manifest = {
|
|
177
|
+
name,
|
|
178
|
+
short_name: shortName,
|
|
179
|
+
...(options.description ? { description: options.description } : {}),
|
|
180
|
+
start_url: base,
|
|
181
|
+
scope: base,
|
|
182
|
+
display: 'standalone',
|
|
183
|
+
theme_color: theme,
|
|
184
|
+
background_color: background,
|
|
185
|
+
icons,
|
|
186
|
+
}
|
|
187
|
+
files.set('manifest.webmanifest', {
|
|
188
|
+
type: 'application/manifest+json',
|
|
189
|
+
source: `${JSON.stringify(manifest, null, 2)}\n`,
|
|
190
|
+
})
|
|
191
|
+
const worker = fs.readFileSync(WORKER_SOURCE, 'utf8').replace('__ICON__', options.icon ? at('icons/icon.png') : at('icons/icon-192.png'))
|
|
192
|
+
files.set('push-sw.js', { type: 'text/javascript', source: worker })
|
|
193
|
+
return {
|
|
194
|
+
files,
|
|
195
|
+
tags: [
|
|
196
|
+
{ tag: 'link', attrs: { rel: 'manifest', href: at('manifest.webmanifest') }, injectTo: 'head' },
|
|
197
|
+
{ tag: 'meta', attrs: { name: 'theme-color', content: theme }, injectTo: 'head' },
|
|
198
|
+
{ tag: 'link', attrs: { rel: 'apple-touch-icon', href: at(touchIcon) }, injectTo: 'head' },
|
|
199
|
+
],
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @param {import('./index').PwaOptions} options */
|
|
204
|
+
export function pwa(options) {
|
|
205
|
+
if (!options?.name?.trim()) throw new Error('pwa: `name` is required (the app as the Home Screen names it).')
|
|
206
|
+
/** @type {ReturnType<typeof pwaFiles>} */
|
|
207
|
+
let built
|
|
208
|
+
return {
|
|
209
|
+
name: 'ui-foundation:pwa',
|
|
210
|
+
configResolved(config) {
|
|
211
|
+
built = pwaFiles(options, config.base, config.root)
|
|
212
|
+
},
|
|
213
|
+
configureServer(server) {
|
|
214
|
+
server.middlewares.use((req, res, next) => {
|
|
215
|
+
const base = server.config.base
|
|
216
|
+
const url = (req.url ?? '').split('?')[0]
|
|
217
|
+
const file = url.startsWith(base) ? built.files.get(url.slice(base.length)) : undefined
|
|
218
|
+
if (!file) return next()
|
|
219
|
+
res.setHeader('Content-Type', file.type)
|
|
220
|
+
res.end(file.source)
|
|
221
|
+
})
|
|
222
|
+
},
|
|
223
|
+
transformIndexHtml() {
|
|
224
|
+
return built.tags
|
|
225
|
+
},
|
|
226
|
+
generateBundle() {
|
|
227
|
+
for (const [fileName, file] of built.files) this.emitFile({ type: 'asset', fileName, source: file.source })
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
}
|