/* Baustelle PWA Service Worker * Pattern nach claude-db #201 (filemtime-basiert): * - Cache-Version kommt aus URL-Query ?v=, 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= — 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= 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( '' + '' + 'Baustelle — offline' + '' + '
\u{1F4F4}
' + '

Keine Verbindung

' + '

Die App konnte nicht geladen werden.
' + 'Sobald wieder Netz da ist, erneut \u00f6ffnen.

', { 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= 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(() => {})); });