diff --git a/Cargo.lock b/Cargo.lock index 2975d76f7..073249191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2361,6 +2361,7 @@ dependencies = [ name = "lightcraft-web" version = "0.2.0" dependencies = [ + "crc32fast", "eframe", "egui", "js-sys", diff --git a/apps/lightcraft-web/Cargo.toml b/apps/lightcraft-web/Cargo.toml index 1f9f14c13..a3331a600 100644 --- a/apps/lightcraft-web/Cargo.toml +++ b/apps/lightcraft-web/Cargo.toml @@ -21,6 +21,7 @@ lightcraft-preview = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +crc32fast = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] eframe = { workspace = true, features = ["default_fonts", "glow"] } diff --git a/apps/lightcraft-web/src/backend.rs b/apps/lightcraft-web/src/backend.rs index 4ccde59c0..e20f6dc9c 100644 --- a/apps/lightcraft-web/src/backend.rs +++ b/apps/lightcraft-web/src/backend.rs @@ -232,6 +232,18 @@ impl Backend { } } + /// Is there a file at `path`? + pub async fn exists(&self, path: &str) -> Result { + match self { + Backend::Opfs(root) => Ok(Self::opfs_file(root, path, false).await?.is_some()), + Backend::Idb(db) => { + let (_tx, store) = Self::idb_store(db, false)?; + let n = idb_req(&store.count_with_key(&path.into()).map_err(err)?).await?; + Ok(n.as_f64().is_some_and(|n| n > 0.0)) + } + } + } + pub async fn remove(&self, path: &str) -> Result<(), String> { match self { Backend::Opfs(root) => { @@ -309,14 +321,25 @@ async fn dir_keys(dir: &FileSystemDirectoryHandle) -> Result, String Ok(out) } -/// Ask the browser not to evict our storage under pressure (best effort; fire and forget). -pub fn request_persistence() { - if let Some(sm) = storage_manager() - && let Ok(p) = sm.persist() - { - wasm_bindgen_futures::spawn_local(async move { - let granted = JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()); - log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted (best effort)" }); - }); - } +/// Ask the browser not to evict our storage under pressure; `on_result(granted)` once known +/// (`false` also when the browser has no storage manager). +pub fn request_persistence(on_result: impl FnOnce(bool) + 'static) { + let Some(sm) = storage_manager() else { + on_result(false); + return; + }; + wasm_bindgen_futures::spawn_local(async move { + // already persistent: nothing to ask + let already = match sm.persisted() { + Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()), + Err(_) => false, + }; + let granted = already + || match sm.persist() { + Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()), + Err(_) => false, + }; + log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted" }); + on_result(granted); + }); } diff --git a/apps/lightcraft-web/src/backup.rs b/apps/lightcraft-web/src/backup.rs new file mode 100644 index 000000000..5161e69fb --- /dev/null +++ b/apps/lightcraft-web/src/backup.rs @@ -0,0 +1,306 @@ +//! Library backups for the browser build: a plain zip (entries stored, not compressed — photos +//! don't shrink) of the library files and every stored original, built one entry at a time so +//! only one file is in wasm memory at once, and read back the same way for a restore. +//! +//! Layout of a backup: +//! - `library/catalog.snap`, `library/catalog.log`, `library/presets.json`, … (see +//! [`crate::files::LIBRARY_FILES`]); +//! - `originals//`: each imported photo's original bytes, under its own +//! name so an unzipped backup is browsable; +//! - `README.txt`. +//! +//! Restoring never deletes anything: the library files go to a new library folder in browser +//! storage, originals that aren't stored yet are added (they're named by content, so existing +//! ones are identical), and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to +//! the restored library. The library that was there before stays in storage. +//! +//! No zip64: a backup holds at most [`MAX_BYTES`] and 65 535 entries. + +/// Storage key of the file naming the library folder in use (absent: `library`). +pub const ACTIVE_LIBRARY: &str = "active-library"; + +/// The default library folder. +pub const DEFAULT_LIBRARY_DIR: &str = "library"; + +/// Largest backup (offsets are 32-bit without zip64). +pub const MAX_BYTES: u64 = 0xFFFF_FFFF - (64 << 20); + +const LOCAL_SIG: u32 = 0x0403_4b50; +const CENTRAL_SIG: u32 = 0x0201_4b50; +const END_SIG: u32 = 0x0605_4b50; +/// General-purpose flag bit 11: names are UTF-8. +const UTF8: u16 = 0x0800; +/// 1980-01-01 00:00 in MS-DOS format (date, time). +const DOS_DATE: u16 = (1 << 5) | 1; + +/// The readme inside every backup. +pub const README: &str = "LightCraft library backup (browser version)\n\n\ +library/ the catalog (catalog.snap + catalog.log), presets, view state and preferences\n\ +originals/ every imported photo, as originals//\n\n\ +Restore it in LightCraft for the web with File > Restore Library from Backup...\n\ +The photos in originals/ are the untouched files you imported; edits live in the catalog.\n"; + +/// Is `name` a safe library folder name (`library` or `library-`)? +pub fn valid_library_dir(name: &str) -> bool { + name == DEFAULT_LIBRARY_DIR + || name.strip_prefix("library-").is_some_and(|s| !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) +} + +/// Is `h` a plausible content hash (used as a storage key segment)? +pub fn valid_hash(h: &str) -> bool { + !h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric()) +} + +/// Zip entry name for an original. +pub fn original_entry(hash: &str, file_name: &str) -> String { + let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect(); + let name = if name.is_empty() || name == "." || name == ".." { "original".to_string() } else { name }; + format!("originals/{hash}/{name}") +} + +/// Where a backup entry goes when restoring into library folder `dir`: `Some(storage key)`, or +/// `None` to skip it (README, thumbnails, anything unknown or unsafe). +pub fn restore_key(entry: &str, dir: &str) -> Option { + if let Some(name) = entry.strip_prefix("library/") { + return crate::files::LIBRARY_FILES.contains(&name).then(|| format!("{dir}/{name}")); + } + let rest = entry.strip_prefix("originals/")?; + let (hash, _) = rest.split_once('/').unwrap_or((rest, "")); + valid_hash(hash).then(|| format!("originals/{hash}")) +} + +fn u16le(v: &mut Vec, x: u16) { + v.extend_from_slice(&x.to_le_bytes()); +} +fn u32le(v: &mut Vec, x: u32) { + v.extend_from_slice(&x.to_le_bytes()); +} + +struct Central { + name: String, + crc: u32, + size: u32, + offset: u32, +} + +/// Writes a stored (uncompressed) zip piece by piece: [`ZipWriter::entry`] returns the local +/// header to emit before the entry's bytes, [`ZipWriter::finish`] the central directory. +#[derive(Default)] +pub struct ZipWriter { + entries: Vec, + offset: u64, +} + +impl ZipWriter { + /// The local header for `name` holding `data` (emit it, then `data`). + pub fn entry(&mut self, name: &str, data: &[u8]) -> Result, String> { + let size = u32::try_from(data.len()).map_err(|_| format!("{name}: larger than 4 GB"))?; + let name_len = u16::try_from(name.len()).map_err(|_| format!("{name}: name too long"))?; + if self.entries.len() >= usize::from(u16::MAX) { + return Err("too many files for one backup".into()); + } + let header_len = 30 + u64::from(name_len); + if self.offset + header_len + u64::from(size) > MAX_BYTES { + return Err("the library is too large for one backup file (4 GB)".into()); + } + let offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?; + let crc = crc32fast::hash(data); + let mut h = Vec::with_capacity(header_len as usize); + u32le(&mut h, LOCAL_SIG); + u16le(&mut h, 20); // version needed + u16le(&mut h, UTF8); + u16le(&mut h, 0); // stored + u16le(&mut h, 0); // time + u16le(&mut h, DOS_DATE); + u32le(&mut h, crc); + u32le(&mut h, size); + u32le(&mut h, size); + u16le(&mut h, name_len); + u16le(&mut h, 0); // extra + h.extend_from_slice(name.as_bytes()); + self.offset += header_len + u64::from(size); + self.entries.push(Central { name: name.to_string(), crc, size, offset }); + Ok(h) + } + + /// The central directory and end record (emit last). + pub fn finish(self) -> Result, String> { + let mut cd = Vec::new(); + for e in &self.entries { + u32le(&mut cd, CENTRAL_SIG); + u16le(&mut cd, 20); // made by + u16le(&mut cd, 20); // needed + u16le(&mut cd, UTF8); + u16le(&mut cd, 0); + u16le(&mut cd, 0); + u16le(&mut cd, DOS_DATE); + u32le(&mut cd, e.crc); + u32le(&mut cd, e.size); + u32le(&mut cd, e.size); + u16le(&mut cd, e.name.len() as u16); + u16le(&mut cd, 0); // extra + u16le(&mut cd, 0); // comment + u16le(&mut cd, 0); // disk + u16le(&mut cd, 0); // internal attrs + u32le(&mut cd, 0); // external attrs + u32le(&mut cd, e.offset); + cd.extend_from_slice(e.name.as_bytes()); + } + let cd_offset = u32::try_from(self.offset).map_err(|_| "backup too large".to_string())?; + let cd_size = u32::try_from(cd.len()).map_err(|_| "backup too large".to_string())?; + let n = self.entries.len() as u16; + let mut out = cd; + u32le(&mut out, END_SIG); + u16le(&mut out, 0); + u16le(&mut out, 0); + u16le(&mut out, n); + u16le(&mut out, n); + u32le(&mut out, cd_size); + u32le(&mut out, cd_offset); + u16le(&mut out, 0); // comment + Ok(out) + } +} + +/// One entry of a zip's central directory. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ZipEntry { + pub name: String, + pub crc: u32, + pub size: u64, + /// Offset of the entry's local header. + pub header: u64, + /// 0 = stored (the only method a restore reads). + pub method: u16, +} + +fn rd16(b: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(b.get(at..at + 2)?.try_into().ok()?)) +} +fn rd32(b: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?)) +} + +/// How many bytes from the end of a zip to read to find its end record. +pub fn tail_len(file_len: u64) -> u64 { + file_len.min(22 + 65_535) +} + +/// The central directory's (offset, size) from the last [`tail_len`] bytes of a zip. +pub fn find_central(tail: &[u8]) -> Result<(u64, u64), String> { + let at = (0..tail.len().saturating_sub(21)).rev().find(|&i| rd32(tail, i) == Some(END_SIG)).ok_or("not a zip file (no end record)")?; + let size = rd32(tail, at + 12).ok_or("truncated zip")?; + let offset = rd32(tail, at + 16).ok_or("truncated zip")?; + if size == u32::MAX || offset == u32::MAX { + return Err("zip64 backups are not supported".into()); + } + Ok((u64::from(offset), u64::from(size))) +} + +/// Parse a central directory. +pub fn parse_central(cd: &[u8]) -> Result, String> { + let mut out = Vec::new(); + let mut i = 0; + while i + 46 <= cd.len() { + if rd32(cd, i) != Some(CENTRAL_SIG) { + return Err("damaged zip directory".into()); + } + let bad = || "damaged zip directory".to_string(); + let method = rd16(cd, i + 10).ok_or_else(bad)?; + let crc = rd32(cd, i + 16).ok_or_else(bad)?; + let size = rd32(cd, i + 24).ok_or_else(bad)?; + let n = usize::from(rd16(cd, i + 28).ok_or_else(bad)?); + let extra = usize::from(rd16(cd, i + 30).ok_or_else(bad)?); + let comment = usize::from(rd16(cd, i + 32).ok_or_else(bad)?); + let header = rd32(cd, i + 42).ok_or_else(bad)?; + let name = cd.get(i + 46..i + 46 + n).ok_or_else(bad)?; + out.push(ZipEntry { name: String::from_utf8_lossy(name).into_owned(), crc, size: u64::from(size), header: u64::from(header), method }); + i += 46 + n + extra + comment; + } + Ok(out) +} + +/// Offset of an entry's data from its local header's first 30 bytes. +pub fn data_offset(entry: &ZipEntry, local: &[u8]) -> Result { + if rd32(local, 0) != Some(LOCAL_SIG) { + return Err(format!("{}: damaged entry", entry.name)); + } + let n = rd16(local, 26).ok_or("truncated entry")?; + let extra = rd16(local, 28).ok_or("truncated entry")?; + Ok(entry.header + 30 + u64::from(n) + u64::from(extra)) +} + +/// Check an entry's bytes. +pub fn verify(entry: &ZipEntry, data: &[u8]) -> Result<(), String> { + if entry.method != 0 { + return Err(format!("{}: compressed entries are not supported (not a LightCraft backup?)", entry.name)); + } + if data.len() as u64 != entry.size || crc32fast::hash(data) != entry.crc { + return Err(format!("{}: damaged (checksum mismatch)", entry.name)); + } + Ok(()) +} + +/// A name for the restored library's folder. +pub fn restored_dir_name(now_ms: f64) -> String { + format!("library-restored-{}", now_ms.max(0.0) as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build(files: &[(&str, &[u8])]) -> Vec { + let mut w = ZipWriter::default(); + let mut out = Vec::new(); + for (n, d) in files { + out.extend(w.entry(n, d).unwrap()); + out.extend_from_slice(d); + } + out.extend(w.finish().unwrap()); + out + } + + #[test] + fn written_backups_read_back() { + let files: [(&str, &[u8]); 3] = + [("README.txt", README.as_bytes()), ("library/catalog.log", b"{\"seq\":1}\n"), ("originals/abc123/IMG 1.jpg", &[0, 1, 2, 3, 255])]; + let zip = build(&files); + let tail = &zip[zip.len() - tail_len(zip.len() as u64) as usize..]; + let (off, size) = find_central(tail).unwrap(); + let entries = parse_central(&zip[off as usize..(off + size) as usize]).unwrap(); + assert_eq!(entries.len(), 3); + for (e, (name, data)) in entries.iter().zip(files) { + assert_eq!(e.name, name); + let start = data_offset(e, &zip[e.header as usize..e.header as usize + 30]).unwrap() as usize; + let got = &zip[start..start + e.size as usize]; + assert_eq!(got, data); + verify(e, got).unwrap(); + } + // damage is caught + let e = &entries[2]; + assert!(verify(e, &[0, 1, 2, 3, 254]).is_err()); + assert!(find_central(b"not a zip at all, just some text").is_err()); + } + + #[test] + fn restore_keys_are_safe() { + assert_eq!(restore_key("library/catalog.snap", "library-restored-1").as_deref(), Some("library-restored-1/catalog.snap")); + assert_eq!(restore_key("library/../../x", "library-r"), None); + assert_eq!(restore_key("library/evil.bin", "library-r"), None); + assert_eq!(restore_key("originals/abc123/IMG.jpg", "d").as_deref(), Some("originals/abc123")); + assert_eq!(restore_key("originals/../library/catalog.log", "d"), None); + assert_eq!(restore_key("README.txt", "d"), None); + assert_eq!(restore_key("thumbs/x.jpg", "d"), None); + assert_eq!(original_entry("abc", "a/b\\c.jpg"), "originals/abc/a_b_c.jpg"); + assert_eq!(original_entry("abc", ".."), "originals/abc/original"); + assert!(valid_library_dir("library") && valid_library_dir("library-restored-17")); + assert!(!valid_library_dir("library-../x") && !valid_library_dir("originals") && !valid_library_dir("library-")); + } + + #[test] + fn size_limits_are_errors_not_panics() { + let mut w = ZipWriter { offset: MAX_BYTES - 10, ..Default::default() }; + assert!(w.entry("x", &[0; 64]).is_err()); + } +} diff --git a/apps/lightcraft-web/src/files.rs b/apps/lightcraft-web/src/files.rs index 18acc93d3..d0c9f348b 100644 --- a/apps/lightcraft-web/src/files.rs +++ b/apps/lightcraft-web/src/files.rs @@ -49,6 +49,8 @@ struct Mirror { files: BTreeMap>, /// Dirty files, least recently modified first. dirty: Vec<(String, Dirty)>, + /// Why the last flush to browser storage failed (quota, eviction…), until one succeeds. + error: Option, } impl Mirror { @@ -138,6 +140,18 @@ impl Files { } } + /// Record the outcome of a flush to browser storage: `Some(why)` when it failed, `None` once + /// one succeeds. While a flush is failing, the catalog [`Store`] refuses new writes, so the + /// engine keeps the changes queued and reports them unsaved (`library.info` → `unsavedOps`, + /// the top bar's warning) instead of pretending they were saved. + pub fn set_error(&self, e: Option) { + self.lock().error = e; + } + + pub fn error(&self) -> Option { + self.lock().error.clone() + } + /// A catalog [`Store`] over these files. pub fn store(&self) -> FilesStore { FilesStore(self.clone()) @@ -147,19 +161,32 @@ impl Files { /// [`Store`] implementation over [`Files`]. pub struct FilesStore(Files); +impl FilesStore { + /// Writes fail while browser storage does (see [`Files::set_error`]). + fn writable(&self) -> io::Result<()> { + match self.0.error() { + Some(e) => Err(io::Error::other(format!("browser storage: {e}"))), + None => Ok(()), + } + } +} + impl Store for FilesStore { fn read(&mut self, name: &str) -> io::Result>> { Ok(self.0.get(name)) } fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()> { + self.writable()?; self.0.write(name, data); Ok(()) } fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> { + self.writable()?; self.0.append(name, data); Ok(()) } fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> { + self.writable()?; self.0.truncate(name, len as usize); Ok(()) } @@ -224,6 +251,37 @@ mod tests { assert_eq!(names, ["catalog.snap", "catalog.log"]); } + /// A failing flush (quota exceeded, storage evicted) is not silent: commands report the change + /// as unsaved and the engine keeps it queued; once storage works again it is written. + #[test] + fn flush_failures_show_as_unsaved_and_are_retried() { + let mut disk = BTreeMap::new(); + let files = Files::default(); + let mut s = Session::new(); + let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false }; + s.open_library_in(stores, true).unwrap(); + flush(&files, &mut disk); + files.set_error(Some("QuotaExceededError".into())); + let r = s.execute("photo.rate", &json!({"rating": 5})); + assert!(matches!(r, Err(lightcraft_engine::EngineError::NotSaved(_))), "{r:?}"); + let (ops, why) = s.unsaved().unwrap(); + assert!(ops > 0 && why.contains("QuotaExceeded"), "{why}"); + files.set_error(None); + s.persist_if_dirty(); + s.persist().unwrap(); + assert!(s.unsaved().is_none(), "written once storage works again"); + flush(&files, &mut disk); + let id = s.selection.active.unwrap(); + let files2 = Files::default(); + for (k, v) in &disk { + files2.preload(k, v.clone()); + } + let mut s2 = Session::new(); + let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files2.store()), files: Box::new(files2.store()), on_disk: false }; + s2.open_library_in(stores, true).unwrap(); + assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived"); + } + #[test] fn library_survives_reload_through_flushes() { let mut disk = BTreeMap::new(); diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs index 3cbb27b3e..04ec89b54 100644 --- a/apps/lightcraft-web/src/lib.rs +++ b/apps/lightcraft-web/src/lib.rs @@ -16,6 +16,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod backup; pub mod bench; pub mod files; pub mod store; @@ -24,6 +25,8 @@ pub mod wire; #[cfg(target_arch = "wasm32")] mod backend; #[cfg(target_arch = "wasm32")] +mod safety; +#[cfg(target_arch = "wasm32")] mod web; #[cfg(target_arch = "wasm32")] mod workers; diff --git a/apps/lightcraft-web/src/safety.rs b/apps/lightcraft-web/src/safety.rs new file mode 100644 index 000000000..c0dac59c2 --- /dev/null +++ b/apps/lightcraft-web/src/safety.rs @@ -0,0 +1,256 @@ +//! Keeping a library in the browser safe: notices the user must see (a failed save, storage the +//! browser may evict, a file that couldn't be stored), one tab at a time per library, a message +//! instead of a dead page after a panic, and backup / restore of the whole library +//! ([`crate::backup`]). + +use std::cell::RefCell; +use std::collections::HashMap; + +use js_sys::{Reflect, Uint8Array}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +use crate::backend::Backend; +use crate::backup::{ + ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len, + valid_hash, verify, +}; +use crate::files::{Files, LIBRARY_FILES}; +use crate::store::storage_key; + +thread_local! { + /// Messages for the user, shown by the app on its next frame. + static NOTICES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Queue a message for the user (shown for several seconds; also logged). +pub fn notice(text: impl Into) { + let text = text.into(); + log::warn!("{text}"); + NOTICES.with(|n| n.borrow_mut().push(text)); + crate::web::request_repaint(); +} + +/// Messages queued since the last call. +pub fn take_notices() -> Vec { + NOTICES.with(|n| std::mem::take(&mut *n.borrow_mut())) +} + +fn document() -> Option { + web_sys::window()?.document() +} + +/// Ask the user (the browser's own confirm box). `false` when it can't be shown. +pub fn confirm(text: &str) -> bool { + web_sys::window().and_then(|w| w.confirm_with_message(text).ok()).unwrap_or(false) +} + +/// Cover the page with a message (the app can't continue, or must not start). +pub fn show_blocking(text: &str) { + let Some(doc) = document() else { return }; + let el = match doc.get_element_by_id("lightcraft_blocking") { + Some(el) => el, + None => { + let Ok(el) = doc.create_element("div") else { return }; + el.set_id("lightcraft_blocking"); + let _ = el.set_attribute( + "style", + "position:fixed;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;padding:32px;\ + background:#1c1c1c;color:#e0e0e0;font:15px/1.5 system-ui,-apple-system,'Segoe UI',sans-serif;text-align:center;white-space:pre-line", + ); + if let Some(body) = doc.body() { + let _ = body.append_child(&el); + } + el + } + }; + el.set_text_content(Some(text)); + if let Some(loading) = doc.get_element_by_id("lightcraft_loading") { + loading.remove(); + } +} + +/// `panic = "abort"` on wasm: a panic ends the module. Say so on the page instead of leaving a +/// frozen canvas, and say that the stored library is intact. +pub fn install_panic_hook() { + std::panic::set_hook(Box::new(|info| { + let msg = match (info.payload().downcast_ref::<&str>(), info.payload().downcast_ref::()) { + (Some(s), _) => (*s).to_string(), + (_, Some(s)) => s.clone(), + _ => "unknown error".into(), + }; + let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default(); + log::error!("LightCraft panicked{at}: {msg}"); + show_blocking(&format!( + "LightCraft stopped after an internal error:\n{msg}\n\nThe library saved in this browser is kept. Reload the page to continue.\n\ + If it happens again right away, a photo may be the cause: reload with ?workers=0 or report the error." + )); + })); +} + +/// The name of the lock that keeps a library to one tab. +const TAB_LOCK: &str = "lightcraft-library"; + +/// Hold the library lock for as long as this page lives (Web Locks API). `Some(true)`: ours; +/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks). +pub async fn acquire_tab_lock() -> Option { + let nav = Reflect::get(&js_sys::global(), &"navigator".into()).ok()?; + let locks = Reflect::get(&nav, &"locks".into()).ok().filter(|l| !l.is_undefined() && !l.is_null())?; + let request: js_sys::Function = Reflect::get(&locks, &"request".into()).ok()?.dyn_into().ok()?; + let opts = js_sys::Object::new(); + Reflect::set(&opts, &"ifAvailable".into(), &true.into()).ok()?; + let granted = js_sys::Promise::new(&mut |resolve, _reject| { + let cb = Closure::once_into_js(move |lock: JsValue| -> JsValue { + let ours = !lock.is_null() && !lock.is_undefined(); + let _ = resolve.call1(&JsValue::NULL, &ours.into()); + // keep the lock until the page goes away: a promise that never settles + if ours { js_sys::Promise::new(&mut |_, _| {}).into() } else { JsValue::NULL } + }); + if let Err(e) = request.call3(&locks, &TAB_LOCK.into(), &opts, &cb) { + log::warn!("Web Locks request failed: {e:?}"); + } + }); + JsFuture::from(granted).await.ok().map(|v| v.is_truthy()) +} + +/// Offer a blob as a download named `name`. +pub fn download_blob(name: &str, blob: &web_sys::Blob) -> Result<(), String> { + let e = |e: JsValue| format!("download failed: {e:?}"); + let url = web_sys::Url::create_object_url_with_blob(blob).map_err(e)?; + let doc = document().ok_or("no document")?; + let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into(); + a.set_href(&url); + a.set_download(name); + a.click(); + // revoke once the download has started + let revoke = Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + if let Some(w) = web_sys::window() { + let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 60_000); + } + Ok(()) +} + +/// File ▸ Back Up Library…: a zip of the library files (as the app has them, saved or not) and +/// every original in storage, built entry by entry into the browser's memory (outside the wasm +/// heap) and downloaded. `names`: content hash → the photo's file name. +pub async fn backup(files: Files, backend: Backend, names: HashMap) -> Result<(usize, f64), String> { + let parts = js_sys::Array::new(); + let mut zip = ZipWriter::default(); + let mut bytes = 0f64; + let mut add = |zip: &mut ZipWriter, name: &str, data: &[u8]| -> Result<(), String> { + let header = zip.entry(name, data)?; + parts.push(&Uint8Array::from(&header[..])); + parts.push(&Uint8Array::from(data)); + bytes += (header.len() + data.len()) as f64; + Ok(()) + }; + add(&mut zip, "README.txt", README.as_bytes())?; + for name in LIBRARY_FILES { + if let Some(d) = files.get(name) { + add(&mut zip, &format!("library/{name}"), &d)?; + } + } + let mut originals = 0; + for hash in backend.list("originals").await? { + if !valid_hash(&hash) { + continue; + } + match backend.read(&storage_key(&hash)).await { + Ok(Some(data)) => { + let name = names.get(&hash).map_or("original", String::as_str); + add(&mut zip, &original_entry(&hash, name), &data)?; + originals += 1; + } + Ok(None) => {} + Err(e) => return Err(format!("reading an original from browser storage: {e}")), + } + } + let tail = zip.finish()?; + parts.push(&Uint8Array::from(&tail[..])); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type("application/zip"); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(|e| format!("{e:?}"))?; + let day: String = String::from(js_sys::Date::new_0().to_iso_string()).chars().take(10).collect(); + download_blob(&format!("lightcraft-library-backup-{day}.zip"), &blob)?; + Ok((originals, bytes + tail.len() as f64)) +} + +/// Bytes `start..end` of a picked file. +async fn read_range(file: &web_sys::File, start: u64, end: u64) -> Result, String> { + let blob = file.slice_with_f64_and_f64(start as f64, end as f64).map_err(|e| format!("{e:?}"))?; + let buf = JsFuture::from(blob.array_buffer()).await.map_err(|e| format!("{e:?}"))?; + Ok(Uint8Array::new(&buf).to_vec()) +} + +/// Restore a backup into a new library folder (nothing in storage is deleted or overwritten), +/// then point [`ACTIVE_LIBRARY`] at it. Returns the folder's name; the caller reloads the page. +pub async fn restore(file: web_sys::File, backend: Backend) -> Result { + let len = file.size() as u64; + let tail = read_range(&file, len - tail_len(len), len).await?; + let (cd_offset, cd_size) = find_central(&tail)?; + let cd = read_range(&file, cd_offset, cd_offset + cd_size).await?; + let entries = parse_central(&cd)?; + if !entries.iter().any(|e| e.name == "library/catalog.snap" || e.name == "library/catalog.log") { + return Err("this isn't a LightCraft library backup (no library/catalog files)".into()); + } + let dir = restored_dir_name(js_sys::Date::now()); + let mut written = 0usize; + for e in &entries { + let Some(key) = restore_key(&e.name, &dir) else { continue }; + // originals are named by their content: one already stored is the same file + if key.starts_with("originals/") && backend.exists(&key).await? { + continue; + } + let local = read_range(&file, e.header, e.header + 30).await?; + let start = data_offset(e, &local)?; + let data = read_range(&file, start, start + e.size).await?; + verify(e, &data)?; + backend.write(&key, &data).await.map_err(|err| format!("storing {}: {err}", e.name))?; + written += 1; + } + backend.write(ACTIVE_LIBRARY, dir.as_bytes()).await?; + log::info!("lightcraft: restored {written} files into {dir}"); + Ok(dir) +} + +/// File ▸ Restore Library from Backup…: confirm, pick the zip, restore, reload. +pub fn pick_and_restore(backend: Backend, before_reload: impl FnOnce() + 'static) { + let ok = confirm( + "Restore a LightCraft library backup?\n\nThe backup becomes the library in this browser and the page reloads. \ + The current library is not deleted: it stays in browser storage.", + ); + if !ok { + return; + } + let Some(doc) = document() else { return }; + let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::()) else { return }; + input.set_type("file"); + input.set_accept(".zip,application/zip"); + let inp = input.clone(); + let mut before_reload = Some(before_reload); + let on_change = Closure::::new(move || { + let Some(file) = inp.files().and_then(|f| f.get(0)) else { return }; + let backend = backend.clone(); + let before = before_reload.take(); + notice(format!("Restoring {}…", file.name())); + wasm_bindgen_futures::spawn_local(async move { + match restore(file, backend).await { + Ok(_) => { + if let Some(f) = before { + f(); + } + if let Some(w) = web_sys::window() { + let _ = w.location().reload(); + } + } + Err(e) => notice(format!("Restore failed: {e}. Nothing was changed: the current library is still in use.")), + } + }); + }); + input.set_onchange(Some(on_change.as_ref().unchecked_ref())); + on_change.forget(); + input.click(); +} diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 09a195959..01c400e68 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -15,16 +15,21 @@ use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use crate::backend::Backend; +use crate::backup::{ACTIVE_LIBRARY, DEFAULT_LIBRARY_DIR, valid_library_dir}; use crate::bench::Bench; use crate::files::{Files, FlushOp, LIBRARY_FILES}; +use crate::safety::{self, notice}; use crate::store::{Originals, content_hash, download_name, storage_key}; use crate::wire::ThumbIndex; use crate::workers::{THUMB_INDEX, Workers}; const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp"; -/// Where the library files live in browser storage. -const LIBRARY_DIR: &str = "library"; +/// After a failed save, wait this long before trying again (ms). +const SAVE_RETRY_MS: f64 = 2000.0; + +/// How long a notice stays on screen (s). +const NOTICE_SECS: f64 = 8.0; /// How often view state, UI prefs and the thumbnail index are saved (ms). const SAVE_EVERY_MS: f64 = 1000.0; @@ -45,8 +50,18 @@ fn set_status(text: &str) { } } -fn library_key(name: &str) -> String { - format!("{LIBRARY_DIR}/{name}") +/// Storage key of library file `name` in library folder `dir`. +fn library_key(dir: &str, name: &str) -> String { + format!("{dir}/{name}") +} + +/// Repaint the app (notices from async tasks). +pub(crate) fn request_repaint() { + CTX.with(|c| { + if let Some(c) = c.borrow().as_ref() { + c.request_repaint(); + } + }); } /// URL options (`?bench&store=idb&workers=2&reset`). @@ -86,8 +101,9 @@ async fn store_file(originals: Originals, backend: Option, name: String if let Some(b) = &backend && let Err(e) = b.write(&storage_key(&hash), &bytes).await { - // still importable for this session; it won't survive a reload - log::error!("storing {name} in browser storage failed: {e}"); + // a photo whose bytes aren't stored would be gone after a reload: don't add it + notice(format!("{name} was not added: browser storage refused it ({e}). Free up space or back up and clear old photos.")); + return; } originals.added(&name, &hash, Arc::from(bytes)); ctx.request_repaint(); @@ -148,8 +164,42 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { Ok(()) } -fn services(originals: Originals, backend: Option, ctx: egui::Context) -> Services { +fn services(originals: Originals, backend: Option, files: Files, frozen: Rc>, ctx: egui::Context) -> Services { + let (backup_backend, backup_files) = (backend.clone(), files); + let restore_backend = backend.clone(); Services { + backup_library: Some(Box::new(move |session: &mut Session| { + let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) }; + // the photos' own names for the originals in the zip + let names: std::collections::HashMap = session + .catalog + .photos() + .filter_map(|p| match &p.source { + Source::File { path } => crate::store::hash_of_path(path).map(|h| (h.to_string(), p.file_name.clone())), + _ => None, + }) + .collect(); + let files = backup_files.clone(); + notice("Backing up the library… (the download starts when it's ready)"); + wasm_bindgen_futures::spawn_local(async move { + match safety::backup(files, b, names).await { + Ok((n, bytes)) => notice(format!( + "Library backed up: {n} original{} ({:.1} MB). Keep the file somewhere safe.", + if n == 1 { "" } else { "s" }, + bytes / 1e6 + )), + Err(e) => notice(format!("Backup failed: {e}")), + } + }); + Ok(json!({"started": true})) + })), + restore_library: Some(Box::new(move |_session: &mut Session| { + let Some(b) = restore_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) }; + let frozen = frozen.clone(); + // once the backup is in place, stop saving the old library (the page reloads) + safety::pick_and_restore(b, move || frozen.set(true)); + Ok(json!({"started": true})) + })), pick_files: Some(Box::new(move || { open_picker(originals.clone(), backend.clone(), ctx.clone()); Vec::new() // files arrive asynchronously and are imported on a later frame @@ -176,21 +226,29 @@ fn services(originals: Originals, backend: Option, ctx: egui::Context) } } -/// Write dirty library files to storage, in order, until none are left. -async fn flush(files: Files, backend: Backend, flushing: Rc>) { +/// Write dirty library files to storage, in order, until none are left. A failure (quota, +/// storage cleared) is recorded in `files` — the catalog then reports changes as unsaved instead +/// of accepting them silently — and retried after [`SAVE_RETRY_MS`]. +async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc>, failed_at: Rc>) { 'outer: loop { let ops = files.take_dirty(); if ops.is_empty() { + if files.error().is_some() { + log::info!("lightcraft: saving works again"); + } + files.set_error(None); break; } for (i, op) in ops.iter().enumerate() { let r = match op { - FlushOp::Write { name, data } => backend.write(&library_key(name), data).await, - FlushOp::Append { name, offset, data } => backend.write_at(&library_key(name), Some(*offset), data).await, + FlushOp::Write { name, data } => backend.write(&library_key(&dir, name), data).await, + FlushOp::Append { name, offset, data } => backend.write_at(&library_key(&dir, name), Some(*offset), data).await, }; if let Err(e) = r { log::error!("saving {} failed: {e}", op.name()); files.failed(&ops[i..]); + files.set_error(Some(e)); + failed_at.set(perf_now()); break 'outer; } } @@ -204,6 +262,8 @@ struct Boot { backend: Option, files: Files, index: ThumbIndex, + /// The library folder in storage (`library`, or a restored backup's). + lib_dir: String, } async fn boot(opts: Options) -> Boot { @@ -221,15 +281,32 @@ async fn boot(opts: Options) -> Boot { }; let files = Files::default(); let mut index = ThumbIndex::default(); + let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string(); if let Some(b) = &backend { if opts.reset { - match b.clear().await { - Ok(()) => log::info!("lightcraft: storage cleared (?reset)"), - Err(e) => log::error!("clearing storage: {e}"), + // everything this site keeps in the browser, photos included: never without asking + let ok = safety::confirm( + "?reset deletes the LightCraft library stored in this browser, including every imported photo. \ + This can't be undone. Delete it?", + ); + if ok { + match b.clear().await { + Ok(()) => log::info!("lightcraft: storage cleared (?reset)"), + Err(e) => log::error!("clearing storage: {e}"), + } + } else { + log::info!("lightcraft: ?reset declined; storage kept"); + } + } + // a restored backup lives in its own folder (see `backup`) + if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await { + let d = String::from_utf8_lossy(&d).trim().to_string(); + if valid_library_dir(&d) { + lib_dir = d; } } for name in LIBRARY_FILES { - match b.read(&library_key(name)).await { + match b.read(&library_key(&lib_dir, name)).await { Ok(Some(data)) => files.preload(name, data), Ok(None) => {} Err(e) => log::error!("reading {name}: {e}"), @@ -238,7 +315,14 @@ async fn boot(opts: Options) -> Boot { if let Ok(Some(j)) = b.read(THUMB_INDEX).await { index = ThumbIndex::from_json(&j); } - crate::backend::request_persistence(); + crate::backend::request_persistence(|granted| { + if !granted { + notice( + "This browser may delete LightCraft's library when it runs short of space (persistent storage wasn't granted). \ + Keep your original photos elsewhere and use File ▸ Back Up Library… now and then.", + ); + } + }); // thumbnails stored without an index entry (index saved late, or lost): delete them let b2 = b.clone(); let known: std::collections::HashSet = b @@ -266,7 +350,7 @@ async fn boot(opts: Options) -> Boot { index.len(), index.total() as f64 / 1e6 ); - Boot { opts, backend, files, index } + Boot { opts, backend, files, index, lib_dir } } struct WebApp { @@ -276,6 +360,11 @@ struct WebApp { backend: Option, workers: Option, flushing: Rc>, + /// When the last save to storage failed (ms; 0 = never). + flush_failed_at: Rc>, + /// Saving stopped (a restored backup is about to replace this library: the page reloads). + frozen: Rc>, + lib_dir: String, bench: Option, first_frame_logged: bool, last_save: f64, @@ -284,13 +373,13 @@ struct WebApp { impl WebApp { fn new(cc: &eframe::CreationContext<'_>, boot: Boot) -> Self { - let Boot { opts, backend, files, index } = boot; + let Boot { opts, backend, files, index, lib_dir } = boot; let originals = Originals::default(); let t = perf_now(); let mut session = Session::new(); originals.install(&mut session); let stores = LibraryStores { - dir: format!("browser:{}/{LIBRARY_DIR}", backend.as_ref().map_or("memory", |b| b.kind())).into(), + dir: format!("browser:{}/{lib_dir}", backend.as_ref().map_or("memory", |b| b.kind())).into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false, @@ -305,14 +394,25 @@ impl WebApp { r.replayed ), Err(e) => { - log::error!("opening the library failed: {e}; starting a temporary one"); + // the stored library is left untouched; say plainly that nothing here is saved + notice(format!( + "The library stored in this browser could not be opened ({e}). This is a temporary session: nothing you do now is saved. \ + The stored library has not been changed." + )); session = Session::with_demo(); originals.install(&mut session); } } + if backend.is_some() { + notice( + "LightCraft in the browser is experimental. Your library is kept in this browser's storage: keep your original photos \ + elsewhere and back up with File ▸ Back Up Library….", + ); + } // previews are large (≤ 2560 px, f32): keep few in a 32-bit address space session.media.preview_capacity = 3; - let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), cc.egui_ctx.clone())); + let frozen = Rc::new(Cell::new(false)); + let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone())); let ui_written = files.get("ui.json").unwrap_or_default(); if let Ok(ui) = serde_json::from_slice::(&ui_written) { app.ui = ui; @@ -337,6 +437,9 @@ impl WebApp { backend, workers, flushing: Rc::new(Cell::new(false)), + flush_failed_at: Rc::new(Cell::new(0.0)), + frozen, + lib_dir, bench: opts.bench.then(|| Bench::new(origin)), first_frame_logged: false, last_save: 0.0, @@ -398,6 +501,9 @@ impl WebApp { /// Save view state, UI prefs and the thumbnail index now and then; flush dirty files. fn save(&mut self) { + if self.frozen.get() { + return; + } let now = perf_now(); if now - self.last_save >= SAVE_EVERY_MS { self.last_save = now; @@ -419,12 +525,16 @@ impl WebApp { }); } } + // after a failure, retry every couple of seconds rather than every frame + let backing_off = self.files.error().is_some() && now - self.flush_failed_at.get() < SAVE_RETRY_MS; if let Some(b) = &self.backend && self.files.is_dirty() && !self.flushing.get() + && !backing_off { self.flushing.set(true); - wasm_bindgen_futures::spawn_local(flush(self.files.clone(), b.clone(), self.flushing.clone())); + let (files, dir, flushing, failed_at) = (self.files.clone(), self.lib_dir.clone(), self.flushing.clone(), self.flush_failed_at.clone()); + wasm_bindgen_futures::spawn_local(flush(files, b.clone(), dir, flushing, failed_at)); } } } @@ -454,8 +564,10 @@ impl WebApp { let (orig_n, orig_bytes) = self.originals.usage(); json!({ "storage": self.backend.as_ref().map_or("memory", |b| b.kind()), + "library": self.lib_dir, "dirty": self.files.is_dirty(), "flushing": self.flushing.get(), + "saveError": self.files.error(), "workers": {"alive": alive, "ready": ready, "remoteJobs": remote, "inlineJobs": inline}, "originalsInMemory": orig_n, "originalBytesInMemory": orig_bytes, @@ -480,6 +592,10 @@ impl WebApp { impl eframe::App for WebApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + for text in safety::take_notices() { + let now = ctx.input(|i| i.time); + self.app.ui.toast = Some((text, now + NOTICE_SECS)); + } self.run_inbox(); self.import_dropped(ctx); self.load_originals(ctx); @@ -517,9 +633,18 @@ impl eframe::App for WebApp { #[wasm_bindgen] pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); + safety::install_panic_hook(); log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now()); let opts = Options::from_url(); wasm_bindgen_futures::spawn_local(async move { + // one tab per library: two would each keep their own copy and overwrite each other's saves + if opts.store != "memory" && safety::acquire_tab_lock().await == Some(false) { + safety::show_blocking( + "LightCraft is already open in another tab or window of this browser.\n\n\ + Switch to that tab, or close it and reload this page. (Two tabs would overwrite each other's changes.)", + ); + return; + } let Some(canvas) = window() .and_then(|w| w.document()) .and_then(|d| d.get_element_by_id("lightcraft_canvas")) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 981867c82..299285f7f 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -224,6 +224,9 @@ fn services() -> Services { png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() })), + // the library is a folder on disk: backed up with the user's other files + backup_library: None, + restore_library: None, } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec4cbc3b2..50e7d190c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -60,6 +60,9 @@ pub type RevealFn = Box Result<(), String>>; pub type OpenUrlFn = Box Result<(), String>>; /// Open a file in an application (`app` = "" for the system's default one). pub type OpenWithFn = Box Result<(), String>>; +/// Something the host does with the session (web: back up or restore the library in browser +/// storage); the work may finish asynchronously. +pub type HostAction = Box Result>; /// Platform services injected by the host app (desktop or web). #[derive(Default)] @@ -89,6 +92,12 @@ pub struct Services { pub open_url: Option, /// Open a file in an external editor (Edit in External Editor; desktop only). pub open_with: Option, + /// File ▸ Back Up Library…: save the whole library (catalog and originals) as one file the + /// user keeps (web only: there the library lives in browser storage, which the browser may + /// clear; on the desktop it is a folder backed up like any other). + pub backup_library: Option, + /// File ▸ Restore Library from Backup… (web only; keeps the current library). + pub restore_library: Option, } #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 71571d326..4e6c092c1 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -58,6 +58,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "@Import from Device", "---", "app.openLibrary", + "file.backupLibrary", + "file.restoreLibrary", "---", "dialog.newAlbum", "dialog.newFolder", @@ -241,6 +243,15 @@ const HIDDEN: &[&str] = &[ "preset.create", ]; +/// Items only some hosts have are left out of the others' menus (the web build's library backup). +fn host_supports(app: &LightcraftApp, id: &str) -> bool { + match id { + "file.backupLibrary" => app.services.backup_library.is_some(), + "file.restoreLibrary" => app.services.restore_library.is_some(), + _ => true, + } +} + fn item(id: &str, params: Value, label: impl Into, shortcut: Option<&str>, enabled: bool, checked: Option) -> MenuNode { MenuNode::Item { id: id.into(), params, label: label.into(), shortcut: shortcut.map(str::to_string), enabled, checked } } @@ -537,7 +548,8 @@ fn tidy(v: Vec) -> Vec { /// The whole menu bar: (title, items) per menu in [`MENUS`] order. pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec)> { - let entries: Vec = crate::menus::menu_entries(app).into_iter().filter(|e| !HIDDEN.contains(&e.id.as_str())).collect(); + let entries: Vec = + crate::menus::menu_entries(app).into_iter().filter(|e| !HIDDEN.contains(&e.id.as_str()) && host_supports(app, &e.id)).collect(); let mut used = vec![false; entries.len()]; let mut bar = Vec::new(); for title in MENUS { @@ -774,6 +786,34 @@ mod tests { assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus"); } + /// Back Up / Restore Library are the browser build's (its library lives in browser storage): + /// absent from the desktop's menus, present and wired to the host where it provides them. + #[test] + fn library_backup_items_follow_the_host() { + let all = |app: &LightcraftApp| -> Vec { menu_bar(app).into_iter().flat_map(|(_, v)| v).collect() }; + let mut desktop = app(); + assert!(find(&all(&desktop), "file.backupLibrary").is_none() && find(&all(&desktop), "file.restoreLibrary").is_none()); + assert!(run_item(&mut desktop, "file.backupLibrary", Value::Null).is_err(), "not available without the host"); + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let c = calls.clone(); + let services = crate::Services { + backup_library: Some(Box::new(move |s: &mut lightcraft_engine::Session| { + c.set(c.get() + 1); + Ok(json!({"photos": s.catalog.len()})) + })), + restore_library: Some(Box::new(|_: &mut lightcraft_engine::Session| Ok(json!({"started": true})))), + ..Default::default() + }; + let mut web = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let bar = all(&web); + for id in ["file.backupLibrary", "file.restoreLibrary"] { + assert!(matches!(find(&bar, id), Some(MenuNode::Item { enabled: true, .. })), "{id}"); + } + let r = run_item(&mut web, "file.backupLibrary", Value::Null).unwrap(); + assert!(r["photos"].as_u64().unwrap() > 0); + assert_eq!(calls.get(), 1); + } + #[test] fn bar_follows_the_registry_and_state() { let mut app = app(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 795c7383b..b3ef506e2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -116,6 +116,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("file.addFolder", "Import from Folder…", None, "File"), ("file.addFromDevice", "Import from Device", None, ""), ("file.findMissing", "Find Missing Photos…", None, "File"), + ("file.backupLibrary", "Back Up Library…", None, "File"), + ("file.restoreLibrary", "Restore Library from Backup…", None, "File"), ("photo.locate", "Locate Missing File…", None, ""), ("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""), ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), @@ -381,6 +383,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::settings::open_library(app, p), + "file.backupLibrary" | "file.restoreLibrary" => { + let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; + match action { + Some(f) => f(&mut app.session), + None => Err("not available here: on the desktop the library is a folder; back it up with your other files".into()), + } + } "view.filmstrip" => { app.ui.filmstrip = !app.ui.filmstrip; Ok(Value::Null) @@ -1188,6 +1197,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "view.compare" => app.session.catalog.len() > 1, "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, "app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(), + "file.backupLibrary" => app.services.backup_library.is_some(), + "file.restoreLibrary" => app.services.restore_library.is_some(), "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 726dc5997..f4a69b44b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -69,8 +69,10 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let fade = ((until - now) / 0.25).clamp(0.0, 1.0) as f32; let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast"))); - let galley = painter.layout_no_wrap(text, t.font(14.0), t.text.gamma_multiply(fade)); - let size = vec2(galley.size().x + 40.0, 40.0); + // long messages (a save warning, the web build's notices) wrap within the canvas + let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0); + let galley = painter.layout(text, t.font(14.0), t.text.gamma_multiply(fade), wrap); + let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0)); let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size); painter.rect_filled(r, 6.0, egui::Color32::from_black_alpha((200.0 * fade) as u8)); painter.galley(r.center() - galley.size() / 2.0, galley, t.text); diff --git a/docs/parity.md b/docs/parity.md index 2f24d5995..af5a27eaf 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 15 | 2 | 2 | 1 | 8/8 (100%) | 6/7 (86%) | +| X. Cross-cutting behaviours (BEHAV) | 15 | 3 | 2 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 30 | 9 | 40 | 9 | — | 20/21 (95%) | -| **Total** | 386 | 28 | 91 | 37 | 194/200 (97%) | 138/147 (94%) | +| **Total** | 386 | 29 | 91 | 37 | 194/200 (97%) | 138/148 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 505 in-scope rows — P0 98.5% of 200 · P1 95.9% of 147 · P2 39.2% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 506 in-scope rows — P0 98.5% of 200 · P1 95.6% of 148 · P2 39.2% of 158. ## Top gaps @@ -496,6 +496,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | | LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `crates/preview/src/lib.rs` (`JobPool`), `crates/ui-egui/src/export_task.rs`, `crates/ui-egui/src/import.rs` | renders off the UI thread; exports started from the UI run on a worker thread with a progress panel and Cancel (`ui.inspect` → `export`); imports show a progress window. No combined activity centre | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | +| LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; no tiling beyond the buffer limit (CPU) | | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) | diff --git a/docs/web.md b/docs/web.md index 6efe13451..165398af8 100644 --- a/docs/web.md +++ b/docs/web.md @@ -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/.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//`, 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-