All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
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>
294 lines
11 KiB
JavaScript
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(() => {}));
|
|
});
|