diff --git a/AGENTS.md b/AGENTS.md index 0339fb8..222da8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,7 @@ Set in Settings > Environment (super admin), not `.env`. `envs` (private; encryp - **Install name:** app setting `install_name` (Settings > General, super admin). `handlePwaFile` rewrites the manifest's `name`/`short_name` per request (`namePwaManifest`), the root head uses it for iOS tags, the loading page's `` is the `{{name}}` placeholder. Devices pick up a new name only on reinstall. - **Install icon:** app setting `install_icon` (JSON, `parseInstallIcon`): a stone, or an upload (decoded, cropped to `source.png`, never stored as sent, no SVG) with the stone at `PwaCornerMarkInset`/`PwaCornerMarkScale`, inside a round launcher's crop. `saveInstallIcon` draws the set (`renderInstallIcons`, shared with `gen:favicon` via `renderAppIcon`) into `DATA_DIR/install-icon/<version>/`, served publicly and `immutable` by `handleInstallIconFile`; the manifest, head links and push payloads point at it. Ember stone = stock icons, no set. - Notification `badge` is `icons/badge-96.png` (white silhouette; Android masks by alpha, so never the opaque app icon). Brave on Android has no WebAPK, so its notifications always carry Brave's name and icon. -- `public/sw.js` is served `no-store`; navigations network-only (authenticated HTML never cached); `/api/`, `/ws`, `/media/`, `/computers/`, `/build-info.json` never intercepted. It never reloads on its own. In development the worker and caches are actively removed (`clear-development-service-worker.ts`, stub `/sw.js`). +- `public/sw.js` is served `no-store`; navigations network-only (authenticated HTML never cached); `/api/`, `/ws`, `/media/`, `/computers/`, `/build-info.json` never intercepted. A new worker takes over by itself (`skipWaiting` on install; nothing in it is per-version) and never reloads a page; there is no update prompt. In development the worker and caches are actively removed (`clear-development-service-worker.ts`, stub `/sw.js`). - **Precache (production):** a navigation without the current build cookie gets `loading.html`, which downloads all of `.bunext/public` into `cobble-assets-<build_id>`, verifies, sets the cookie and reloads (falls back to network on stall). React knows nothing of this. - Phone shell: rail is a slide-over below `md`; rail internals use the `@rail:` container variant, not viewport `lg:`. `LiveSocket` is remounted on online/pageshow/visibility since twui stops retrying after three attempts. Nothing is queued offline. - **Web Push:** `push_subscriptions`, `user_presence`; VAPID keys generated into `envs` by `ensureVapidKeys`. `pushBotNotice` is the one trigger, skipped while the user is present (`AppData.PushPresenceWindowMs`, per user not per device). Best effort, no retry. Needs HTTPS. diff --git a/public/sw.js b/public/sw.js index 7122c46..4b2c72a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -85,7 +85,14 @@ function isBypassRequest(url) { ); } +/** + * Nothing the worker holds belongs to one version (navigations go to the + * network, the code cache belongs to the build), so a new worker takes + * over at once instead of waiting for every tab to close. It never + * reloads a page: the open one keeps running, now served by it. + */ self.addEventListener("install", (event) => { + self.skipWaiting(); event.waitUntil( caches.open(CACHE_NAME).then((cache) => Promise.allSettled(SHELL.map((url) => cache.add(url))), @@ -190,9 +197,8 @@ async function cacheFirst(request) { } /** - * The page asks for a new version from its update notice: nothing skips - * the wait until the user says so. The loading page asks too, since it - * isn't the app and nothing of the app's is running. + * The loading page asks a waiting worker to take over. Workers from + * before `install` skipped the wait on their own only answer this. */ self.addEventListener("message", (event) => { if (event.data?.type === "SKIP_WAITING") { diff --git a/src/components/general/service-worker-registrar.tsx b/src/components/general/service-worker-registrar.tsx index 9e36905..d5f2899 100644 --- a/src/components/general/service-worker-registrar.tsx +++ b/src/components/general/service-worker-registrar.tsx @@ -1,11 +1,6 @@ "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 { useEffect, useRef } from "react"; import { PwaServiceWorkerPath } from "@/src/data/pwa-data"; import clearDevelopmentServiceWorker from "@/src/functions/frontend/clear-development-service-worker"; @@ -24,8 +19,11 @@ const DevelopmentClearMaxAttempts = 3; /** * 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. + * HMR is never cached) and checks for a new one hourly and whenever the + * app comes back into view. A new worker takes over by itself + * (`public/sw.js`), so there is nothing to offer and nothing reloads: a + * bot reply may be running. New builds reach the device through the + * loading page. * * In development it does the opposite: it clears the worker and the * caches a production deploy left on the device, so the dev server's @@ -33,8 +31,6 @@ const DevelopmentClearMaxAttempts = 3; * `clear-development-service-worker.ts`). */ export default function ServiceWorkerRegistrar() { - const [update_ready, setUpdateReady] = useState(false); - const [reloading, setReloading] = useState(false); const registration = useRef<ServiceWorkerRegistration | null>(null); useEffect(() => { @@ -45,26 +41,6 @@ export default function ServiceWorkerRegistrar() { 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: "/", @@ -73,7 +49,6 @@ export default function ServiceWorkerRegistrar() { .then((reg) => { if (cancelled) return; registration.current = reg; - watch(reg); interval = setInterval(() => reg.update().catch(() => {}), UpdateCheckMs); }) .catch((error) => { @@ -160,57 +135,5 @@ export default function ServiceWorkerRegistrar() { }; }, []); - 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> - ); + return null; }