mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
2868220032
commit
2628b0dd3d
7 files changed
+278
-16
No files matched your search
@@ -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…</div>
|
||||
<div id="photocraft_loading">
|
||||
<p>Loading PhotoCraft…</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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user