From 5105b42c46972cba6b652883756434129ba9b39b Mon Sep 17 00:00:00 2001 From: kamaji Date: Sat, 7 Feb 2026 16:01:07 -0600 Subject: [PATCH] Prevent blob eviction: store as ArrayBuffer + request persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS Safari evicts Blob file-backed data from IndexedDB under memory pressure, causing upload POSTs to throw 'Load failed' without ever reaching the server. Two-pronged fix: 1. Store photos as ArrayBuffer (inline bytes) instead of Blob (file reference) in IndexedDB — ArrayBuffers are not subject to eviction 2. Request navigator.storage.persist() to signal the browser not to evict our storage under pressure Also adds Storage.getBlob() helper for converting stored ArrayBuffer back to Blob at upload/display time, with backward compat for any existing Blob-stored photos. Co-Authored-By: Claude Opus 4.6 --- app/static/js/storage.js | 47 ++++++++++++++++++++++++-------------- app/static/js/sync.js | 34 ++++----------------------- app/static/sw.js | 6 ++--- app/templates/capture.html | 4 +++- app/templates/queue.html | 14 +++++++----- 5 files changed, 48 insertions(+), 57 deletions(-) diff --git a/app/static/js/storage.js b/app/static/js/storage.js index 596a876..0fbf03c 100644 --- a/app/static/js/storage.js +++ b/app/static/js/storage.js @@ -7,43 +7,56 @@ const Storage = { init() { // Initialize Dexie database this.db = new Dexie('nextsnap'); - + // Define schema with compound indexes this.db.version(1).stores({ photos: '++id, username, timestamp, filename, targetPath, status, [username+status], [username+timestamp]', settings: '++id, username, [username+key]' }); - + + // Request persistent storage so the browser won't evict our data + this.requestPersistence(); + return this.db.open(); }, + + async requestPersistence() { + if (navigator.storage && navigator.storage.persist) { + const granted = await navigator.storage.persist(); + console.log('[STORAGE] Persistence:', granted ? 'granted' : 'denied'); + } + }, async savePhoto(photoData) { - /** - * Save a photo to IndexedDB - * photoData: { - * username: string, - * timestamp: number, - * filename: string, - * targetPath: string, - * blob: Blob, - * status: 'pending' | 'uploading' | 'uploaded' | 'verified' - * retryCount: number, - * lastError: string - * } - */ + // Convert Blob to ArrayBuffer for storage resilience. + // iOS Safari can evict Blob file-backed data from IndexedDB, + // but ArrayBuffer is stored inline and won't be evicted. + let imageData = photoData.blob; + if (imageData instanceof Blob) { + imageData = await imageData.arrayBuffer(); + } + const id = await this.db.photos.add({ username: photoData.username, timestamp: photoData.timestamp, filename: photoData.filename, targetPath: photoData.targetPath, - blob: photoData.blob, + blob: imageData, status: photoData.status || 'pending', retryCount: photoData.retryCount || 0, lastError: photoData.lastError || null }); - + return id; }, + + // Convert stored photo data back to a Blob for upload or display. + // Handles both legacy Blob storage and new ArrayBuffer storage. + getBlob(photo) { + if (!photo || !photo.blob) return null; + if (photo.blob instanceof Blob) return photo.blob; + return new Blob([photo.blob], { type: 'image/jpeg' }); + }, async getPhoto(id) { return await this.db.photos.get(id); diff --git a/app/static/js/sync.js b/app/static/js/sync.js index 6d5b197..1e8374b 100644 --- a/app/static/js/sync.js +++ b/app/static/js/sync.js @@ -137,31 +137,18 @@ const Sync = { return true; } - const blob = freshPhoto.blob; + // Convert stored data (ArrayBuffer or legacy Blob) to a Blob for upload + const blob = Storage.getBlob(freshPhoto); console.log('[SYNC] Uploading:', photo.filename, '(' + (blob ? (blob.size / 1024 / 1024).toFixed(1) + 'MB' : 'no blob') + ')', 'attempt', retryCount + 1, 'of', this.MAX_RETRIES); - // Validate blob reference exists - if (!blob || !(blob instanceof Blob) || blob.size === 0) { + // Validate blob exists and has content + if (!blob || blob.size === 0) { throw new Error('Photo data is missing or corrupted - please delete and re-capture'); } - // Actually try to read the blob to verify data is accessible - // (iOS Safari can evict blob data from IndexedDB while keeping the reference) - const readable = await this.validateBlobReadable(blob); - if (!readable) { - // Blob data is gone — no amount of retrying will fix this - console.error('[SYNC] Blob data is no longer readable:', photo.filename); - await Storage.updatePhoto(photo.id, { - status: 'failed', - lastError: 'Photo data was lost by the browser - please re-capture', - error: 'Photo data was lost by the browser - please re-capture' - }); - return false; - } - await Storage.updatePhoto(photo.id, { status: 'uploading' }); // Prevent page navigation during upload @@ -294,19 +281,6 @@ const Sync = { } }, - async validateBlobReadable(blob) { - try { - // Try to read the first byte — if iOS has evicted the data, - // this will throw even though the Blob reference looks valid - const slice = blob.slice(0, 1); - const buf = await slice.arrayBuffer(); - return buf.byteLength > 0; - } catch (e) { - console.error('[SYNC] Blob readability check failed:', e.message); - return false; - } - }, - async checkFileExists(path) { try { const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(path); diff --git a/app/static/sw.js b/app/static/sw.js index f234262..968ae06 100644 --- a/app/static/sw.js +++ b/app/static/sw.js @@ -1,9 +1,9 @@ // NextSnap Service Worker // Provides offline-first caching for the app shell -const CACHE_VERSION = 'nextsnap-v14'; -const APP_SHELL_CACHE = 'nextsnap-shell-v10'; -const RUNTIME_CACHE = 'nextsnap-runtime-v10'; +const CACHE_VERSION = 'nextsnap-v15'; +const APP_SHELL_CACHE = 'nextsnap-shell-v11'; +const RUNTIME_CACHE = 'nextsnap-runtime-v11'; // Assets to cache on install const APP_SHELL_ASSETS = [ diff --git a/app/templates/capture.html b/app/templates/capture.html index 4b31417..8efdbcd 100644 --- a/app/templates/capture.html +++ b/app/templates/capture.html @@ -631,7 +631,9 @@ async function loadRecentPhotos() { container.innerHTML = ''; for (const photo of photos) { - const url = URL.createObjectURL(photo.blob); + const blob = Storage.getBlob(photo); + if (!blob) continue; + const url = URL.createObjectURL(blob); const statusClass = photo.status === 'verified' ? 'verified' : photo.status === 'uploading' ? 'uploading' : 'pending'; diff --git a/app/templates/queue.html b/app/templates/queue.html index 7d17bbf..fb5d37e 100644 --- a/app/templates/queue.html +++ b/app/templates/queue.html @@ -452,9 +452,10 @@ function createQueueItem(photo, isCompleted) { // Create thumbnail (use placeholder if blob was stripped) const thumbnail = document.createElement('div'); thumbnail.className = 'queue-item-thumbnail'; - if (photo.blob && photo.blob.size > 0) { + const photoBlob = Storage.getBlob(photo); + if (photoBlob && photoBlob.size > 0) { const img = document.createElement('img'); - img.src = URL.createObjectURL(photo.blob); + img.src = URL.createObjectURL(photoBlob); thumbnail.appendChild(img); } else { thumbnail.innerHTML = '' + @@ -472,8 +473,8 @@ function createQueueItem(photo, isCompleted) { const meta = document.createElement('div'); meta.className = 'queue-item-meta'; const date = new Date(photo.completedAt || photo.timestamp).toLocaleString(); - if (photo.blob && photo.blob.size > 0) { - const size = (photo.blob.size / 1024 / 1024).toFixed(2); + if (photoBlob && photoBlob.size > 0) { + const size = (photoBlob.size / 1024 / 1024).toFixed(2); meta.textContent = size + ' MB \u2022 ' + date; } else { meta.textContent = date; @@ -552,8 +553,9 @@ async function updateStats() { const pendingCount = photos.filter(p => p.status === 'pending').length; const uploadingCount = photos.filter(p => p.status === 'uploading').length; const totalSize = photos - .filter(p => p.blob && p.blob.size) - .reduce((sum, p) => sum + p.blob.size, 0) / 1024 / 1024; + .map(p => Storage.getBlob(p)) + .filter(b => b && b.size) + .reduce((sum, b) => sum + b.size, 0) / 1024 / 1024; document.getElementById('pending-stat').textContent = pendingCount; document.getElementById('uploading-stat').textContent = uploadingCount;