baustelle-pwa/sw.js
Eddy 30202f8f28 Keine Doppel-Uploads mehr, App-Update laedt nicht mitten in der Arbeit neu
Doppel-Uploads (Prod-Log 17.09.: reihenweise duplicate:true): Seite
(syncQueue) und Service Worker (Background Sync) arbeiteten dieselbe
IndexedDB-Queue ab, ohne voneinander zu wissen. Dank md5-Abgleich
folgenlos, aber doppeltes Datenvolumen. Mit eingestelltem Verkleinern
sogar zwei verschiedene Dateien im Auftrag: der Worker lud das
Original, die Seite danach die kleine Fassung.

- idb.js: queueKeys/queuePatch/queueClaim/queueRelease - Lesen-Aendern-
  Schreiben in einer readwrite-Transaktion, ueber Kontexte atomar
- syncQueue (Seite) und drainQueue (Worker) belegen jedes Foto vor dem
  Upload (Lease 90 s > laengster Upload-Timeout); belegt = ueberspringen,
  abgelaufen = uebernehmen. Nur noch ein Foto gleichzeitig im Speicher
- enqueuePhoto({hold:true}) + releaseHold: waehrend des Verkleinerns
  bleibt das Foto belegt
- Versuchszaehler, retryFailed und replaceQueuedBlob per Patch statt
  ganzes Item zurueckschreiben

Reload nach App-Update: controllerchange lud sofort neu - offene Kamera,
getippte Notiz, halbe Unterschrift weg; mit Fotos in der Warteschlange
kam dazu der Browserdialog "Seite verlassen?".

- window.appBusy(): Modal, Auswahl, laufender Upload, Eingabe im Fokus,
  laufende Sprachnotiz. PIN-Sperre zaehlt bewusst nicht
- index.php holt den Reload nach, sobald nichts mehr offen ist; eigener
  Reload setzt __selfReload, beforeunload warnt dann nicht

Verifiziert lokal in Chromium: echte Kamera-Oberflaeche mit Canvas-
Stream offline, paralleler Zugriff Seite+Worker (3 Fotos = 3 POSTs im
Serverlog, kein duplicate), Verkleinern (eine Datei, kleine Fassung),
fremde Lease frisch/abgelaufen, aufgeschobener Reload ohne Dialog.
Nicht getestet: Kamera-Hardware und Background Sync bei Display aus am
Handy. Kein Deploy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 23:49:02 +02:00

446 lines
18 KiB
JavaScript

