Issue #107: Web build: data-safety fixes for libraries kept in the browser

- File > Back Up Library... (web): a zip of the catalog files and every
  stored original, built entry by entry; Restore Library from Backup...
  reads it into a new library folder and switches only when complete
  (the previous library stays in storage).
- Failed flushes to browser storage make the catalog Store refuse
  writes, so changes show as unsaved (top-bar warning) and are retried
  every 2 s instead of being lost silently.
- A picked/dropped photo whose bytes can't be stored is not added.
- One tab per library (Web Locks); a second tab shows a message.
- Notices: storage not persistent, library failed to open (temporary
  session), experimental build; ?reset asks first; a panic shows a
  message instead of a frozen page; long toasts wrap.
- Hosting: _headers/.htaccess no longer mark the unhashed .js/.wasm
  immutable; HOSTING.md rewritten for the actual build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-05 17:46:21 -04:00
1 parent 572b55a44a
commit ebc73bb7b4
19 files changed
+985 -95

No files matched your search

+40 -8
View File
@@ -54,7 +54,8 @@ gzip size (5.25 MB). `wasm-opt -Oz`, when installed, shrinks it further.
## Deploying: headers
`cargo xtask web --serve` sends these on every response, and a production server should too:
`cargo xtask web --serve` sends these on every response. A production server may send them too;
today they are optional:
```
Cross-Origin-Opener-Policy: same-origin
@@ -62,11 +63,13 @@ Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
```
COOP + COEP make the page *cross-origin isolated* (`crossOriginIsolated === true`). That is
COOP + COEP make the page *cross-origin isolated* (`crossOriginIsolated === true`). That will be
**required** for `SharedArrayBuffer`, i.e. for a future wasm-threads build (`+atomics`, nightly
`build-std`), and gives full-resolution `performance.now()`. The current render workers (below)
don't share memory, so the app also runs without them; with COEP on, every subresource must be
same-origin or send CORP/CORS headers (the bundle has no third-party resources).
don't share memory, so the app runs without them; with COEP on, every subresource must be
same-origin or send CORP/CORS headers (the bundle has no third-party resources). The file names
are the same in every version, so serve them with `Cache-Control: no-cache` (not `immutable`);
`packaging/web/README.md` (shipped as `HOSTING.md`) has the hosting details.
## What works
@@ -86,10 +89,33 @@ same-origin or send CORP/CORS headers (the bundle has no third-party resources).
- `thumbs/<key>.jpg` + `thumbs/index.json`: the rendered-thumbnail cache, with the desktop's
budget (2 GB, least recently used pruned to 80 %).
A new library starts with the procedural demo photos. The app asks for persistent storage
(`navigator.storage.persist()`); without it the browser may evict the data under storage
pressure. URL options: `?store=idb` forces IndexedDB, `?store=memory` keeps nothing, `?reset`
deletes the stored library first.
A new library starts with the procedural demo photos. URL options: `?store=idb` forces
IndexedDB, `?store=memory` keeps nothing, `?reset` deletes the stored library first (after a
confirmation: it deletes every imported photo too).
- **Keeping the library safe** (experimental: the library lives only in this browser):
- The app asks for persistent storage (`navigator.storage.persist()`); when the browser doesn't
grant it, a notice says the library may be evicted under storage pressure.
- **File ▸ Back Up Library…** downloads a zip of the library files and every stored original
(`originals/<hash>/<file name>`, so an unzipped backup is browsable; entries are stored, not
compressed; at most 4 GB). **File ▸ Restore Library from Backup…** reads such a zip into a
new library folder (`library-restored-<time>/`; originals already stored are skipped, every
entry's checksum is verified) and only then switches to it (`active-library` names the
folder in use) and reloads: the previous library stays in storage. Both are web-only
(`file.backupLibrary`, `file.restoreLibrary`; the desktop library is a folder).
- A failed save (quota exceeded, storage cleared) is not silent: the catalog then refuses new
writes, so commands report `saved in memory but not written`, the top bar shows the unsaved
warning (as on the desktop, see `docs/control-protocol.md`), and saving is retried every
2 s until it works (`web.stats` → `saveError`).
- A picked or dropped photo whose bytes can't be stored is not added (it would be gone after
a reload); a notice says why.
- One tab at a time: the page holds a Web Lock (`navigator.locks`, `lightcraft-library`); a
second tab or window shows "LightCraft is already open in another tab" instead of loading
its own copy (two copies would overwrite each other's saves). Browsers without Web Locks
aren't protected.
- If the stored library can't be opened, a notice says the session is temporary and nothing
is saved; the stored library is left as it was.
- A panic (wasm is built with `panic = "abort"`) replaces the page with a message saying the
stored library is kept and to reload, instead of a frozen canvas.
- **Importing photos with no filesystem.** *File ▸ Import Photos…* (<kbd>⌘⇧I</kbd>) opens the
browser's file picker. You can also drop files anywhere on the page. The bytes are written to
storage, then imported and decoded by the same engine code as the desktop app
@@ -119,6 +145,12 @@ same-origin or send CORP/CORS headers (the bundle has no third-party resources).
yet fails with "still loading" and works a moment later.
- **Durability is "a frame later", not fsync-before-return:** a crash or tab kill in the few
milliseconds between a command and its flush loses that command.
- **Data-safety gaps still open** (issue #107): a panic in a decoder still ends the page (and a
photo that panics while its thumbnail renders can do so again after a reload; there is no
`?safe` start that skips rendering yet); removing photos doesn't delete their stored originals
(`originals/<hash>`; they are kept, and included in backups); restored-over libraries stay in
storage until the site's data is cleared; backups larger than 4 GB need zip64, which isn't
written yet.
- **Preset files** (import/export `.json`) aren't wired to browser pickers yet.
- **Control channel / MCP.** These are desktop-only, because they need a TCP socket.
- **AVIF export** is untested in the browser; it's the one encoder that may not be wasm-safe.