diff --git a/README.md b/README.md index 5473edf..de1a43b 100644 --- a/README.md +++ b/README.md @@ -329,11 +329,40 @@ stehenbleiben, die nie angekommen ist. Diese Stelle (`mutate()` in Speichert nur die Programmdateien, **nicht** die API-Antworten. Eine veraltete Liste aus dem Cache wäre schlimmer als gar keine. Das kontrollierte Vorhalten -der Daten übernimmt in Phase 4 IndexedDB. +der Daten übernimmt IndexedDB. Bei Änderungen an den Dateien in `SHELL` muss `VERSION` in `sw.js` hochgezählt werden, sonst behalten installierte Clients die alte Fassung. +### Aktualisierung installierter Apps + +Eine installierte PWA wird oft wochenlang nicht vollständig geschlossen. Der +Browser lädt den Service Worker zwar im Hintergrund neu, aber der neue bleibt +im Wartezustand, bis alle Fenster geschlossen sind – und die laufende Seite +arbeitet weiter mit dem alten JavaScript. Für den Nutzer sieht es aus, als käme +das Update nie an. + +`web/html/js/update.js` löst das: + +1. Nachfragen, ob etwas bereitliegt – beim Zurückkehren in den Vordergrund, + bei Verbindungsrückkehr und alle 30 Minuten +2. Wenn ja: unten ein Hinweis mit „Jetzt neu laden" und einem Kreuz zum + Wegklicken +3. Auf Klick übernimmt der neue Service Worker, und über `controllerchange` + lädt die Seite **einmal** neu – auch in weiteren offenen Fenstern + +**`skipWaiting()` steht nicht mehr im `install`-Ereignis.** Genau dort war der +Fehler: Der neue Service Worker übernahm sofort die Kontrolle, während die +geladene Seite noch mit dem alten JavaScript lief – gemischte Stände, und +aktualisiert wurde trotzdem erst beim nächsten vollständigen Schließen. Jetzt +wartet er, bis die Anwendung ihn ausdrücklich dazu auffordert. + +**Kein ungefragtes Neuladen.** Ein erzwungener Neustart mitten in einer Eingabe +würde den halb getippten Artikelnamen verlieren. Die Daten selbst sind sicher – +sie liegen in der Outbox –, aber der Ärger wäre trotzdem da. Wer den Hinweis +wegklickt, bekommt die neue Fassung beim nächsten Start; das geschieht ohnehin, +sobald alle Fenster geschlossen sind. + ### Auf dem Telefon installieren Android/Chrome bietet „Zur Startseite hinzufügen" von selbst an. Bei iOS geht diff --git a/web/html/app.css b/web/html/app.css index 7e4180b..c3f0c0b 100644 --- a/web/html/app.css +++ b/web/html/app.css @@ -1577,3 +1577,59 @@ select.category-select { man sehen, ohne hinzusehen. */ select.market-select:not([data-empty]), select.category-select:not([data-empty]) { border-color: var(--accent); } + +/* =================================================================== + Hinweis auf eine neue Fassung + =================================================================== */ + +.update-banner { + position: fixed; + z-index: 80; + left: 1rem; + right: 1rem; + bottom: calc(1rem + env(safe-area-inset-bottom)); + max-width: 28rem; + margin: 0 auto; + + display: flex; + align-items: center; + gap: .6rem; + + background: var(--card); + border: 1px solid var(--accent); + border-radius: var(--radius); + padding: .7rem .8rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, .22); + font-size: .92rem; +} + +.update-text { flex: 1; min-width: 0; } + +button.update-apply { + flex: 0 0 auto; + background: var(--accent); + color: var(--accent-fg); + border: none; + border-radius: var(--radius); + padding: .45rem .8rem; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +button.update-later { + flex: 0 0 auto; + background: none; + border: none; + color: var(--muted); + font-size: 1.2rem; + line-height: 1; + padding: .2rem .4rem; + cursor: pointer; +} + +/* Über dem Sucher und über Dialogen hat der Hinweis nichts zu suchen. */ +body.scanning .update-banner, +body.modal-open .update-banner { display: none; } + +@media print { .update-banner { display: none !important; } } diff --git a/web/html/js/app.js b/web/html/js/app.js index 03ddc19..7d0e22a 100644 --- a/web/html/js/app.js +++ b/web/html/js/app.js @@ -24,6 +24,7 @@ import { manageView } from "./views/manage.js"; import { closeMenu } from "./appbar.js"; import * as db from "./db.js"; import { installTriggers, stopWatching } from "./sync.js"; +import { watchForUpdates } from "./update.js"; import { publicView } from "./views/public.js"; import { acceptInviteView, shareView } from "./views/share.js"; @@ -276,13 +277,9 @@ async function start() { await route(); - if ("serviceWorker" in navigator) { - try { - await navigator.serviceWorker.register("/sw.js", { scope: "/" }); - } catch (err) { - console.warn("Service Worker nicht registriert:", err.message); - } - } + // Registriert den Service Worker und meldet sich, wenn eine neue + // Fassung bereitliegt. + watchForUpdates(); } start(); diff --git a/web/html/js/update.js b/web/html/js/update.js new file mode 100644 index 0000000..cd5caf4 --- /dev/null +++ b/web/html/js/update.js @@ -0,0 +1,119 @@ +// Erkennt neue Fassungen der Anwendung und wendet sie an. +// +// Warum das nötig ist: Eine installierte PWA wird oft wochenlang nicht +// vollständig geschlossen. Der Browser lädt zwar den Service Worker im +// Hintergrund neu, aber der neue bleibt im Wartezustand, bis alle +// Fenster geschlossen sind - und die laufende Seite arbeitet weiter mit +// dem alten JavaScript. Für den Nutzer sieht es aus, als käme das +// Update nie an. +// +// Ablauf hier: +// 1. Regelmäßig nachfragen, ob eine neue Fassung bereitliegt +// 2. Wenn ja: Hinweis einblenden, nicht ungefragt neu laden +// 3. Auf Wunsch den Wechsel anfordern und danach EINMAL neu laden +"use strict"; + +import { el } from "./dom.js"; + +// Wie oft im laufenden Betrieb nachgefragt wird. Der Browser prüft von +// sich aus nur bei Navigationen - in einer installierten PWA kann das +// sehr lange dauern. +const CHECK_INTERVAL_MS = 30 * 60 * 1000; + +let reloading = false; +let banner = null; + +function showBanner(onApply) { + if (banner) return; + + banner = el("div.update-banner", { role: "status" }, + el("span.update-text", {}, + "Eine neue Fassung der App ist bereit."), + el("button.update-apply", { type: "button", onclick: onApply }, + "Jetzt neu laden"), + el("button.update-later", { + type: "button", + "aria-label": "Hinweis ausblenden", + title: "Später – die neue Fassung wird beim nächsten Start aktiv", + onclick: () => { + banner.remove(); + banner = null; + }, + }, "×") + ); + + document.body.append(banner); +} + +/** + * Registriert den Service Worker und überwacht Aktualisierungen. + * + * Gibt nichts zurück; Fehler werden geschluckt, weil eine fehlende + * Aktualisierungsprüfung die Anwendung nicht unbenutzbar machen darf. + */ +export async function watchForUpdates() { + if (!("serviceWorker" in navigator)) return; + + let registration; + try { + registration = await navigator.serviceWorker.register("/sw.js", { scope: "/" }); + } catch (err) { + console.warn("Service Worker nicht registriert:", err.message); + return; + } + + /** Fordert den wartenden Service Worker auf, zu übernehmen. Das + * eigentliche Neuladen löst danach das controllerchange-Ereignis + * aus - so wird auch in anderen offenen Fenstern neu geladen. */ + function apply() { + const waiting = registration.waiting; + if (!waiting) { + // Sollte nicht vorkommen; dann eben unmittelbar neu laden. + window.location.reload(); + return; + } + waiting.postMessage({ type: "SKIP_WAITING" }); + } + + function considerWaiting() { + // Ohne controller ist es die Erstinstallation - da gibt es nichts + // zu ersetzen, und ein Hinweis wäre verwirrend. + if (registration.waiting && navigator.serviceWorker.controller) { + showBanner(apply); + } + } + + // Beim Start: Liegt schon etwas bereit? + considerWaiting(); + + registration.addEventListener("updatefound", () => { + const incoming = registration.installing; + if (!incoming) return; + incoming.addEventListener("statechange", () => { + if (incoming.state === "installed") considerWaiting(); + }); + }); + + // Genau einmal neu laden, wenn der neue Service Worker übernimmt. + navigator.serviceWorker.addEventListener("controllerchange", () => { + if (reloading) return; + reloading = true; + window.location.reload(); + }); + + const check = () => { + // Ohne Verbindung wäre die Abfrage nur ein Fehler im Protokoll. + if (!navigator.onLine) return; + registration.update().catch(() => {}); + }; + + // Beim Zurückkehren in den Vordergrund - der häufigste Moment, in dem + // eine installierte PWA nach Tagen wieder benutzt wird. + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") check(); + }); + + window.addEventListener("online", check); + setInterval(check, CHECK_INTERVAL_MS); + check(); +} diff --git a/web/html/sw.js b/web/html/sw.js index 1725f49..941b659 100644 --- a/web/html/sw.js +++ b/web/html/sw.js @@ -5,7 +5,7 @@ // Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet, // wann alte Zwischenspeicher verworfen werden. -const VERSION = "v30"; +const VERSION = "v31"; const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`; const SHELL = [ @@ -18,6 +18,7 @@ const SHELL = [ "/js/dom.js", "/js/store.js", "/js/appbar.js", + "/js/update.js", "/js/db.js", "/js/sync.js", "/js/push.js", @@ -41,13 +42,28 @@ const SHELL = [ ]; self.addEventListener("install", (event) => { + // KEIN skipWaiting hier. + // + // Vorher stand es an dieser Stelle - mit der Folge, dass der neue + // Service Worker sofort die Kontrolle übernahm, während die bereits + // geladene Seite mit dem alten JavaScript weiterlief. Ergebnis: + // gemischte Stände, und aktualisiert wurde trotzdem erst beim + // nächsten vollständigen Schließen der App. + // + // Jetzt wartet er, bis die Anwendung ihn ausdrücklich dazu auffordert + // (Nachricht "SKIP_WAITING") oder bis alle Fenster geschlossen sind. event.waitUntil( - caches.open(SHELL_CACHE) - .then((cache) => cache.addAll(SHELL)) - .then(() => self.skipWaiting()) + caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL)) ); }); +// Die Anwendung fordert den Wechsel an, sobald der Nutzer zugestimmt hat. +self.addEventListener("message", (event) => { + if (event.data && event.data.type === "SKIP_WAITING") { + self.skipWaiting(); + } +}); + self.addEventListener("activate", (event) => { event.waitUntil( caches.keys()