Gemeldet: die App zeigte beim Oeffnen nur "Failed to fetch", waehrend das
Teilen aus WhatsApp weiter ging. Deploy und API waren in Ordnung — die
Meldung ist die rohe Browserausgabe eines fetch(), das gar nicht bis zum
Server kam, und stand 1:1 im UI, weil die Routen im catch e.message
anzeigten. Das Teilen fiel nicht auf, weil share.html sich die letzten
Auftraege seit eaad796 lokal merkt; die Hauptapp hatte fuer die Listen
keinen Offline-Fallback.
- api.js uebersetzt Netzfehler in einen eigenen Fehler mit offline=true
und deutschem Text; 4xx/5xx bleiben Serverfehler
- isTransient() in offline.js erkannte Netzfehler an "instanceof TypeError".
Ohne Anpassung waere ein Foto aus dem Funkloch als dauerhaft
fehlgeschlagen gewertet und nach sechs Versuchen in Quarantaene gelandet
- Datenspiegel in IndexedDB: Auftrags-, Kunden- und Berichtsliste sowie
geoeffnete Details (letzte 30). Ohne Netz wird der Stand angezeigt,
mit Band "Kein Netz — Stand von HH:MM Uhr", und die Suche filtert darin
- beim Abmelden wird der Spiegel geloescht (geteiltes Geraet)
- sw.js: der Same-Origin-Zweig endete auf caches.match(); fand der Cache
nichts, wurde respondWith(undefined) aufgerufen und die Anfrage
scheiterte erneut mit genau der Meldung, die verhindert werden sollte.
Jetzt immer eine echte Response, nur vollstaendige Antworten im Cache
- Precache nach Deploy: die Seite meldet dem Worker ihre echten Asset-URLs,
sonst ist der Cache direkt nach einem Deploy leer (?v=<mtime>)
Muster aus KB #1037 und #1041. Verifiziert auf der lokalen Testinstanz
inklusive echtem Funkloch (setOffline + reload): App startet aus dem
Cache, zeigt Band und Auftragsliste, keine JS-Fehler.
385 lines
16 KiB
JavaScript
385 lines
16 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 queueRead(db) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(QUEUE_STORE, 'readonly');
|
|
const req = tx.objectStore(QUEUE_STORE).getAll();
|
|
req.onsuccess = () => resolve(req.result || []);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
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 items = (await queueRead(db)).filter(i => i.type === 'photo' && !i.failed);
|
|
if (!items.length) return;
|
|
|
|
let left = 0;
|
|
for (const it of items) {
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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(() => {}));
|
|
});
|