Make the app an installable PWA with phone layout and Web Push

Add a generated manifest, icon set, offline page and service worker that never caches authenticated HTML, plus install and update UI. Turn the rail into a drawer below md with container-query internals, dvh and safe-area handling, and reconnect the socket after visibility/network changes. Add Web Push subscriptions, presence-aware delivery, notification settings and tests.
This commit is contained in:
2026-10-19 17:12:16 +01:00
parent 61098a114c
commit dbfe48796b
100 changed files with 3573 additions and 117 deletions
+14
View File
@@ -275,4 +275,18 @@ export const AppData = {
* Largest picture a bot or a workspace can have.
*/
ProfileImageMaxBytes: 5 * 1024 * 1024,
/**
* A user with an open socket seen this recently gets no push: they
* are already looking at the app.
*/
PushPresenceWindowMs: 45_000,
/**
* A notification's body is one line, cut here.
*/
PushBodyMaxChars: 140,
/**
* How long a push service may hold a notification for a device that
* is offline.
*/
PushTtlSeconds: 60 * 60,
} as const;
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import path from "path";
import { SiteData } from "./site-data";
import {
PwaIcons,
PwaManifestName,
PwaOfflinePath,
PwaScope,
PwaStartPath,
} from "./pwa-data";
const ROOT = path.resolve(import.meta.dir, "../..");
describe("PwaData", () => {
test("has the sizes a browser needs to install", () => {
const any_sizes = PwaIcons.filter((icon) => icon.purpose == "any").map(
(icon) => icon.sizes,
);
const maskable_sizes = PwaIcons.filter(
(icon) => icon.purpose == "maskable",
).map((icon) => icon.sizes);
expect(any_sizes).toContain("192x192");
expect(any_sizes).toContain("512x512");
expect(maskable_sizes).toContain("192x192");
expect(maskable_sizes).toContain("512x512");
});
test("names the app from SiteData and starts on the crew", () => {
expect(PwaManifestName).toBe(SiteData["SiteName"]);
expect(PwaStartPath).toBe("/admin/bots");
expect(PwaScope).toBe("/");
});
test("the generated manifest matches this config", async () => {
const manifest = (await Bun.file(
path.join(ROOT, "public/manifest.webmanifest"),
).json()) as {
name: string;
start_url: string;
scope: string;
icons: { src: string; purpose: string }[];
};
expect(manifest.name).toBe(PwaManifestName);
expect(manifest.start_url).toBe(PwaStartPath);
expect(manifest.scope).toBe(PwaScope);
expect(
manifest.icons.map((icon) => `${icon.src}:${icon.purpose}`),
).toEqual(PwaIcons.map((icon) => `${icon.src}:${icon.purpose}`));
});
test("the offline page and every icon exist", async () => {
expect(
await Bun.file(path.join(ROOT, "public", PwaOfflinePath)).exists(),
).toBe(true);
for (const icon of PwaIcons) {
const file = Bun.file(path.join(ROOT, "public", icon.src));
expect(await file.exists()).toBe(true);
}
});
});
+77
View File
@@ -0,0 +1,77 @@
import { SiteData } from "./site-data";
/**
* What makes the app installable, shared by the pages that advertise it,
* the server that serves it and the script that generates it.
*/
export const PwaStartPath = "/admin/bots";
export const PwaScope = "/";
export const PwaManifestPath = "/manifest.webmanifest";
export const PwaServiceWorkerPath = "/sw.js";
export const PwaOfflinePath = "/offline.html";
export type PwaIcon = {
src: string;
sizes: string;
type: string;
purpose: "any" | "maskable";
};
/**
* Generated from `public/logos/logo.svg` by `bun run gen:favicon`.
*/
export const PwaIcons: PwaIcon[] = [
{
src: "/icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/icons/maskable-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/icons/maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
];
export type PwaShortcut = {
name: string;
url: string;
icons: { src: string; sizes: string; type: string }[];
};
export const PwaShortcuts: PwaShortcut[] = [
{
name: "Routines",
url: "/admin/routines",
icons: [{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }],
},
{
name: "Help",
url: "/admin/help",
icons: [{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }],
},
];
export const PwaIconPath = (name: `icon` | `maskable`, size: number) =>
`/icons/${name}-${size}.png`;
/**
* The app's install name: `SiteName` is lowercase, `PwaShortName` fits
* under an icon.
*/
export const PwaManifestName = SiteData["SiteName"];
export const PwaShortName = SiteData["SiteName"];