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
@@ -0,0 +1,67 @@
import { PwaManifestPath } from "@/src/data/pwa-data";
/**
* Runs before React mounts, in `<head>`:
* - keeps `beforeinstallprompt` (it can fire before hydration, and only
* one prompt can ever be held)
* - points the browser's theme color at the current theme's paper token,
* so it follows light/dark without repeating the color here
* - lets the page use safe-area insets and resize under the on-screen
* keyboard (bunext emits a fixed viewport meta)
*/
const PwaInitScriptSource = `(function () {
try {
window.__cobbleInstallPrompt = null;
window.addEventListener("beforeinstallprompt", function (event) {
event.preventDefault();
window.__cobbleInstallPrompt = event;
window.dispatchEvent(new CustomEvent("cobble:install-available"));
});
window.addEventListener("appinstalled", function () {
window.__cobbleInstallPrompt = null;
window.dispatchEvent(new CustomEvent("cobble:installed"));
});
} catch (e) {}
try {
var meta = document.querySelector('meta[name="viewport"]');
if (meta) {
meta.setAttribute(
"content",
"width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content",
);
}
} catch (e) {}
try {
var theme_meta = document.querySelector('meta[name="theme-color"]');
if (!theme_meta) {
theme_meta = document.createElement("meta");
theme_meta.setAttribute("name", "theme-color");
document.head.appendChild(theme_meta);
}
var apply_theme_color = function () {
var paper = getComputedStyle(document.documentElement)
.getPropertyValue("--c-paper")
.trim();
if (paper) theme_meta.setAttribute("content", paper);
};
apply_theme_color();
window.addEventListener("load", apply_theme_color);
var dark_query = window.matchMedia("(prefers-color-scheme: dark)");
if (dark_query.addEventListener) dark_query.addEventListener("change", apply_theme_color);
new MutationObserver(apply_theme_color).observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
} catch (e) {}
})();`;
export default function PwaInitScript() {
return (
<>
<script dangerouslySetInnerHTML={{ __html: PwaInitScriptSource }} />
<link rel="manifest" href={PwaManifestPath} />
</>
);
}
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Paper from "@moduletrace/twui/elements/Paper";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import { RefreshCw } from "lucide-react";
import { PwaServiceWorkerPath } from "@/src/data/pwa-data";
const UpdateCheckMs = 60 * 60 * 1000;
/**
* Registers the app's service worker (production only, so development
* HMR is never cached) and offers a reload once a new version is
* installed. It never reloads on its own: a bot reply may be running.
*/
export default function ServiceWorkerRegistrar() {
const [update_ready, setUpdateReady] = useState(false);
const [reloading, setReloading] = useState(false);
const registration = useRef<ServiceWorkerRegistration | null>(null);
useEffect(() => {
if (process.env.NODE_ENV != "production") return;
if (typeof navigator == "undefined") return;
if (!("serviceWorker" in navigator) || !window.isSecureContext) return;
let cancelled = false;
let interval: ReturnType<typeof setInterval> | undefined;
const watch = (reg: ServiceWorkerRegistration) => {
if (reg.waiting && navigator.serviceWorker.controller) {
setUpdateReady(true);
}
reg.addEventListener("updatefound", () => {
const installing = reg.installing;
installing?.addEventListener("statechange", () => {
if (cancelled) return;
if (
installing.state == "installed" &&
navigator.serviceWorker.controller
) {
setUpdateReady(true);
}
});
});
};
navigator.serviceWorker
.register(PwaServiceWorkerPath, {
scope: "/",
updateViaCache: "none",
})
.then((reg) => {
if (cancelled) return;
registration.current = reg;
watch(reg);
interval = setInterval(() => reg.update().catch(() => {}), UpdateCheckMs);
})
.catch((error) => {
console.log("Service worker registration failed:", error);
});
const checkOnVisible = () => {
if (document.visibilityState == "visible") {
registration.current?.update().catch(() => {});
}
};
document.addEventListener("visibilitychange", checkOnVisible);
return () => {
cancelled = true;
if (interval) clearInterval(interval);
document.removeEventListener("visibilitychange", checkOnVisible);
};
}, []);
useEffect(() => {
if (!reloading) return;
const onControllerChange = () => window.location.reload();
navigator.serviceWorker?.addEventListener("controllerchange", onControllerChange);
registration.current?.waiting?.postMessage({ type: "SKIP_WAITING" });
const fallback = setTimeout(() => window.location.reload(), 1500);
return () => {
clearTimeout(fallback);
navigator.serviceWorker?.removeEventListener("controllerchange", onControllerChange);
};
}, [reloading]);
if (!update_ready) return null;
return (
<Paper
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-[250] w-[min(320px,calc(100vw-32px))] gap-3! p-4 bg-surface shadow-popover! border-hairline dark:bg-surface dark:border-hairline"
>
<Span className="text-sm font-bold leading-5 text-ink">
A new version is ready
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
Reload to use it. Your conversations are saved.
</Span>
<Row className="gap-2! flex-nowrap justify-end">
<Button
title="Reload later"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={reloading}
onClick={() => setUpdateReady(false)}
>
Later
</Button>
<Button
title="Reload the app"
type="button"
size="small"
loading={reloading}
beforeIcon={<RefreshCw size={15} />}
onClick={() => setReloading(true)}
>
Reload
</Button>
</Row>
</Paper>
);
}