baustelle-pwa/sw.js
Eddy c2bf4e7c30
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
Background Sync, volle Kameraqualitaet, Persist-First streng [deploy]
Drei Sachen, die zusammengehoeren — alle drei Ursachen dafuer, dass Fotos
fehlten oder nichts drauf zu erkennen war.

1. BACKGROUND SYNC (sw.js)
   Nachliefern lief bisher NUR in der offenen Seite: online-Ereignis,
   „wieder sichtbar", 15-Sekunden-Takt. Display aus oder App weggewischt =
   eingefrorene Timer, es passierte nichts. Der Service Worker haengt sich
   jetzt an dieselbe IndexedDB-Queue und laedt selbst hoch; die Anmeldung
   steckt im HttpOnly-Cookie awl_sso und geht bei same-origin automatisch mit,
   also braucht es dort kein Token-Handling.
   - geloescht wird nur bei bestaetigtem Upload: 2xx UND application/json UND
     relpath. 2xx mit HTML (Proxy/abgelaufene Anmeldung) gilt als Fehlschlag
   - bleibt etwas offen, wird das Sync-Versprechen abgelehnt -> der Browser
     stellt erneut zu
   - Erfolg meldet der SW per postMessage an die App (Badge/Miniaturen)
   - periodicSync zusaetzlich, falls der Browser ihn gewaehrt

2. VOLLE KAMERAQUALITAET (app.js)
   Ausgeloest wird jetzt ueber ImageCapture.takePhoto() — das liefert die
   native JPEG-Datei der Kamera in Sensoraufloesung samt EXIF, statt ein
   Standbild aus dem Video-Stream abzugreifen. Kann der Browser das nicht,
   bleibt das Standbild als Rueckfallebene (dann mit Qualitaet 0.95 statt 0.92).
   Verkleinert wird nur noch, wenn es im Admin eingestellt ist (Vorgabe: nein).

3. PERSIST-FIRST STRENG (app.js)
   Das Foto geht unveraendert und als allererstes in die Queue; Verkleinern
   passiert erst DANACH und ersetzt den Eintrag nur bei Erfolg
   (offline.replaceQueuedBlob). Vorher lag das Verkleinern davor — und weil es
   bei ausgeschaltetem Display haengen bleibt, war das Foto in dem Moment
   nirgends gesichert. Genau so gingen am 28.08. acht Fotos verloren. Jetzt
   kann nach dem Sichern nichts mehr passieren, was ein Foto kostet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 19:07:06 +02:00

294 lines
11 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 (r && r.ok) c.put(request, r.clone()).then(trimMediaCache);
}).catch(() => null);
return hit;
}
try {
const r = await fetch(request);
if (r && r.ok) { 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: POST auf share.html abfangen */
async function handleShareTarget(request) {
const fd = await request.formData();
let files = [];
for (const key of ['photos', 'file', 'files', 'image', 'images']) {
const v = fd.getAll(key);
if (v && v.length) files = files.concat(v);
}
// Fallback: alle File-Entries im FormData durchsuchen
if (!files.length) {
for (const [, v] of fd.entries()) {
if (v && typeof v === 'object' && v.size !== undefined) files.push(v);
}
}
if (files.length) {
const db = await new Promise((res, rej) => {
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 = () => res(req.result);
req.onerror = () => rej(req.error);
});
const tx = db.transaction('kv', 'readwrite');
tx.objectStore('kv').put(
files.map(f => ({ name: f.name || 'photo.jpg', type: f.type || 'image/jpeg', data: f })),
'shared_files'
);
await new Promise(res => { tx.oncomplete = res; });
}
return Response.redirect('./share.html', 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(
fetch(e.request)
.then(response => {
// Cache aktualisieren (nur erfolgreiche responses)
if (response && response.ok) {
const clone = response.clone();
caches.open(CACHE).then(c => c.put(e.request, clone)).catch(() => null);
}
return response;
})
.catch(() => caches.match(e.request))
);
return;
}
});
/* 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));
}
});
/* ==========================================================================
* 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(() => {}));
});