diff --git a/README.md b/README.md index 7c28ef7..6662806 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und - ✅ **Persist-First / Datenverlust-Schutz**: jedes Foto wird beim Auslösen ZUERST in IndexedDB gesichert, Upload erst danach; Queue-Item wird nur nach bestätigtem Upload (HTTP 2xx mit gültigem `relpath`) gelöscht — überlebt fehlendes/schwaches Netz, hängende Uploads (45s-Timeout) und App-Kill. Erkennt 2xx-HTML (abgelaufene Session/Proxy-Loginseite) als Fehler statt als Erfolg - ✅ Auto-Sync bei "online", periodisch (15s) und bei App-Fokus; Status-Badge (🟢 alles gesichert / 🟡 lädt hoch / 🔴 offline / ⚠️ fehlgeschlagen) - ✅ Recovery: Tipp auf das Status-Badge öffnet die Warteschlange → erneut senden / teilen; Warnung beim Schließen mit noch ungesicherten Fotos +- ✅ **Seite und Service Worker laden nichts doppelt hoch**: wer ein Foto sendet, belegt es in IndexedDB (Lease, 90 s); der andere überspringt es und übernimmt erst nach Ablauf. Beim Verkleinern bleibt das Foto belegt, bis die kleine Fassung in der Queue liegt — sonst gingen Original **und** kleine Fassung raus. Logik steht zweimal (`lib/idb.js` + `sw.js`), immer zusammen ändern +- ✅ **App-Update lädt nicht mitten in der Arbeit neu**: nach einem Deploy wartet der Reload, bis kein Dialog (Kamera, Notiz, Unterschrift) offen ist, nichts getippt wird und kein Upload läuft (`window.appBusy()`); der eigene Reload löst keinen Browser-Dialog aus - ✅ **Wartende Fotos stehen am Auftrag**: Block „⏳ Warten auf Upload (n)" mit Vorschaubildern aus der Warteschlange und Klartext (gesichert auf dem Gerät, geht raus sobald Netz da ist); nach dem Upload zieht die Auftragsseite von selbst nach. Hinweis-Toast beim Schließen der Kamera, wenn noch etwas wartet - ✅ Kacheln laden **serverseitige Thumbnails** (`photo.php?size=thumb`) statt der Originale — rund 10 KB statt mehrerer hundert KB je Kachel, parallel und mit `loading="lazy"`, Wiederholaufrufe enden mit 304 - ✅ Foto-Viewer mit Zoom + Swipe diff --git a/ROADMAP.md b/ROADMAP.md index 95dda82..bf4c6f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -295,18 +295,80 @@ fertig gewordenen Upload nicht neu. Verloren ging nichts — alle Fotos kamen an - **Nicht** getestet: die echte Kamera (`getUserMedia`) am Handy — der Test hat den Kamera-Cleanup nachgestellt, nicht die Kamera selbst bedient. -### Offen +### 6.1 Doppel-Uploads: Seite und Service Worker laden dasselbe Foto hoch -- [ ] **Doppel-Uploads** (Log: mehrere Antworten mit `duplicate:true`) — Seite und Service - Worker (Background Sync) laden dasselbe Queue-Item parallel hoch. Dank md5-Abgleich - seit Bericht 1.6.0 folgenlos, kostet aber im Mobilfunk doppeltes Datenvolumen. - Lösung: Sperre je Item in IndexedDB (`uploading_since`), die der jeweils andere achtet. -- [ ] **SW-Update lädt mitten in der Arbeit neu** — beim Test beobachtet: `controllerchange` - → `location.reload()` (index.php) feuerte, während drei Fotos in der Warteschlange - lagen; der Browser zeigte dazu seinen eigenen „Seite verlassen?"-Dialog (`beforeunload`). - War am 17.09. **nicht** die Ursache (kein Deploy, kein `index.php` im Log), trifft aber - jeden, der direkt nach einem Deploy fotografiert. Lösung: Reload aufschieben, solange - ein Modal (Kamera!) offen ist oder die Warteschlange nicht leer ist. +Log 17.09.: mehrere Antworten mit `duplicate:true`. `syncQueue()` (Seite) und `drainQueue()` +(Service Worker, Background Sync) lesen dieselbe IndexedDB-Queue und wissen nichts +voneinander. Dank md5-Abgleich (Bericht 1.6.0) folgenlos — kostet aber im Mobilfunk das +doppelte Datenvolumen, bei 8-MB-Originalen spürbar. + +**Schlimmer, bisher unbemerkt:** Ist im Admin ein Verkleinern eingestellt, sichert die Kamera +erst das Original und ersetzt es danach durch die kleine Fassung. Greift der Service Worker +dazwischen zu, lädt er das **Original** hoch, die Seite danach die **kleine Fassung** — +unterschiedlicher Inhalt, der md5-Abgleich greift nicht, es liegen **zwei Dateien** im Auftrag. + +Umgesetzt (2026-09-18) — Lease je Item in IndexedDB (kein Web Lock: ein eingefrorener Tab +hielte den ewig, und genau für den eingefrorenen Tab gibt es den Background Sync): + +- [x] `idb.queueKeys()` / `queuePatch(id, fn)` / `queueClaim(id, owner, leaseMs)` / + `queueRelease()` — Lesen-Ändern-Schreiben in **einer** readwrite-Transaktion (über + Kontexte hinweg atomar). Gegenstück in `sw.js` (`queueClaim`/`queueRelease`) — + **beide Stellen zusammen ändern**, der Worker kann `lib/idb.js` nicht laden (`window`). +- [x] `syncQueue()` und `drainQueue()` holen sich jedes Item per Claim; ist es frisch vom + anderen belegt, wird es übersprungen (im Worker zählt es als „offen", damit der + Browser nachfasst). Lease 90 s (> längster Upload-Timeout 60 s), danach darf der + andere übernehmen. Besitzer je Seiten-/Worker-Instanz eindeutig (`page-…` / `sw-…`). + Nebeneffekt: es liegt nur noch **ein** Foto gleichzeitig im Speicher statt aller. +- [x] Fehlschlag gibt die Lease sofort frei; Versuchszähler/Quarantäne per Patch statt + „ganzes Item zurückschreiben" (überschrieb bisher eine parallele Änderung). Ebenso + `retryFailed()`. +- [x] `enqueuePhoto(…, {hold:true})` + `releaseHold()`: soll verkleinert werden, kommt das + Foto **mit** Lease (`page-…:resize`) in die Queue; freigegeben wird im `finally` nach + dem Verkleinern. Vergisst es jemand oder stirbt die Seite: Ablauf nach 90 s. +- [x] `replaceQueuedBlob()` ersetzt per Patch + +### 6.2 SW-Update lädt mitten in der Arbeit neu + +Beim Test beobachtet: `controllerchange` → `location.reload()` (index.php, Muster KB #201) +feuerte, während drei Fotos in der Warteschlange lagen; der Browser zeigte dazu seinen +eigenen „Seite verlassen?"-Dialog (`beforeunload`). War am 17.09. **nicht** die Ursache (kein +Deploy, kein `index.php` im Log), trifft aber jeden, der nach einem Deploy arbeitet: offene +Kamera weg, getippte Notiz weg, halbe Unterschrift weg. + +- [x] `window.appBusy()` in app.js: Modal offen, Mehrfachauswahl, laufender Upload + (`offline.isSyncing()`), Eingabefeld im Fokus, laufende Sprachnotiz. **Nicht** + beschäftigt: PIN-Sperre — dort ist der beste Moment zum Neuladen, sonst kommt die + PIN-Abfrage zweimal. Suchfelder zählen nicht (behalten am Handy den Fokus, der Reload + käme nie). +- [x] index.php: Reload nur, wenn nicht beschäftigt; sonst alle 3 s und bei jedem + Sichtbarkeitswechsel erneut prüfen +- [x] Eigener Reload setzt `window.__selfReload` → `beforeunload` warnt dann nicht (die Fotos + liegen in IndexedDB, ein Reload verliert nichts). Die Warnung beim echten Schließen bleibt. + +### Verifiziert 6.1 + 6.2 (2026-09-18, lokale Testinstanz, Chromium, Auftrag 62) + +- **Echte Kamera-Oberfläche** mit nachgebildetem Kamerabild (`canvas.captureStream()` statt + Hardware), offline: 3× Auslöser, „Fertig" → bleibt im Auftrag, Toast „📴 Kein Netz — 3 Fotos + auf dem Gerät gesichert, Upload folgt automatisch", Block mit 3 Vorschaubildern, Badge 🔴 3 +- **Gleichzeitiger Zugriff:** Netz an, Seite und Service Worker greifen parallel zu — der + Worker lud Foto 4 und 6, die Seite Foto 5. Serverlog: **genau 3** `upload_photo`-POSTs für + 3 Fotos, keine Antwort mit `duplicate:true` +- **Verkleinern:** während des Verkleinerns ist das Item von `page-…:resize` belegt; auf dem + Server liegt **eine** Datei mit 41.832 Bytes (die kleine Fassung), nicht das 208-KB-Original +- **Fremde Lease:** frisch → wird respektiert (kein Upload, bleibt in der Queue); + künstlich abgelaufen → wird übernommen und hochgeladen +- **Reload:** `controllerchange` bei offenem Notiz-Dialog → 4,5 s kein Reload; Dialog zu → + Reload nach 0,7 s, gleicher Auftrag, **kein Browser-Dialog**, obwohl ein Foto in der + Warteschlange lag. Konsole ohne Fehler. Testdaten danach wieder entfernt. +- **Nicht** getestet: echte Kamera-Hardware und echter Background Sync bei ausgeschaltetem + Display am Handy — beides geht nur auf dem Gerät. + +### Offen (beobachtet, nicht Teil dieser Runde) + +- [ ] `idb.queueAll()` lädt **alle** Foto-Blobs in den Speicher — auch `updateBadge()`, das + nur zählen will. Bei 20 Originalen à 8 MB sind das 160 MB je Badge-Update. Braucht ein + Zähl-Verfahren ohne Werte (Schlüssel + kleiner Meta-Store); Schema-Änderung, weil + `sw.js`/`share.html` die DB mit fester Version 1 öffnen. ## Erledigt diff --git a/app.js b/app.js index 5d15b18..73386c2 100644 --- a/app.js +++ b/app.js @@ -111,6 +111,26 @@ function closeModal(el) { try { entry.cleanup && entry.cleanup(); } catch {} } +/* Darf die Seite JETZT neu laden? Gefragt von index.php, wenn nach einem Deploy ein neuer + * Service Worker uebernimmt. Bis 18.09.2026 lud sie dann sofort neu — mitten in der offenen + * Kamera, in der getippten Notiz, in der halben Unterschrift. Alles davon ist danach weg. + * + * Ausdruecklich NICHT beschaeftigt ist die PIN-Sperre: dort ist der beste Moment zum + * Neuladen. Wuerde sie als Modal zaehlen, kaeme der Reload direkt NACH der PIN-Eingabe — + * und die PIN-Abfrage gleich ein zweites Mal. */ +window.appBusy = function () { + const app = document.getElementById('app'); + if (app && app.style.visibility === 'hidden') return false; // PIN-Sperre + if (modalStack.length > 0 || selectModeCleanup) return true; // Kamera, Notiz, Unterschrift, Auswahl + try { if (offline.isSyncing()) return true; } catch (_) {} // halb gesendeter Upload + // Es wird gerade getippt. Suchfelder zaehlen nicht: am Handy behalten sie den Fokus, bis + // man die Seite verlaesst — der Reload kaeme nie, und ein Suchwort ist kein Verlust. + const a = document.activeElement; + if (a && /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) && a.type !== 'search') return true; + if (Array.from(document.querySelectorAll('audio')).some(x => !x.paused)) return true; + return false; +}; + function isTopLevelHash(h) { const hh = (h || '').replace(/^#/, '').replace(/\/$/, '') || '/'; return ['/', '/orders', '/today', '/customers', '/reports', '/settings'].includes(hh); @@ -1230,17 +1250,22 @@ async function uploadPhoto(orderId, file) { // gingen am 28.08.2026 acht Fotos verloren. Jetzt kann nach dem Sichern nichts // mehr passieren, was das Foto kostet. const name = file.name || ('foto_' + Date.now() + '.jpg'); - const qid = await offline.enqueuePhoto(orderId, file, name); + // Wird gleich verkleinert, kommt das Foto BELEGT in die Queue (hold) — sonst laedt der + // Service Worker das Original hoch, waehrend hier die kleine Fassung entsteht, und im + // Auftrag liegen zwei verschiedene Dateien. + const willShrink = photoCfg.photo_maxside > 0; + const qid = await offline.enqueuePhoto(orderId, file, name, { hold: willShrink }); // Verkleinern ist ab hier reine Kuer: nur wenn im Admin eingestellt, und wenn es // schiefgeht oder haengt, bleibt schlicht das Original in der Queue. - if (photoCfg.photo_maxside > 0) { + if (willShrink) { try { const small = await resizeImage(file, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100); if (small && small !== file && small.size < file.size) { await offline.replaceQueuedBlob(qid, small); } } catch (_) { /* Original bleibt gesichert */ } + finally { await offline.releaseHold(qid); } } if (navigator.onLine) showToast('Sende ' + name + '…'); @@ -3647,12 +3672,14 @@ async function openCameraModal(orderId) { try { // Erst sichern, dann erst ueber Verkleinern nachdenken (siehe uploadPhoto) const name = 'foto_' + Date.now() + '_' + shots + '.jpg'; - const qid = await offline.enqueuePhoto(orderId, raw, name); - if (photoCfg.photo_maxside > 0) { + const willShrink = photoCfg.photo_maxside > 0; + const qid = await offline.enqueuePhoto(orderId, raw, name, { hold: willShrink }); + if (willShrink) { try { const small = await resizeImage(raw, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100); if (small && small !== raw && small.size < raw.size) await offline.replaceQueuedBlob(qid, small); } catch (_) { /* Original bleibt gesichert */ } + finally { await offline.releaseHold(qid); } } thumb.dataset.qid = qid; thumb.classList.remove('saving'); diff --git a/index.php b/index.php index 1573f16..204ec09 100644 --- a/index.php +++ b/index.php @@ -156,13 +156,28 @@ if ('serviceWorker' in navigator) { ziel.postMessage({ type: 'PRECACHE', urls: urls }); }).catch(function () {}); - // Controller-Change → einmal neu laden - var reloaded = false; - navigator.serviceWorker.addEventListener('controllerchange', function () { - if (reloaded) return; + /* Controller-Change → einmal neu laden — aber nicht mitten in der Arbeit. + Frueher kam der Reload sofort: offene Kamera zu, getippte Notiz weg, halbe + Unterschrift weg (und lagen noch Fotos in der Warteschlange, zeigte der Browser + dazu seinen eigenen "Seite verlassen?"-Dialog). Jetzt fragt die Seite app.js + (window.appBusy) und holt den Reload nach, sobald nichts mehr offen ist — + spaetestens, wenn die App in den Hintergrund geht und dabei nichts offen ist. */ + var reloadWanted = false, reloaded = false; + function tryReload() { + if (!reloadWanted || reloaded) return; + var busy = false; + try { busy = (typeof window.appBusy === 'function') && window.appBusy(); } catch (_) {} + if (busy) return; reloaded = true; + window.__selfReload = true; // offline.js: eigener Reload, keine beforeunload-Warnung window.location.reload(); + } + navigator.serviceWorker.addEventListener('controllerchange', function () { + reloadWanted = true; + tryReload(); }); + setInterval(tryReload, 3000); + document.addEventListener('visibilitychange', tryReload); }); } diff --git a/lib/idb.js b/lib/idb.js index daf8deb..21aaae1 100644 --- a/lib/idb.js +++ b/lib/idb.js @@ -107,5 +107,68 @@ }); } - window.idb = { get, set, del, keys, queuePush, queueAll, queueDelete, queueUpdate }; + // Nur die Ids — wer die Queue abarbeitet, holt die Eintraege danach einzeln. queueAll() + // zieht dagegen jedes Foto komplett in den Speicher. + async function queueKeys() { + const db = await open(); + return new Promise((res, rej) => { + const tx = db.transaction('queue', 'readonly'); + const r = tx.objectStore('queue').getAllKeys(); + r.onsuccess = () => res(r.result || []); + r.onerror = () => rej(r.error); + }); + } + + /* Lesen-Aendern-Schreiben in EINER Transaktion. IndexedDB reiht readwrite-Transaktionen + * auf demselben Store ueber alle Kontexte hinweg hintereinander — Seite und Service + * Worker koennen sich hier also nicht ins Wort fallen. `fn(item)` aendert den Eintrag an + * Ort und Stelle; gibt sie `false` zurueck, wird NICHT geschrieben. + * Rueckgabe: der Eintrag nach der Aenderung, oder null (weg bzw. nicht geschrieben). */ + async function queuePatch(id, fn) { + const db = await open(); + return new Promise((res, rej) => { + const tx = db.transaction('queue', 'readwrite'); + const store = tx.objectStore('queue'); + let out = null; + const r = store.get(id); + r.onsuccess = () => { + const it = r.result; + if (!it) return; // inzwischen hochgeladen und geloescht + if (fn(it) === false) return; + store.put(it); + out = it; + }; + tx.oncomplete = () => res(out); + tx.onerror = () => rej(tx.error); + tx.onabort = () => rej(tx.error); + }); + } + + /* Einen Eintrag fuer den Upload belegen (Lease). Seite und Service Worker arbeiten + * dieselbe Queue ab; ohne Absprache laden beide dasselbe Foto hoch (Prod-Log 17.09.2026: + * reihenweise duplicate:true). Belegt wird nur, was frei ist oder dessen Lease abgelaufen + * ist — eine eingefrorene oder abgeschossene Seite blockiert ihr Foto also nicht ewig. + * DIESELBE LOGIK STEHT IN sw.js (queueClaim) — beide Stellen zusammen aendern. + * Rueckgabe: der belegte Eintrag, oder null (weg, fehlgeschlagen-markiert oder belegt). */ + function queueClaim(id, owner, leaseMs) { + const now = Date.now(); + return queuePatch(id, (it) => { + if (it.failed) return false; + if (it.uploading_since && it.uploading_by !== owner + && (now - it.uploading_since) < leaseMs) return false; + it.uploading_since = now; + it.uploading_by = owner; + }); + } + + function queueRelease(id, extra) { + return queuePatch(id, (it) => { + delete it.uploading_since; + delete it.uploading_by; + if (extra) extra(it); + }); + } + + window.idb = { get, set, del, keys, queuePush, queueAll, queueDelete, queueUpdate, + queueKeys, queuePatch, queueClaim, queueRelease }; })(); diff --git a/lib/offline.js b/lib/offline.js index ea318f8..c578113 100644 --- a/lib/offline.js +++ b/lib/offline.js @@ -14,10 +14,26 @@ let lastUnsynced = 0; // ALLE noch nicht bestätigten Items (für Beforeunload-Warnung) const MAX_ATTEMPTS = 6; // nach so vielen Dauerfehlern → Quarantäne (blockiert Queue nicht) - async function enqueuePhoto(orderId, fileBlob, filename) { + /* Wer ein Foto gerade hochlaedt, belegt es (idb.queueClaim). Die Seite und der Service + * Worker (Background Sync) arbeiten dieselbe Queue ab — ohne Absprache luden beide + * dasselbe Foto hoch (Prod-Log 17.09.2026: reihenweise duplicate:true, im Mobilfunk das + * doppelte Datenvolumen). Die Lease muss laenger halten als der laengste Upload-Timeout + * (Seite 45 s, Service Worker 60 s), sonst uebernimmt der andere einen LAUFENDEN Upload. + * Nach Ablauf darf er uebernehmen — eine eingefrorene Seite blockiert ihr Foto nicht ewig. + * Der Besitzer ist je Seiten-Instanz eindeutig: zwei offene Fenster sind zwei Besitzer. */ + const OWNER = 'page-' + Math.random().toString(36).slice(2, 10); + const HOLD_OWNER = OWNER + ':resize'; + const LEASE_MS = 90000; + + /* opts.hold: Das Foto kommt BELEGT in die Queue. Noetig, wenn es gleich noch verkleinert + * wird — sonst greift sich der Service Worker das Original, waehrend die Seite die kleine + * Fassung nachschiebt, und im Auftrag liegen zwei verschiedene Dateien (der md5-Abgleich + * des Servers erkennt sie nicht als Duplikat). Der Aufrufer MUSS danach releaseHold() + * rufen; vergisst er es oder stirbt die Seite, laeuft die Lease nach 90 s von selbst ab. */ + async function enqueuePhoto(orderId, fileBlob, filename, opts) { // Blob → ArrayBuffer für IndexedDB-Speicherung const buf = await fileBlob.arrayBuffer(); - const id = await idb.queuePush({ + const item = { type: 'photo', order_id: orderId, filename, @@ -26,24 +42,40 @@ attempts: 0, failed: false, created: Date.now(), - }); + }; + if (opts && opts.hold) { + item.uploading_since = Date.now(); + item.uploading_by = HOLD_OWNER; + } + const id = await idb.queuePush(item); await updateBadge(); emitChange(); registerBackgroundSync(); // auch ohne offene App nachliefern return id; } + async function releaseHold(id) { + try { + await idb.queuePatch(id, (it) => { + if (it.uploading_by !== HOLD_OWNER) return false; // laengst von jemandem uebernommen + delete it.uploading_since; + delete it.uploading_by; + }); + } catch (_) {} + } + // Blob eines bereits gesicherten Queue-Items ersetzen. Gebraucht fuer das // Verkleinern: gesichert wird zuerst das Original, die kleinere Fassung ersetzt // es erst danach. Klappt das Verkleinern nicht, bleibt das Original — nie ein Loch. + // Per Patch in einer Transaktion: das fruehere "alles lesen, ganzes Item zurueckschreiben" + // ueberschrieb, was ein anderer in der Zwischenzeit am Eintrag geaendert hatte. async function replaceQueuedBlob(id, fileBlob) { - const items = await idb.queueAll().catch(() => []); - const it = items.find(x => x.id === id); - if (!it) return false; // schon hochgeladen und geloescht - it.data = await fileBlob.arrayBuffer(); - it.mime = fileBlob.type || it.mime || 'image/jpeg'; - await idb.queueUpdate(it); - return true; + const buf = await fileBlob.arrayBuffer(); + const it = await idb.queuePatch(id, (x) => { + x.data = buf; + x.mime = fileBlob.type || x.mime || 'image/jpeg'; + }); + return !!it; // null: schon hochgeladen und geloescht } // Background Sync: der Browser holt die Queue auch dann nach, wenn die App gar @@ -140,10 +172,16 @@ try { do { syncAgain = false; - const items = await idb.queueAll(); + // Nur die Ids holen und jedes Foto einzeln belegen: so liegt immer nur EIN + // Foto im Speicher, und gelesen wird der Stand zum Zeitpunkt des Uploads + // (also die verkleinerte Fassung, falls sie inzwischen da ist). + const ids = await idb.queueKeys(); let stoppedByNetwork = false; - for (const it of items) { - if (it.failed) continue; // quarantänisiert: behalten, überspringen + for (const id of ids) { + // null: schon weg, in Quarantaene — oder gerade vom Service Worker bzw. + // vom Verkleinern belegt. Dann nicht anfassen, der naechste Lauf sieht es wieder. + const it = await idb.queueClaim(id, OWNER, LEASE_MS); + if (!it) continue; if (it.type !== 'photo') { await idb.queueDelete(it.id); continue; } try { const blob = new Blob([it.data], { type: it.mime || 'image/jpeg' }); @@ -163,16 +201,22 @@ } catch (e) { console.warn('[Sync] Item ' + it.id + ' fehlgeschlagen:', e); if (isTransient(e)) { - // Netzproblem → abbrechen; ALLES bleibt erhalten, nächster Versuch später + // Netzproblem → abbrechen; ALLES bleibt erhalten, nächster Versuch später. + // Lease sofort freigeben, sonst wartet der Service Worker 90 s auf nichts. + try { await idb.queueRelease(it.id); } catch (_) {} stoppedByNetwork = true; break; } // Dauerhafter Fehler → Versuchszähler hoch, ggf. Quarantäne, // aber weiter mit den nächsten Items (ein „Poison-Item" blockiert nicht mehr die Queue). - it.attempts = (it.attempts || 0) + 1; - it.last_error = (e && e.message) || 'Fehler'; - if (it.attempts >= MAX_ATTEMPTS) it.failed = true; - try { await idb.queueUpdate(it); } catch (_) {} + const msg = (e && e.message) || 'Fehler'; + try { + await idb.queueRelease(it.id, (x) => { + x.attempts = (x.attempts || 0) + 1; + x.last_error = msg; + if (x.attempts >= MAX_ATTEMPTS) x.failed = true; + }); + } catch (_) {} } } if (stoppedByNetwork) break; @@ -196,14 +240,16 @@ // Quarantänisierte Items reaktivieren und erneut versuchen async function retryFailed() { - const items = await idb.queueAll().catch(() => []); - for (const it of items) { - if (it.failed) { - it.failed = false; - it.attempts = 0; - delete it.last_error; - try { await idb.queueUpdate(it); } catch (_) {} - } + const ids = await idb.queueKeys().catch(() => []); + for (const id of ids) { + try { + await idb.queuePatch(id, (it) => { + if (!it.failed) return false; + it.failed = false; + it.attempts = 0; + delete it.last_error; + }); + } catch (_) {} } await updateBadge(); emitChange(); @@ -254,7 +300,10 @@ // Warnung, wenn die App mit noch nicht hochgeladenen Fotos geschlossen/neu geladen wird. // Feuert in einer Hash-Router-SPA NICHT bei normaler In-App-Navigation, nur beim echten Verlassen. // lastUnsynced schließt quarantänisierte (failed) Fotos ein — die sind erst recht gefährdet. + // Nicht beim eigenen Reload nach einem App-Update (index.php setzt __selfReload): die Fotos + // liegen in IndexedDB, ein Reload verliert nichts — der Browserdialog waere nur Laerm. window.addEventListener('beforeunload', (e) => { + if (window.__selfReload) return; if (lastUnsynced > 0) { e.preventDefault(); e.returnValue = ''; return ''; } }); @@ -341,7 +390,12 @@ return all.length; } - window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed, + // Laeuft gerade ein Upload? Daran haengt u.a., ob die Seite nach einem App-Update neu + // laden darf (index.php) — ein Reload risse einen halb gesendeten 8-MB-Upload ab. + function isSyncing() { return syncing; } + + window.offline = { enqueuePhoto, releaseHold, syncQueue, isSyncing, updateBadge, queueCount, + listQueue, retryFailed, acquireWakeLock, releaseWakeLock, replaceQueuedBlob, registerBackgroundSync, mirrorSave, mirrorLoad, mirrorClear }; })(); diff --git a/sw.js b/sw.js index 3afffd2..ab413f2 100644 --- a/sw.js +++ b/sw.js @@ -290,15 +290,71 @@ function queueDb() { }); } -function queueRead(db) { +function queueIds(db) { return new Promise((resolve, reject) => { const tx = db.transaction(QUEUE_STORE, 'readonly'); - const req = tx.objectStore(QUEUE_STORE).getAll(); + 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'); @@ -349,11 +405,15 @@ async function uploadQueued(item) { async function drainQueue() { const db = await queueDb(); - const items = (await queueRead(db)).filter(i => i.type === 'photo' && !i.failed); - if (!items.length) return; + const ids = await queueIds(db); + if (!ids.length) return; let left = 0; - for (const it of items) { + 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); @@ -367,6 +427,7 @@ async function drainQueue() { }); } catch (_) { left++; // bleibt in der Queue, naechster Sync versucht es erneut + await queueRelease(db, it.id); // sofort freigeben, sonst wartet die Seite 90 s } }