Preferences merge with other tabs/windows and retry failed saves; web startup shows a Reload hint (#475, #476, #477) (#561)

* Preferences: saves merge with what another tab or window stored, and failed writes are retried (#475, #476)

A save now writes only the values this instance changed since it last
loaded or saved them over what storage holds (a three-way merge over the
JSON tree; arrays are leaves), so a second browser tab no longer reverts
the other's changes. The saved revision moves only when the write
succeeds: a failure is retried after 2 s, doubling to 30 s (sooner after a
newer change), with a status message on the first failure and a notice
while it keeps failing. Shared by the desktop and web builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Web: a startup that never gets going shows what may have failed and a Reload link (#477)

When the .wasm fails to download or doesn't match the page, no Rust runs,
so the loading page itself (CSS only, no script) adds the hint after 20 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
BFlatandClaude Opus 5.5 authored and GitHub committed 2026-10-07 15:07:19 +07:00
1 parent 2868220032
commit 2628b0dd3d
7 files changed
+278 -16

No files matched your search

+14 -3
View File
@@ -8,12 +8,23 @@
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#photocraft_loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
#photocraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 0 16px; text-align: center; color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
#photocraft_loading p { margin: 0 0 8px; max-width: 420px; line-height: 1.5; }
#photocraft_loading a { color: #e0e0e0; pointer-events: auto; }
/* The app removes this element once it runs. If the WebAssembly never downloads or doesn't match
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
@keyframes photocraft-stalled { to { visibility: visible; } }
</style>
</head>
<body>
<canvas id="photocraft_canvas"></canvas>
<div id="photocraft_loading">Loading PhotoCraft&hellip;</div>
<div id="photocraft_loading">
<p>Loading PhotoCraft&hellip;</p>
<p id="photocraft_stalled">This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
console lists what failed.</p>
</div>
</body>
</html>
+15
View File
@@ -28,3 +28,18 @@ fn main() {
fn main() {
eprintln!("photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web");
}
#[cfg(test)]
mod tests {
/// A WebAssembly module that never downloads (or doesn't match the page) runs no Rust, so the
/// page itself replaces the endless "Loading" with what may have gone wrong and a way to
/// retry, without a hand-written script.
#[test]
fn loading_page_offers_a_reload_when_startup_stalls() {
let html = include_str!("../index.html");
let (_, hint) = html.split_once("<p id=\"photocraft_stalled\">").expect("stalled-startup hint in the loading element");
assert!(hint.contains("failed to") && hint.contains("<a href=\"\">Reload</a>"), "{hint}");
assert!(html.contains("animation: photocraft-stalled"), "the hint appears after a delay");
assert!(!html.contains("<script"), "trunk injects the generated loader; the page has no script of its own");
}
}
+6 -1
View File
@@ -1226,13 +1226,18 @@ impl Session {
/// Everything persisted as one JSON document: the preferences plus `colorSettings`.
pub fn prefs_to_json(&self) -> String {
serde_json::to_string_pretty(&self.prefs_value()).unwrap_or_default()
}
/// [`Session::prefs_to_json`] as a JSON tree (frontends merge it with what storage holds).
pub fn prefs_value(&self) -> Value {
let mut v = self.prefs().to_json();
if let Value::Object(m) = &mut v {
m.insert("colorSettings".into(), serde_json::to_value(&self.color.settings).unwrap_or(Value::Null));
m.insert("version".into(), json!(1));
m.insert("presets".into(), self.presets.to_json(self));
}
serde_json::to_string_pretty(&v).unwrap_or_default()
v
}
/// Restore preferences saved by [`Session::prefs_to_json`]. Missing keys keep their
+3 -2
View File
@@ -23,14 +23,15 @@ pub struct Notice {
pub error: bool,
}
/// Show a notice (newest last).
pub fn post(app: &mut PhotocraftApp, title: impl Into<String>, lines: Vec<String>, error: bool) {
/// Show a notice (newest last); returns its id.
pub fn post(app: &mut PhotocraftApp, title: impl Into<String>, lines: Vec<String>, error: bool) -> u64 {
let id = app.ui.alloc_id();
app.ui.notices.push(Notice { id, title: title.into(), lines, error });
let n = app.ui.notices.len();
if n > MAX_NOTICES {
app.ui.notices.drain(..n - MAX_NOTICES);
}
id
}
/// Report import/export `warnings` for the file operation `what` (e.g. "Opened a.psd"): the status
+233 -9
View File
@@ -23,7 +23,11 @@ use crate::theme::{ThemeKind, Tokens};
#[derive(Default)]
pub struct Runtime {
loaded: bool,
/// Preferences revision last written to storage (or loaded from it).
saved_rev: u64,
/// This instance's preferences as of `saved_rev`: the base of the three-way merge on save.
saved_value: Option<Value>,
save_retry: SaveRetry,
theme_pref: Option<Theme>,
next_autosave_ms: f64,
autosaved: HashMap<DocId, u64>,
@@ -104,6 +108,7 @@ pub fn load(app: &mut PhotocraftApp) {
crate::dock::restore(app);
app.sync_recent();
app.prefs_rt.saved_rev = app.session.prefs.rev();
app.prefs_rt.saved_value = Some(app.session.prefs_value());
if app.session.prefs().file_handling.recover_on_launch
&& let Some(recover) = app.services.recover.as_mut()
{
@@ -209,14 +214,8 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) {
presets_store(app);
sync_tooltips(app, ctx);
app.sync_recent();
if app.session.prefs.rev() != app.prefs_rt.saved_rev {
app.prefs_rt.saved_rev = app.session.prefs.rev();
let text = app.session.prefs_to_json();
if let Some(save) = app.services.save_prefs.as_mut()
&& let Err(e) = save(&text)
{
app.ui.status = format!("Couldn't save preferences: {e}");
}
if let Some(wait) = persist(app, ctx.input(|i| i.time)) {
ctx.request_repaint_after(std::time::Duration::try_from_secs_f64(wait).unwrap_or_default());
}
let style = canvas_style(app);
if let Some(gpu) = app.gpu.as_ref()
@@ -229,6 +228,121 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) {
history_log(app);
}
/// First retry delay after a failed preferences write, in seconds; it doubles with every further
/// failure up to [`SAVE_RETRY_MAX_S`]. A new change retries sooner, but no sooner than this.
const SAVE_RETRY_S: f64 = 2.0;
const SAVE_RETRY_MAX_S: f64 = 30.0;
/// Failed writes in a row before a notice says preferences aren't being saved.
const SAVE_NOTICE_AFTER: u32 = 3;
/// Retry state of a failed preferences write (times are egui input time, in seconds).
#[derive(Default)]
struct SaveRetry {
failures: u32,
/// When the next retry is due.
at: f64,
/// When the last write was tried, and the revision it carried.
tried_at: f64,
tried_rev: u64,
/// The "not saved" notice, while it's shown.
notice: Option<u64>,
}
/// Save changed preferences. A failed write stays pending and is retried with a backoff (a newer
/// change retries sooner); the status bar reports the first failure and a notice a persistent
/// one. Returns the seconds until the next retry while one is pending.
fn persist(app: &mut PhotocraftApp, now: f64) -> Option<f64> {
let rev = app.session.prefs.rev();
if rev == app.prefs_rt.saved_rev {
return None;
}
let r = &app.prefs_rt.save_retry;
if r.failures > 0 {
let due = if rev == r.tried_rev { r.at } else { r.at.min(r.tried_at + SAVE_RETRY_S) };
if now < due {
return Some(due - now);
}
}
let ours = app.session.prefs_value();
// A change that was undone (or set a value to what it already was) leaves nothing to write.
let written = if app.prefs_rt.saved_value.as_ref() == Some(&ours) { Ok(()) } else { write_prefs(app, &ours) };
match written {
Ok(()) => {
app.prefs_rt.saved_rev = rev;
app.prefs_rt.saved_value = Some(ours);
let r = std::mem::take(&mut app.prefs_rt.save_retry);
if r.failures > 0 {
app.ui.status = "Preferences saved".into();
app.ui.status_error = false;
if let Some(id) = r.notice {
app.ui.notices.retain(|n| n.id != id);
}
}
None
}
Err(e) => {
let r = &mut app.prefs_rt.save_retry;
r.failures = r.failures.saturating_add(1);
(r.tried_at, r.tried_rev) = (now, rev);
let delay = (SAVE_RETRY_S * 2f64.powi(r.failures.min(16) as i32 - 1)).min(SAVE_RETRY_MAX_S);
r.at = now + delay;
let failures = r.failures;
if failures == 1 {
app.ui.status = format!("Couldn't save preferences: {e}. Retrying…");
app.ui.status_error = true;
} else if failures == SAVE_NOTICE_AFTER {
let lines = vec![e, "PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes.".into()];
let id = crate::notices::post(app, "Preferences can't be saved", lines, true);
app.prefs_rt.save_retry.notice = Some(id);
}
Some(delay)
}
}
}
/// Write `ours` over the stored preferences, keeping what another instance (a second browser
/// tab, another app window) changed there since this one last loaded or saved them.
fn write_prefs(app: &mut PhotocraftApp, ours: &Value) -> Result<(), String> {
let Some(save) = app.services.save_prefs.as_mut() else { return Ok(()) };
let stored = app.services.load_prefs.as_mut().and_then(|load| load()).and_then(|t| serde_json::from_str::<Value>(&t).ok());
let text = match (stored, &app.prefs_rt.saved_value) {
(Some(mut theirs @ Value::Object(_)), Some(base)) => {
merge_changes(base, ours, &mut theirs);
serde_json::to_string_pretty(&theirs)
}
_ => serde_json::to_string_pretty(ours),
}
.map_err(|e| e.to_string())?;
save(&text)
}
/// Three-way merge of preference trees: whatever `ours` changed since `base` goes over `theirs`
/// (what storage holds now) and the rest of `theirs` is kept. Objects merge key by key; any
/// other value, arrays included, is replaced whole.
fn merge_changes(base: &Value, ours: &Value, theirs: &mut Value) {
if ours == base {
return;
}
match (base, ours, theirs) {
(Value::Object(b), Value::Object(o), Value::Object(t)) => {
for (k, ov) in o {
match (b.get(k), t.get_mut(k)) {
(Some(bv), Some(tv)) => merge_changes(bv, ov, tv),
// Removed there, unchanged here: it stays removed.
(Some(bv), None) if bv == ov => {}
_ => {
t.insert(k.clone(), ov.clone());
}
}
}
for k in b.keys().filter(|k| !o.contains_key(*k)) {
t.remove(k);
}
}
(_, _, theirs) => *theirs = ours.clone(),
}
}
/// Background autosave of documents with unsaved changes every N minutes (File Handling).
fn autosave(app: &mut PhotocraftApp) {
let fh = &app.session.prefs().file_handling;
@@ -1237,16 +1351,126 @@ mod tests {
fn app_with_saved(text: Option<String>) -> (PhotocraftApp, Arc<Mutex<Option<String>>>) {
let store: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(text));
(app_on(&store, Arc::default()), store)
}
/// An app whose preferences live in `store`; writes fail while `fail` is set.
fn app_on(store: &Arc<Mutex<Option<String>>>, fail: Arc<std::sync::atomic::AtomicBool>) -> PhotocraftApp {
let (a, b) = (store.clone(), store.clone());
let services = crate::Services {
load_prefs: Some(Box::new(move || a.lock().unwrap().clone())),
save_prefs: Some(Box::new(move |s: &str| {
if fail.load(std::sync::atomic::Ordering::Relaxed) {
return Err("storage is full".into());
}
*b.lock().unwrap() = Some(s.to_string());
Ok(())
})),
..Default::default()
};
(PhotocraftApp::new(photocraft_engine::Session::new(), services), store)
PhotocraftApp::new(photocraft_engine::Session::new(), services)
}
fn stored(store: &Arc<Mutex<Option<String>>>) -> Value {
serde_json::from_str(store.lock().unwrap().as_deref().unwrap_or("null")).unwrap()
}
#[test]
fn merge_keeps_changes_made_elsewhere() {
let base = json!({"a": {"x": 1, "y": 1}, "list": [1], "gone": 1, "kept": 1});
// Here: a.x changed, `gone` removed, `new` added.
let ours = json!({"a": {"x": 2, "y": 1}, "list": [1], "kept": 1, "new": 1});
// There: a.y, `list` and `kept` changed, and a key this version doesn't know.
let mut theirs = json!({"a": {"x": 1, "y": 3}, "list": [1, 2], "gone": 1, "kept": 5, "future": true});
merge_changes(&base, &ours, &mut theirs);
assert_eq!(theirs, json!({"a": {"x": 2, "y": 3}, "list": [1, 2], "kept": 5, "new": 1, "future": true}));
// Arrays are leaves: a list changed here replaces the stored one whole.
merge_changes(&json!({"list": [1]}), &json!({"list": [1, 3]}), &mut theirs);
assert_eq!(theirs["list"], json!([1, 3]));
// Nothing changed here: storage is left as it is, whatever it holds.
let mut odd = json!({"x": [7]});
merge_changes(&ours, &ours, &mut odd);
assert_eq!(odd, json!({"x": [7]}));
// A value that changed type here replaces the stored one.
merge_changes(&json!({"a": 1}), &json!({"a": {"b": 2}}), &mut theirs);
assert_eq!(theirs["a"], json!({"b": 2}));
}
#[test]
fn saving_keeps_preferences_another_instance_changed() {
let store = Arc::new(Mutex::new(None));
let ctx = egui::Context::default();
// Two browser tabs (or app windows) on the same storage.
let (mut a, mut b) = (app_on(&store, Arc::default()), app_on(&store, Arc::default()));
tick(&mut a, &ctx);
tick(&mut b, &ctx);
a.run("prefs.set", json!({"values": {"interface.theme": "studio"}})).unwrap();
tick(&mut a, &ctx);
assert_eq!(stored(&store)["interface"]["theme"], "studio");
// The other one, never reloaded, saves an unrelated change: the theme stays.
b.run("prefs.set", json!({"values": {"performance.historyStates": 12}})).unwrap();
tick(&mut b, &ctx);
let v = stored(&store);
assert_eq!((v["interface"]["theme"].as_str(), v["performance"]["historyStates"].as_u64()), (Some("studio"), Some(12)));
// And the first one's next save keeps the second one's change.
a.run("prefs.set", json!({"values": {"interface.showTooltips": false}})).unwrap();
tick(&mut a, &ctx);
let v = stored(&store);
assert_eq!(v["performance"]["historyStates"], 12);
assert_eq!(v["interface"]["showTooltips"], false);
// A value changed in both: the latest save wins.
b.run("prefs.set", json!({"values": {"interface.theme": "classic"}})).unwrap();
tick(&mut b, &ctx);
assert_eq!(stored(&store)["interface"]["theme"], "classic");
// Unreadable storage is overwritten with this instance's preferences.
*store.lock().unwrap() = Some("not json".into());
a.run("prefs.set", json!({"values": {"performance.historyStates": 30}})).unwrap();
tick(&mut a, &ctx);
let (mut c, _) = app_with_saved(store.lock().unwrap().clone());
tick(&mut c, &ctx);
assert_eq!(c.session.prefs().performance.history_states, 30);
assert_eq!(c.session.prefs().interface.theme, Theme::Studio);
}
#[test]
fn failed_preference_saves_are_retried() {
use std::sync::atomic::{AtomicBool, Ordering};
let store = Arc::new(Mutex::new(None));
let fail = Arc::new(AtomicBool::new(true));
let mut app = app_on(&store, fail.clone());
tick(&mut app, &egui::Context::default());
app.run("prefs.set", json!({"values": {"interface.theme": "studio"}})).unwrap();
// The write fails: reported, and retried after a backoff rather than marked saved.
assert_eq!(persist(&mut app, 100.0), Some(SAVE_RETRY_S));
assert!(app.ui.status.starts_with("Couldn't save preferences: storage is full") && app.ui.status_error);
assert!(store.lock().unwrap().is_none());
assert_eq!(persist(&mut app, 101.0), Some(1.0), "not due yet");
// Storage works again: the pending change is written without another edit.
fail.store(false, Ordering::Relaxed);
assert_eq!(persist(&mut app, 102.0), None);
assert_eq!(stored(&store)["interface"]["theme"], "studio");
assert_eq!(app.ui.status, "Preferences saved");
assert_eq!(persist(&mut app, 103.0), None, "nothing left to save");
// A persistent failure backs off up to a cap and raises a notice once.
fail.store(true, Ordering::Relaxed);
app.run("prefs.set", json!({"values": {"interface.theme": "classic"}})).unwrap();
let mut now = 200.0;
let mut delays = Vec::new();
for _ in 0..6 {
let wait = persist(&mut app, now).unwrap();
delays.push(wait);
now += wait;
}
assert_eq!(delays, [2.0, 4.0, 8.0, 16.0, SAVE_RETRY_MAX_S, SAVE_RETRY_MAX_S]);
assert_eq!(app.ui.notices.iter().filter(|n| n.error && n.title == "Preferences can't be saved").count(), 1);
// A newer change retries sooner than the backoff, but not on every frame.
app.run("prefs.set", json!({"values": {"interface.theme": "pro"}})).unwrap();
assert_eq!(persist(&mut app, now - 29.0), Some(1.0));
fail.store(false, Ordering::Relaxed);
assert_eq!(persist(&mut app, now - 28.0), None);
assert_eq!(stored(&store)["interface"]["theme"], "pro");
assert!(app.ui.notices.is_empty(), "the notice goes once preferences are saved");
}
#[test]
+6 -1
View File
@@ -111,7 +111,12 @@ The desktop app stores them in `preferences.json` in the platform config directo
`$XDG_CONFIG_HOME/photocraft`; override with `PHOTOCRAFT_CONFIG_DIR`); autosaves go to its
`Recovery` folder. In portable mode (a `portable.txt` or `PhotoCraft.portable` file beside the
executable, as in the Windows portable zip) that directory is `PhotoCraftData` next to the
executable instead. The web build keeps them in `localStorage`.
executable instead. The web build keeps them in `localStorage`. A save writes only the values
this instance changed since it last loaded or saved them over what storage holds now, so a second
browser tab (or app window) never reverts the other's changes; for a value changed in both, the
latest save wins. A failed write stays pending and is retried (after 2 s, doubling up to 30 s, and
sooner after a further change); the status bar reports the first failure and a notice a
persistent one.
User and imported (`.abr`) brush presets live in the config directory's `Presets` folder: one
`.pcbrushes` JSON file per preset group, content-addressed tip bitmaps under `tips/`, and an
+1
View File
@@ -285,6 +285,7 @@ How the web shell (`apps/photocraft-web/src/web.rs`) differs from desktop:
- **Open** uses `rfd::AsyncFileDialog`. The bytes arrive asynchronously in `Services::inbox`, which the app drains every frame.
- **Save / Save As / Export** trigger a browser download of the encoded bytes. The shell does this with a Blob, an object URL and a temporary `<a download>`, all created from Rust. There is no save dialog, so the suggested name becomes the download name.
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them. It reads each file with `DroppedFile::bytes_async` and pushes the bytes into the inbox.
- **Startup failures:** `index.html` shows "Loading PhotoCraft…" until the app removes it, or replaces it with the error when the web runner fails to start. If the `.wasm` never downloads or doesn't match the page's `.js` (a stale cached `index.html` after a deploy), no Rust runs at all, so the page itself (CSS only, no script) adds a hint with a Reload link after 20 seconds.
- **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`. `Page.setInterceptFileChooserDialog` plus `DOM.setFileInputFiles` covers Open, `Input.dispatchDragEvent` with `files` covers drops, and `Browser.setDownloadBehavior` captures downloads.
- Headless Chrome on macOS (`--headless=new --enable-unsafe-webgpu`) gets a real WebGPU adapter.