/* Baustelle PWA Service Worker
* Pattern nach claude-db #201 (filemtime-basiert):
* - Cache-Version kommt aus URL-Query ?v=<mtime>, kein manuelles Hochzählen
* - Network-First für eigene Assets (immer aktuell, Fallback Cache)
* - skipWaiting() + clients.claim() damit Updates sofort greifen
* - Web Share Target via POST → share.html
*/
// Cache-Version dynamisch aus ?v=<mtime> — wird von index.php gesetzt
const SW_VERSION = (new URL(self.location.href)).searchParams.get('v') || 'static';
const CACHE = 'baustelle-' + SW_VERSION;
/* Bild-Cache für die Thumbnails aus photo.php — bewusst NICHT versioniert, damit ein
* Deploy die schon geladenen Fotos nicht wegräumt. Ohne das ist auf der Baustelle ohne
* Netz kein einziges Foto sichtbar, auch keins, das man vorher schon angesehen hat. */
const MEDIA_CACHE = 'baustelle-media';
const MEDIA_MAX = 300;
async function trimMediaCache() {
const c = await caches.open(MEDIA_CACHE);
const keys = await c.keys();
// keys() liefert in Einfüge-Reihenfolge — die ältesten stehen vorn
for (let i = 0; i < keys.length - MEDIA_MAX; i++) await c.delete(keys[i]);
}
/* Thumbnails: erst Cache, dann Netz (und im Hintergrund auffrischen).
* Die URL enthält relpath + Größe, und der Server schickt ein ETag aus der mtime —
* ein ersetztes Original bekommt damit ohnehin eine neue Antwort. */
async function mediaCacheFirst(request) {
const c = await caches.open(MEDIA_CACHE);
const hit = await c.match(request);
if (hit) {
// Auffrischen ohne den Nutzer warten zu lassen
fetch(request).then(r => {
if (cacheable(r)) c.put(request, r.clone()).then(trimMediaCache);
}).catch(() => null);
return hit;
}
try {
const r = await fetch(request);
if (cacheable(r)) { await c.put(request, r.clone()); trimMediaCache(); }
return r;
} catch (e) {
// Offline und nichts im Cache — der Aufrufer zeigt seinen Platzhalter
return new Response('', { status: 504, statusText: 'Offline' });
}
}
const SHELL = [
'./',
'./index.php',
'./share.html',
'./icons/icon-192.png',
'./icons/icon-512.png',
'./icons/icon.svg',
// CSS/JS werden mit ?v=<mtime> geladen und dynamisch gecached
// beim ersten fetch — nicht in SHELL damit Pre-Cache nicht mit alten v's läuft
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE)
.then(c => c.addAll(SHELL).catch(() => null))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then(keys => Promise.all(
// MEDIA_CACHE ausnehmen — der überlebt Deploys bewusst
keys.filter(k => k.startsWith('baustelle-') && k !== CACHE && k !== MEDIA_CACHE)
.map(k => caches.delete(k))
))
.then(() => self.clients.claim())
);
});
/* ==========================================================================
* Web Share Target — was aus WhatsApp & Co. geteilt wird, hier annehmen
*
* Der POST-Body gibt seine Daten genau EINMAL her. Deshalb wird alles sofort in
* IndexedDB gesichert, bevor irgendetwas angezeigt wird; share.html liest es von dort.
*
* Bis 30.08.2026 wurden nur Dateien gesichert. Teilt jemand aus WhatsApp einen
* Beschreibungstext (mit oder ohne Bild), landete der Text nirgends — die Seite
* meldete dann "Keine geteilten Fotos gefunden". Titel, Text und Link gehoeren
* genauso ins Paket wie die Bilder.
* ========================================================================== */
const SHARE_KEY = 'shared_payload';
function shareDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('baustelle-pwa-v1', 1);
req.onupgradeneeded = () => {
const d = req.result;
if (!d.objectStoreNames.contains('kv')) d.createObjectStore('kv');
if (!d.objectStoreNames.contains('queue')) d.createObjectStore('queue', { keyPath: 'id', autoIncrement: true });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function isSharedFile(v) {
return v && typeof v === 'object' && typeof v.arrayBuffer === 'function' && v.size > 0;
}
async function handleShareTarget(request) {
try {
const fd = await request.formData();
let files = [];
for (const key of ['photos', 'file', 'files', 'image', 'images']) {
for (const v of fd.getAll(key)) if (isSharedFile(v)) files.push(v);
}
// Fallback: manche Apps benennen das Feld anders — alles nehmen, was eine Datei ist
if (!files.length) {
for (const [, v] of fd.entries()) if (isSharedFile(v)) files.push(v);
}
const str = (k) => { const v = fd.get(k); return typeof v === 'string' ? v.trim() : ''; };
const payload = {
title: str('title'),
text: str('text'),
url: str('url'),
files: files.map(f => ({ name: f.name || 'foto.jpg', type: f.type || 'image/jpeg', data: f })),
ts: Date.now(),
};
const db = await shareDb();
const tx = db.transaction('kv', 'readwrite');
tx.objectStore('kv').put(payload, SHARE_KEY);
tx.objectStore('kv').delete('shared_files'); // altes Format, sonst taucht eine alte Ladung wieder auf
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
} catch (e) {
// Nicht am Sichern haengenbleiben — share.html meldet dann, dass nichts ankam
}
// Absolute Adresse: eine relative wuerde Response.redirect je nach Browser ablehnen
return Response.redirect(new URL('share.html', self.location.href).href, 303);
}
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
// Web Share Target POST → share.html
if (e.request.method === 'POST' && url.pathname.endsWith('/share.html')) {
e.respondWith(handleShareTarget(e.request));
return;
}
// Thumbnails aus photo.php: cache-first, damit die Fotoliste offline steht
if (e.request.method === 'GET'
&& url.pathname.endsWith('/custom/bericht/api/photo.php')
&& url.searchParams.get('size') === 'thumb') {
e.respondWith(mediaCacheFirst(e.request));
return;
}
// Übrige API-Requests: nicht cachen, durchreichen
if (url.pathname.includes('/custom/bericht/api/')) {
return;
}
// Nicht-GETs: durchreichen
if (e.request.method !== 'GET') {
return;
}
// Eigene Assets: Network-First mit Cache-Fallback
if (url.origin === location.origin) {
e.respondWith(networkFirst(e.request));
return;
}
});
/* Nur vollstaendige Antworten cachen. Eine 206 (Teilinhalt, z.B. Audio-Seek) oder eine
* opaque Antwort liefert der Worker spaeter als kaputte Datei aus. */
function cacheable(response) {
return !!response && response.ok && response.status === 200 && response.type !== 'opaque';
}
/* Network-First fuer eigene Dateien.
*
* ACHTUNG, hier lag ein echter Fehler: frueher endete der Zweig auf
* `.catch(() => caches.match(e.request))`. Findet der Cache nichts, ist das Ergebnis
* `undefined` — und `respondWith(undefined)` laesst die Anfrage erneut scheitern, mit
* exakt derselben Meldung "Failed to fetch", die der Fallback verhindern sollte.
* Deshalb gibt diese Fassung IMMER eine echte Response zurueck. */
async function networkFirst(request) {
try {
const response = await fetch(request);
if (cacheable(response)) {
const clone = response.clone();
caches.open(CACHE).then(c => c.put(request, clone)).catch(() => null);
}
return response;
} catch (_) {
const hit = await caches.match(request);
if (hit) return hit;
return offlineFallback(request);
}
}
/* Kein Netz und nichts im Cache. Fuer eine Seitennavigation die App-Huelle nehmen
* (die Startseite liegt im Precache) — die App startet dann und zeigt ihren eigenen
* Offline-Stand. Nur wenn selbst die fehlt, eine erklaerende Seite statt der
* Browser-Fehlerseite. */
async function offlineFallback(request) {
const accept = request.headers.get('accept') || '';
const isPage = request.mode === 'navigate' || accept.includes('text/html');
if (isPage) {
const shell = (await caches.match('./index.php')) || (await caches.match('./'));
if (shell) return shell;
return new Response(
'<!doctype html><html lang="de"><head><meta charset="utf-8">'
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
+ '<title>Baustelle — offline</title></head>'
+ '<body style="margin:0;background:#1a1a1f;color:#f0f0f0;'
+ 'font-family:-apple-system,Roboto,sans-serif;display:flex;align-items:center;'
+ 'justify-content:center;height:100vh;text-align:center">'
+ '<div style="padding:24px"><div style="font-size:44px">\u{1F4F4}</div>'
+ '<h1 style="font-size:18px">Keine Verbindung</h1>'
+ '<p style="opacity:.7;line-height:1.5">Die App konnte nicht geladen werden.<br>'
+ 'Sobald wieder Netz da ist, erneut \u00f6ffnen.</p></div></body></html>',
{ status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
}
return new Response(JSON.stringify({ error: 'offline', offline: true }), {
status: 503,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
/* Message-Handler: Client kann "SKIP_WAITING" schicken um ein wartenden SW
* sofort zu aktivieren (für In-App-Update-Button). */
self.addEventListener('message', (e) => {
if (e.data && e.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
// Beim Abmelden dürfen keine Kundenfotos auf dem Gerät zurückbleiben
if (e.data && e.data.type === 'CLEAR_MEDIA') {
e.waitUntil(caches.delete(MEDIA_CACHE));
}
/* Nach jedem Deploy traegt der Cache einen neuen Namen und ist erst einmal LEER —
* inklusive app.js und app.css. Wer die neue Fassung morgens laedt und dann ins
* Funkloch faehrt, haette die Browser-Fehlerseite (KB #1041).
*
* Eine feste Dateiliste im install-Schritt hilft nicht: die Asset-URLs tragen
* ?v=<mtime> und aendern sich mit jedem Deploy. Deshalb meldet die Seite nach dem
* Laden, was sie tatsaechlich geholt hat, und das wird nachgecached — einzeln, denn
* ein addAll() wuerde bei einer einzigen 404 alles verwerfen. */
if (e.data && e.data.type === 'PRECACHE' && Array.isArray(e.data.urls)) {
e.waitUntil(caches.open(CACHE).then(c => Promise.all(
e.data.urls.map(u => c.match(u).then(hit => hit ? null : c.add(u).catch(() => null)))
)));
}
});
/* ==========================================================================
* Background Sync — Fotos nachliefern, auch wenn die App gar nicht offen ist
*
* Bis hierher lief das Nachliefern ausschliesslich in der Seite selbst: über
* das online-Ereignis, „App wieder sichtbar" und einen 15-Sekunden-Takt. Ist
* das Display aus oder die App weggewischt, friert der Browser diese Timer ein
* — es passierte schlicht nichts, bis jemand die App wieder aufmachte.
*
* Der Service Worker läuft unabhängig davon. Er greift auf dieselbe
* IndexedDB-Queue zu (baustelle-pwa-v1 / Store "queue") und lädt selbst hoch;
* die Anmeldung steckt im HttpOnly-Cookie awl_sso und geht bei same-origin
* automatisch mit — deshalb braucht es hier kein Token-Handling.
*
* Gelöscht wird ein Eintrag nur bei BESTÄTIGTEM Upload: HTTP 2xx, echtes JSON
* und ein relpath in der Antwort. Alles andere bleibt liegen; wir lehnen das
* Sync-Versprechen ab, dann plant der Browser den nächsten Versuch selbst.
* ========================================================================== */
const QUEUE_DB = 'baustelle-pwa-v1';
const QUEUE_STORE = 'queue';
const UPLOAD_BASE = '/custom/bericht/api/orders.php';
function queueDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(QUEUE_DB); // KEINE Version angeben:
req.onsuccess = () => resolve(req.result); // das Schema legt die Seite an
req.onerror = () => reject(req.error);
});
}
function queueIds(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction(QUEUE_STORE, 'readonly');
const req = tx.objectStore(QUEUE_STORE).getAllKeys();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
/* Die Seite arbeitet dieselbe Queue ab (lib/offline.js, syncQueue). Ohne Absprache luden
* beide dasselbe Foto hoch — im Prod-Log vom 17.09.2026 reihenweise duplicate:true, im
* Mobilfunk also das doppelte Datenvolumen. Deshalb belegt, wer hochlaedt, den Eintrag
* (Lease) — in EINER readwrite-Transaktion, die IndexedDB ueber alle Kontexte hinweg
* hintereinander ausfuehrt. DIESELBE LOGIK STEHT IN lib/idb.js (queueClaim) — beide
* Stellen zusammen aendern.
*
* Die Lease haelt laenger als der laengste Upload-Timeout (60 s, unten), damit niemand
* einen LAUFENDEN Upload uebernimmt; nach Ablauf ist der Eintrag wieder frei — fuer die
* eingefrorene oder abgeschossene Seite gibt es den Background Sync ja gerade. */
const SW_OWNER = 'sw-' + Math.random().toString(36).slice(2, 10);
const LEASE_MS = 90000;
/* Rueckgabe: { item } wenn belegt, { busy:true } wenn gerade ein anderer dran ist
* (Seite laedt hoch oder verkleinert noch), sonst {} (weg, Quarantaene, kein Foto). */
function queueClaim(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction(QUEUE_STORE, 'readwrite');
const store = tx.objectStore(QUEUE_STORE);
const out = {};
const req = store.get(id);
req.onsuccess = () => {
const it = req.result;
if (!it || it.failed || it.type !== 'photo') return;
const now = Date.now();
if (it.uploading_since && it.uploading_by !== SW_OWNER
&& (now - it.uploading_since) < LEASE_MS) { out.busy = true; return; }
it.uploading_since = now;
it.uploading_by = SW_OWNER;
store.put(it);
out.item = it;
};
tx.oncomplete = () => resolve(out);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
function queueRelease(db, id) {
return new Promise((resolve) => {
const tx = db.transaction(QUEUE_STORE, 'readwrite');
const store = tx.objectStore(QUEUE_STORE);
const req = store.get(id);
req.onsuccess = () => {
const it = req.result;
if (!it || it.uploading_by !== SW_OWNER) return;
delete it.uploading_since;
delete it.uploading_by;
store.put(it);
};
tx.oncomplete = () => resolve();
tx.onerror = () => resolve(); // Freigeben darf nie den Lauf kippen — die Lease laeuft ohnehin ab
tx.onabort = () => resolve();
});
}
function queueRemove(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction(QUEUE_STORE, 'readwrite');
tx.objectStore(QUEUE_STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function tellClients(msg) {
const list = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' });
list.forEach(c => { try { c.postMessage(msg); } catch (_) {} });
}
/* Ein Foto hochladen. Rückgabe: die Serverantwort bei bestätigtem Upload,
* sonst wird geworfen — der Aufrufer lässt den Eintrag dann in der Queue. */
async function uploadQueued(item) {
const blob = new Blob([item.data], { type: item.mime || 'image/jpeg' });
const fd = new FormData();
fd.append('file', blob, item.filename || 'foto.jpg');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 60000); // haengende Verbindung nicht ewig halten
let r;
try {
r = await fetch(UPLOAD_BASE + '?id=' + encodeURIComponent(item.order_id) + '&action=upload_photo', {
method: 'POST',
body: fd,
credentials: 'same-origin',
signal: ctrl.signal,
});
} finally {
clearTimeout(timer);
}
if (!r.ok) throw new Error('HTTP ' + r.status);
// 2xx mit HTML-Body heisst: Proxy oder abgelaufene Anmeldung, NICHT gespeichert.
const ct = (r.headers.get('content-type') || '').toLowerCase();
if (ct.indexOf('application/json') === -1) throw new Error('Antwort ist kein JSON');
const data = await r.json();
if (!data || typeof data.relpath !== 'string' || data.relpath === '') {
throw new Error('Upload nicht bestaetigt (kein relpath)');
}
return data;
}
async function drainQueue() {
const db = await queueDb();
const ids = await queueIds(db);
if (!ids.length) return;
let left = 0;
for (const id of ids) {
const claim = await queueClaim(db, id);
if (claim.busy) { left++; continue; } // die Seite ist dran — zaehlt als offen, damit
if (!claim.item) continue; // der Browser nachfasst, falls sie es nicht schafft
const it = claim.item;
try {
const res = await uploadQueued(it);
await queueRemove(db, it.id);
await tellClients({
type: 'photo-uploaded',
queueId: it.id,
relpath: res.relpath,
orderId: it.order_id,
filename: it.filename,
duplicate: !!res.duplicate,
});
} catch (_) {
left++; // bleibt in der Queue, naechster Sync versucht es erneut
await queueRelease(db, it.id); // sofort freigeben, sonst wartet die Seite 90 s
}
}
// Noch etwas offen? Ablehnen, damit der Browser erneut zustellt.
if (left) throw new Error(left + ' Foto(s) weiterhin offen');
}
self.addEventListener('sync', (e) => {
if (e.tag === 'photo-queue') e.waitUntil(drainQueue());
});
/* Periodic Background Sync, falls der Browser ihn gewährt (installierte PWA,
* Chrome/Android). Fängt den Fall ab, dass gar kein sync-Ereignis mehr kommt. */
self.addEventListener('periodicsync', (e) => {
if (e.tag === 'photo-queue') e.waitUntil(drainQueue().catch(() => {}));
});