Optimierung automatische Updates der Webanwendung bei Aktualisierung der Anwendung auf dem Server
This commit is contained in:
31
README.md
31
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
|
Speichert nur die Programmdateien, **nicht** die API-Antworten. Eine veraltete
|
||||||
Liste aus dem Cache wäre schlimmer als gar keine. Das kontrollierte Vorhalten
|
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
|
Bei Änderungen an den Dateien in `SHELL` muss `VERSION` in `sw.js` hochgezählt
|
||||||
werden, sonst behalten installierte Clients die alte Fassung.
|
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
|
### Auf dem Telefon installieren
|
||||||
|
|
||||||
Android/Chrome bietet „Zur Startseite hinzufügen" von selbst an. Bei iOS geht
|
Android/Chrome bietet „Zur Startseite hinzufügen" von selbst an. Bei iOS geht
|
||||||
|
|||||||
@@ -1577,3 +1577,59 @@ select.category-select {
|
|||||||
man sehen, ohne hinzusehen. */
|
man sehen, ohne hinzusehen. */
|
||||||
select.market-select:not([data-empty]),
|
select.market-select:not([data-empty]),
|
||||||
select.category-select:not([data-empty]) { border-color: var(--accent); }
|
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; } }
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { manageView } from "./views/manage.js";
|
|||||||
import { closeMenu } from "./appbar.js";
|
import { closeMenu } from "./appbar.js";
|
||||||
import * as db from "./db.js";
|
import * as db from "./db.js";
|
||||||
import { installTriggers, stopWatching } from "./sync.js";
|
import { installTriggers, stopWatching } from "./sync.js";
|
||||||
|
import { watchForUpdates } from "./update.js";
|
||||||
import { publicView } from "./views/public.js";
|
import { publicView } from "./views/public.js";
|
||||||
import { acceptInviteView, shareView } from "./views/share.js";
|
import { acceptInviteView, shareView } from "./views/share.js";
|
||||||
|
|
||||||
@@ -276,13 +277,9 @@ async function start() {
|
|||||||
|
|
||||||
await route();
|
await route();
|
||||||
|
|
||||||
if ("serviceWorker" in navigator) {
|
// Registriert den Service Worker und meldet sich, wenn eine neue
|
||||||
try {
|
// Fassung bereitliegt.
|
||||||
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
watchForUpdates();
|
||||||
} catch (err) {
|
|
||||||
console.warn("Service Worker nicht registriert:", err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start();
|
start();
|
||||||
|
|||||||
119
web/html/js/update.js
Normal file
119
web/html/js/update.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
// Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet,
|
// Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet,
|
||||||
// wann alte Zwischenspeicher verworfen werden.
|
// wann alte Zwischenspeicher verworfen werden.
|
||||||
const VERSION = "v30";
|
const VERSION = "v31";
|
||||||
const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`;
|
const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`;
|
||||||
|
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
@@ -18,6 +18,7 @@ const SHELL = [
|
|||||||
"/js/dom.js",
|
"/js/dom.js",
|
||||||
"/js/store.js",
|
"/js/store.js",
|
||||||
"/js/appbar.js",
|
"/js/appbar.js",
|
||||||
|
"/js/update.js",
|
||||||
"/js/db.js",
|
"/js/db.js",
|
||||||
"/js/sync.js",
|
"/js/sync.js",
|
||||||
"/js/push.js",
|
"/js/push.js",
|
||||||
@@ -41,13 +42,28 @@ const SHELL = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
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(
|
event.waitUntil(
|
||||||
caches.open(SHELL_CACHE)
|
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL))
|
||||||
.then((cache) => cache.addAll(SHELL))
|
|
||||||
.then(() => self.skipWaiting())
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys()
|
caches.keys()
|
||||||
|
|||||||
Reference in New Issue
Block a user