Keine Doppel-Uploads mehr, App-Update laedt nicht mitten in der Arbeit neu
Doppel-Uploads (Prod-Log 17.09.: reihenweise duplicate:true): Seite
(syncQueue) und Service Worker (Background Sync) arbeiteten dieselbe
IndexedDB-Queue ab, ohne voneinander zu wissen. Dank md5-Abgleich
folgenlos, aber doppeltes Datenvolumen. Mit eingestelltem Verkleinern
sogar zwei verschiedene Dateien im Auftrag: der Worker lud das
Original, die Seite danach die kleine Fassung.
- idb.js: queueKeys/queuePatch/queueClaim/queueRelease - Lesen-Aendern-
Schreiben in einer readwrite-Transaktion, ueber Kontexte atomar
- syncQueue (Seite) und drainQueue (Worker) belegen jedes Foto vor dem
Upload (Lease 90 s > laengster Upload-Timeout); belegt = ueberspringen,
abgelaufen = uebernehmen. Nur noch ein Foto gleichzeitig im Speicher
- enqueuePhoto({hold:true}) + releaseHold: waehrend des Verkleinerns
bleibt das Foto belegt
- Versuchszaehler, retryFailed und replaceQueuedBlob per Patch statt
ganzes Item zurueckschreiben
Reload nach App-Update: controllerchange lud sofort neu - offene Kamera,
getippte Notiz, halbe Unterschrift weg; mit Fotos in der Warteschlange
kam dazu der Browserdialog "Seite verlassen?".
- window.appBusy(): Modal, Auswahl, laufender Upload, Eingabe im Fokus,
laufende Sprachnotiz. PIN-Sperre zaehlt bewusst nicht
- index.php holt den Reload nach, sobald nichts mehr offen ist; eigener
Reload setzt __selfReload, beforeunload warnt dann nicht
Verifiziert lokal in Chromium: echte Kamera-Oberflaeche mit Canvas-
Stream offline, paralleler Zugriff Seite+Worker (3 Fotos = 3 POSTs im
Serverlog, kein duplicate), Verkleinern (eine Datei, kleine Fassung),
fremde Lease frisch/abgelaufen, aufgeschobener Reload ohne Dialog.
Nicht getestet: Kamera-Hardware und Background Sync bei Display aus am
Handy. Kein Deploy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
64ebbf7850
commit
30202f8f28
7 changed files with 336 additions and 52 deletions
|
|
@ -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
|
||||
|
|
|
|||
84
ROADMAP.md
84
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
|
||||
|
||||
|
|
|
|||
35
app.js
35
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');
|
||||
|
|
|
|||
23
index.php
23
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);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
65
lib/idb.js
65
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 };
|
||||
})();
|
||||
|
|
|
|||
108
lib/offline.js
108
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 };
|
||||
})();
|
||||
|
|
|
|||
71
sw.js
71
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue