From 309bac6185ee13d109836a48608680cfb6087298 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Sun, 18 Oct 2026 19:32:46 +0200 Subject: [PATCH 01/10] =?UTF-8?q?M7:=20the=20web=20build=20opens=20files:?= =?UTF-8?q?=20File=20=E2=96=B8=20Open,=20Import=20and=20dropped=20files=20?= =?UTF-8?q?(#759)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File ▸ Open, Import and Place image did nothing in the browser (the pickers return a path, which a browser never has) and dropped files were ignored (eframe hands their contents over asynchronously). - Services::pick_open_contents: a platform can pick a file's name and contents; the web build uses an . Open and Import pass them to file.open / file.import as data + name. - A dropped file without a path waits behind the opening card until its contents are read (bytes_async on wasm), or says it couldn't be read. - Canvas drops still import, as on the desktop. Closes #759. Co-authored-by: Cursor --- apps/figcraft-web/Cargo.toml | 2 +- apps/figcraft-web/src/web.rs | 56 +++++++++- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/async_dialog_tests.rs | 40 +++++++- crates/ui-egui/src/canvas.rs | 12 +-- crates/ui-egui/src/home.rs | 13 +-- crates/ui-egui/src/home_tests.rs | 62 +++++++++++ crates/ui-egui/src/import_report_tests.rs | 1 + crates/ui-egui/src/lib.rs | 120 ++++++++++++++++------ crates/ui-egui/src/pending_open.rs | 53 ++++++++-- docs/hardware-parity.md | 5 +- 12 files changed, 302 insertions(+), 64 deletions(-) diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 2f1a8190..ffd44f3b 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" js-sys = "0.3" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 29213621..01511252 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -1,7 +1,7 @@ //! The browser shell: web `Services` and the eframe web runner. use figcraft_engine::Session; -use figcraft_ui_egui::{FigApp, Services}; +use figcraft_ui_egui::{ContentsFuture, FigApp, Services}; use wasm_bindgen::JsCast as _; const CANVAS_ID: &str = "figcraft_canvas"; @@ -75,9 +75,10 @@ impl eframe::App for WebShell { } } -/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved -/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste -/// events with their text (a page can't read the clipboard when it likes). +/// Browser services. Files are opened and imported through the browser's file picker, which hands +/// over their contents (pickers that need a path return nothing, the trait's defaults); files are +/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as +/// paste events with their text (a page can't read the clipboard when it likes). struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, @@ -85,6 +86,16 @@ struct WebServices { } impl Services for WebServices { + /// An `` taking `exts`, clicked; its file is read once picked. + fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option { + let accept = exts.iter().map(|e| format!(".{e}")).collect::>().join(","); + Some(Box::pin(async move { + pick_file(&accept).await.unwrap_or_else(|e| { + log::error!("couldn't open a file: {e:?}"); + None + }) + })) + } fn downloads(&self) -> bool { true } @@ -136,6 +147,43 @@ impl Services for WebServices { } } +/// The largest file read from the picker (browsers read at most 4 GiB into one buffer). +const MAX_PICKED_BYTES: f64 = u32::MAX as f64; + +/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its +/// name; `None` when the picker is cancelled. +async fn pick_file(accept: &str) -> Result)>, wasm_bindgen::JsValue> { + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let body = document.body().ok_or("no body")?; + let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?; + input.set_type("file"); + input.set_accept(accept); + input.set_hidden(true); + // Some browsers (older Safari) open the picker only for an input in the page. + body.append_child(&input)?; + // Settles on `change` (a file was picked) or `cancel` (the picker was closed without one). + let mut settle = |resolve: js_sys::Function, _reject: js_sys::Function| { + let done = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = resolve.call0(&wasm_bindgen::JsValue::NULL); + }); + let _ = input.add_event_listener_with_callback("change", done.unchecked_ref()); + let _ = input.add_event_listener_with_callback("cancel", done.unchecked_ref()); + }; + let closed = js_sys::Promise::new(&mut settle); + input.click(); + let result = wasm_bindgen_futures::JsFuture::from(closed).await; + let file = input.files().and_then(|files| files.get(0)); + input.remove(); + result?; + let Some(file) = file else { return Ok(None) }; + if file.size() > MAX_PICKED_BYTES { + return Err(format!("{} is too large to read in the browser", file.name()).into()); + } + let buffer = wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await?; + Ok(Some((file.name(), js_sys::Uint8Array::new(&buffer).to_vec()))) +} + fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5..5d5b1856 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -319,6 +319,7 @@ name = "English" "Corner smoothing" = "Corner smoothing" "Couldn't download the variables" = "Couldn't download the variables" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." +"Couldn't read {name}." = "Couldn't read {name}." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" "Count" = "Count" "Create" = "Create" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e222..56b6d29c 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -320,6 +320,7 @@ status = "draft — needs native review" "Corner radius" = "Radio de esquina" "Corner smoothing" = "Suavizado de esquinas" "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." +"Couldn't read {name}." = "No se pudo leer {name}." "Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" "Count" = "Número" "Create" = "Crear" diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs index 26808fe5..ad5dcfbc 100644 --- a/crates/ui-egui/src/async_dialog_tests.rs +++ b/crates/ui-egui/src/async_dialog_tests.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use figcraft_engine::Session; -use crate::{DialogFuture, FigApp, Services}; +use crate::{ContentsFuture, DialogFuture, FigApp, Services}; struct OpenFile(PathBuf); @@ -79,6 +79,44 @@ fn save_as_saves_the_file_it_was_opened_for_after_another_tab_comes_to_the_front let _ = std::fs::remove_dir_all(dir); } +/// The browser: the picker hands over a file's name and contents, never a path. +struct Contents(&'static str, Vec); + +impl Services for Contents { + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + panic!("a platform that picks contents is never asked for a path") + } + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + Some(Box::pin(std::future::ready(Some((self.0.to_string(), self.1.clone()))))) + } +} + +#[test] +fn open_dialog_opens_picked_contents_as_a_new_unsaved_document() { + let bytes = figcraft_format::save_bytes(&figcraft_doc::Document::new("Picked"), None).unwrap(); + let mut app = FigApp::new(Session::empty(), Box::new(Contents("Picked.figcraft", bytes))); + app.ui.home = true; + app.open_dialog(); + assert!(app.session.docs.is_empty(), "opening waits for the picker result"); + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.docs.len(), 1); + assert_eq!(app.session.docs[0].path, None, "no path to save over"); + assert_eq!(app.session.docs[0].doc.name, "Picked"); + assert!(!app.ui.home, "and the editor shows it"); +} + +#[test] +fn import_dialog_imports_picked_contents_into_the_file_in_front() { + let svg = br#""#.to_vec(); + let mut app = FigApp::new(Session::new(), Box::new(Contents("Logo.svg", svg))); + let rectangles = |app: &FigApp| app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)).count(); + assert_eq!(rectangles(&app), 0); + app.import_dialog(false); + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.docs.len(), 1, "imported, not opened"); + assert_eq!(rectangles(&app), 1, "the SVG's content"); +} + #[test] fn a_dialog_whose_file_closed_meanwhile_does_nothing() { let (mut app, dir, path) = two_files("closed"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9e0c1d79..ce003f66 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -507,21 +507,17 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { let path = f.path().to_path_buf(); let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); - let params = if path.exists() { - json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}) - } else if let Some(bytes) = crate::dropped_bytes(&f) { - use base64::Engine as _; - let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - json!({"data": b64, "name": name, "x": pt.x, "y": pt.y}) + let (params, read) = if path.exists() { + (json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None) } else { - continue; + (json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f))) }; let Some(target) = app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() }) else { continue; }; - app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target) }); + app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read }); } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { // A drag released off the canvas, or off the window, ends where the pointer was last seen. diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 236e2786..0d372d1b 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -187,16 +187,9 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } // `opens` saw an extension, so there is a file name to tell the format by. let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let params = if path.exists() { - json!({"path": path.to_string_lossy()}) - } else if let Some(bytes) = crate::dropped_bytes(&f) { - use base64::Engine as _; - let data = base64::engine::general_purpose::STANDARD.encode(&bytes); - json!({"data": data, "name": name}) - } else { - continue; - }; - app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None }); + let (params, read) = + if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(&f))) }; + app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read }); } } diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index c493a0a1..ea812ea0 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -573,6 +573,68 @@ fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() { assert!(!path.exists(), "nothing is written"); } +/// A drop whose contents arrive later, as every drop in the browser does: `None` until `arrived` +/// holds them. +fn later(arrived: &std::rc::Rc>>>>) -> crate::ReadFuture { + let arrived = arrived.clone(); + Box::pin(std::future::poll_fn(move |_| match arrived.borrow_mut().take() { + Some(bytes) => std::task::Poll::Ready(bytes), + None => std::task::Poll::Pending, + })) +} + +#[test] +fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() { + let svg = br#""#.to_vec(); + let arrived = std::rc::Rc::new(std::cell::RefCell::new(None)); + let mut u = first_run(); + u.app.ui.home = true; + u.app.ui.pending_opens.push(crate::pending_open::PendingOpen { + label: "Browser drop.svg".into(), + params: json!({"name": "Browser drop.svg"}), + command: "file.open", + target: None, + read: Some(later(&arrived)), + }); + // egui lays a new card out invisibly on its first frame. + u.frame(vec![]); + for _ in 0..5 { + let s = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&s, "Browser drop.svg"), "the card shows while the contents are read"); + } + assert!(u.app.session.docs.is_empty(), "nothing opens before the contents arrive"); + *arrived.borrow_mut() = Some(Some(svg)); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.ui.last_error, None); + assert_eq!(u.app.session.docs.len(), 1, "the contents open"); + assert_eq!(u.app.session.docs[0].path, None); + assert!(u.app.session.docs[0].doc.nodes().any(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)), "the SVG's content"); + assert!(!u.app.ui.home); +} + +#[test] +fn a_dropped_file_that_cannot_be_read_says_so_and_opens_nothing() { + let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(None))); + let mut u = first_run(); + u.app.ui.home = true; + u.app.ui.pending_opens.push(crate::pending_open::PendingOpen { + label: "Unreadable.figcraft".into(), + params: json!({"name": "Unreadable.figcraft"}), + command: "file.open", + target: None, + read: Some(later(&arrived)), + }); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert!(u.app.session.docs.is_empty()); + assert_eq!(u.app.ui.last_error.as_deref(), Some("Couldn't read Unreadable.figcraft.")); +} + #[test] fn dropping_a_native_file_on_the_home_screen_opens_it() { let dir = Temp::new(); diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index a2411582..8c10c7f3 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -177,6 +177,7 @@ fn a_dropped_file_shows_its_report_once_it_opens() { params: json!({"path": path.to_string_lossy()}), command: "file.open", target: None, + read: None, }); for _ in 0..6 { u.frame(vec![]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b4..026228dc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -236,6 +236,11 @@ pub trait Services { fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { Box::pin(std::future::ready(None)) } + /// Pick a file and read it, where the platform hands over a file's name and contents rather + /// than a path to it (the browser). `None`: the platform picks paths ([`Services::pick_open`]). + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + None + } fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { Box::pin(std::future::ready(None)) } @@ -290,14 +295,38 @@ pub trait Services { } pub type DialogFuture = Pin>>>; -type DialogDone = Box; +/// A picked file's name and contents ([`Services::pick_open_contents`]). +pub type ContentsFuture = Pin)>>>>; +/// A dropped file's contents, read once they arrive (`None`: they couldn't be read). +pub type ReadFuture = Pin>>>>; +type PickFuture = Pin>>>; +type DialogDone = Box; + +/// What a dialog picked: a path, or a file's name and contents where there are no paths. +enum Picked { + Path(PathBuf), + Contents(String, Vec), +} + +impl Picked { + /// The file as `file.open` and `file.import` take it. + fn file_params(self) -> Value { + match self { + Picked::Path(p) => json!({"path": p.to_string_lossy()}), + Picked::Contents(name, bytes) => { + use base64::Engine as _; + json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name}) + } + } + } +} /// A native dialog in flight. The app keeps working while it is open, so its result goes to the /// file and window it was opened for, not to whichever is in front when it closes. struct PendingDialog { - future: DialogFuture, - /// The path picked, kept until the window that holds `doc` draws. - path: Option, + future: PickFuture, + /// What was picked, kept until the window that holds `doc` draws. + picked: Option, done: DialogDone, /// The file it acts on (`None`: none, such as Open). doc: Option, @@ -1099,16 +1128,11 @@ impl FigApp { pub fn open_dialog(&mut self) { // Opening needs no file in front: it goes ahead even if that file closed meanwhile. - self.start_dialog( - |services| services.pick_open(i18n::tr("Open"), OPEN_EXTENSIONS), - false, - |app, p| { - let ps = p.to_string_lossy().to_string(); - if app.run_user("file.open", json!({"path": ps})).is_some() { - app.ui.home = false; - } - }, - ); + self.pick_file(i18n::tr("Open"), OPEN_EXTENSIONS, false, |app, file| { + if app.run_user("file.open", file).is_some() { + app.ui.home = false; + } + }); } pub fn import_dialog(&mut self, place: bool) { @@ -1118,12 +1142,30 @@ impl FigApp { &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"] }; let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; - self.pick_open(title, exts, move |app, p| { - let ps = p.to_string_lossy().to_string(); - app.run_user("file.import", json!({"path": ps})); + self.pick_file(title, exts, true, |app, file| { + app.run_user("file.import", file); }); } + /// Pick a file to open or import; `done` gets it as `file.open` and `file.import` take it (a + /// path, or the name and contents where the platform has no paths). `pin`: as + /// [`FigApp::pick_open`]'s file in front. + fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) { + self.start_pick( + |services| -> PickFuture { + match services.pick_open_contents(title, exts) { + Some(read) => Box::pin(async move { read.await.map(|(name, bytes)| Picked::Contents(name, bytes)) }), + None => { + let pick = services.pick_open(title, exts); + Box::pin(async move { pick.await.map(Picked::Path) }) + } + } + }, + pin, + move |app, picked| done(app, picked.file_params()), + ); + } + /// Pick a file to open. `done` runs with the file in front now back in front (and not at all /// if that file closed meanwhile). pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { @@ -1142,12 +1184,28 @@ impl FigApp { /// Open a native dialog from the window drawing now, for the file in front (`pin`). fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_pick( + |services| -> PickFuture { + let pick = start(services); + Box::pin(async move { pick.await.map(Picked::Path) }) + }, + pin, + move |app, picked| { + if let Picked::Path(p) = picked { + done(app, p); + } + }, + ); + } + + /// [`FigApp::start_dialog`] for any kind of pick. + fn start_pick(&mut self, start: impl FnOnce(&mut dyn Services) -> PickFuture, pin: bool, done: impl FnOnce(&mut FigApp, Picked) + 'static) { if self.pending_dialog.is_some() { return; } let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None }; let future = start(self.services.as_mut()); - self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current }); + self.pending_dialog = Some(PendingDialog { future, picked: None, done: Box::new(done), doc, window: self.windows.current }); if let Some(ctx) = &self.ui_context { ctx.request_repaint(); } @@ -1157,12 +1215,12 @@ impl FigApp { /// that file in front; then the tab in front before comes back (unless the dialog opened a file). fn poll_dialog(&mut self, ctx: &egui::Context) { let Some(d) = &mut self.pending_dialog else { return }; - if d.path.is_none() { + if d.picked.is_none() { let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); - let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; - d.path = path; + let Poll::Ready(picked) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; + d.picked = picked; } - let (doc, origin, picked) = (d.doc, d.window, d.path.is_some()); + let (doc, origin, picked) = (d.doc, d.window, d.picked.is_some()); let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u)); // Cancelled, or its file closed meanwhile. if !picked || index == Some(None) { @@ -1178,12 +1236,12 @@ impl FigApp { ctx.request_repaint_of(windows::viewport(window)); return; } - let Some(PendingDialog { path: Some(path), done, .. }) = self.pending_dialog.take() else { return }; + let Some(PendingDialog { picked: Some(picked), done, .. }) = self.pending_dialog.take() else { return }; let before = self.session.ds().ok().map(|d| d.uid); if let Some(Some(i)) = index { self.session.active = i; } - done(self, path); + done(self, picked); if doc.is_some() && self.session.ds().ok().map(|d| d.uid) == doc && let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u)) @@ -1693,17 +1751,17 @@ pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } -/// The contents of a dropped file that has no file on disk to read by path. +/// Reads the contents of a dropped file that has no file on disk to read by path. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> Option> { - f.bytes().ok() +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture { + Box::pin(std::future::ready(f.bytes().ok())) } -/// The contents of a dropped file that has no file on disk to read by path. +/// Reads the contents of a dropped file; the browser hands them over asynchronously. #[cfg(target_arch = "wasm32")] -pub(crate) fn dropped_bytes(_f: &egui::DroppedFileHandle) -> Option> { - // Browser drops arrive asynchronously; not supported yet. - None +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture { + let f = f.clone(); + Box::pin(async move { f.bytes_async().await.ok() }) } /// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle, diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index 9d19b942..ef798eca 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -1,13 +1,17 @@ //! Files dropped on the home screen or the canvas open a frame later, behind a card that says what //! is happening: opening a large `.fig` blocks the UI thread for a while, and the card is on screen -//! before it does (rather than a window that freezes without a word). +//! before it does (rather than a window that freezes without a word). A file with no path to read +//! (every drop in the browser) waits behind the card until its contents arrive. + +use std::sync::Arc; +use std::task::{Context as TaskContext, Poll, Waker}; use figcraft_doc::NodeId; -use serde_json::Value; +use serde_json::{Value, json}; -use crate::FigApp; use crate::i18n::{tr, trf, trn}; use crate::theme::{Tokens, semibold}; +use crate::{FigApp, ReadFuture, RepaintWaker}; /// A dropped file waiting to be opened (`file.open`, a document of its own) or imported /// (`file.import`, into the current document). @@ -18,6 +22,8 @@ pub struct PendingOpen { pub command: &'static str, /// Canvas imports stay with the document and page that received the drop. pub target: Option, + /// The file's contents still on their way; they go into `params` as `data` once read. + pub read: Option, } pub struct ImportTarget { @@ -74,15 +80,48 @@ fn cancel(app: &mut FigApp, message: String) { /// frame, so the second one is the first the window shows. const FRAMES_BEFORE_OPENING: u8 = 2; -/// The card while files wait, then (once it has been on screen) open them. Called before the import -/// report, so a report for these files shows after them. +/// Polls the contents still being read: those that arrived go into their file's parameters, files +/// that couldn't be read are dropped with a message. Whether any are still on their way. +fn read_contents(app: &mut FigApp, ctx: &egui::Context) -> bool { + let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); + let mut cx = TaskContext::from_waker(&waker); + let mut reading = false; + let mut unread = Vec::new(); + app.ui.pending_opens.retain_mut(|pending| { + let Some(read) = pending.read.as_mut() else { return true }; + match read.as_mut().poll(&mut cx) { + Poll::Pending => { + reading = true; + true + } + Poll::Ready(Some(bytes)) => { + use base64::Engine as _; + pending.params["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)); + pending.read = None; + true + } + Poll::Ready(None) => { + unread.push(pending.label.clone()); + false + } + } + }); + for name in unread { + cancel(app, trf("Couldn't read {name}.", &[("name", &name)])); + } + reading +} + +/// The card while files wait, then (once it has been on screen and their contents are read) open +/// them. Called before the import report, so a report for these files shows after them. pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { + let reading = read_contents(app, ctx); if app.ui.pending_opens.is_empty() { app.ui.pending_open_frames = 0; return; } ctx.request_repaint(); - if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING { + if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING && !reading { app.ui.pending_open_frames = 0; for pending in std::mem::take(&mut app.ui.pending_opens) { let command = pending.command; @@ -92,7 +131,7 @@ pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { } return; } - app.ui.pending_open_frames += 1; + app.ui.pending_open_frames = app.ui.pending_open_frames.saturating_add(1); let title = match app.ui.pending_opens.first().map(|p| p.command) { Some("file.import") => { let name = app diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index df822981..bc0de68d 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the web build opens files: File ▸ Open, Import and Place image through the browser's file picker, dropped files read (#759); macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -43,13 +43,14 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR | | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | -| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | +| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser: File ▸ Open, Import and Place image use its file picker, dropped files are read (a drop on the home screen opens, a drop on the canvas imports, as on the desktop), saving and exports are downloads; pickers for libraries, variables, plugin manifests and image fills still need a path and do nothing there; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Opening files in the web build (#759): File ▸ Open, Import and Place image did nothing there (the pickers return a path, which a browser never has) and dropped files were ignored (eframe hands their contents over asynchronously). A platform can now pick a file's name and contents (`Services::pick_open_contents`; the browser's ``), which `file.open` and `file.import` take as `data` and `name`, and a dropped file without a path waits behind the opening card until its contents arrive, or says it couldn't be read (`async_dialog_tests`, `home_tests`; the browser half built for wasm, not run in a browser). Numbers unchanged | | 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | | 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged | | 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | From c3cb8c3e0eca767a5e8cc59940c835695e0a78e6 Mon Sep 17 00:00:00 2001 From: Hamza Date: Sun, 18 Oct 2026 23:48:54 +0500 Subject: [PATCH 02/10] cli: confine info, export, convert, render and run to automation roots `figcraft-cli mcp` takes `--automation-read-root` / `--automation-write-root` to confine the files an agent's commands touch, but the file subcommands took neither: `figcraft-cli run script.json --out /anywhere/x.figcraft` read and wrote as the user who started it. The README invites agent use, and a script or agent driving `run`, `export` or `convert` had no way to say which folders it may reach (#833). `info`, `export`, `convert`, `render` and `run` take the same two flags, with the same semantics: repeatable, the folders must exist, reads inside a read or write root, writes only inside a write root, everything else refused -- so giving no write root denies writing altogether. Without the flags they behave as before. The flags would have been decorative on their own: `figcraft_engine` enforces roots through `file_access`, which the engine's own loads already go through, but the CLI wrote its output with `std::fs` and read its script the same way. Those three calls now go through `file_access` too, so the confinement reaches the files the CLI itself touches. --- apps/figcraft-cli/src/files.rs | 11 ++++-- apps/figcraft-cli/src/main.rs | 54 ++++++++++++++++++++------- apps/figcraft-cli/tests/cli.rs | 67 ++++++++++++++++++++++++++++++++++ docs/cli.md | 22 +++++++++++ 4 files changed, 136 insertions(+), 18 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 65b19ee4..9dc357b1 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -29,11 +29,13 @@ fn image_format(e: &str) -> Option { } } +/// Writes through [`figcraft_engine::file_access`], so a run given automation roots can only +/// write inside a write root (#833). Unconfined it writes as before. fn write(path: &str, bytes: &[u8]) -> CliResult<()> { if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?; + figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?; } - std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}"))) + figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string())) } fn page_named(doc: &Document, name: Option<&str>) -> CliResult { @@ -244,7 +246,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { println!("{out}"); return Ok(ExitCode::SUCCESS); } - std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?; + figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?; let ids = match target { Some(t) => vec![t], None => { @@ -329,7 +331,8 @@ pub fn parse_script(text: &str) -> Result, String> { } pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult { - let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; + let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?; + let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?; let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let mut s = match input { Some(path) => { diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 281d79dd..d9f757d0 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -22,6 +22,7 @@ USAGE: figcraft-cli convert figcraft-cli render -o [--scale N] [--page NAME] figcraft-cli run [--in ] [--out ] + (info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR) figcraft-cli commands [--json] [--filter TEXT] figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... figcraft-cli plugin publish --server ws://host:port/[?key=KEY] @@ -42,12 +43,34 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) `plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder) to a figcraft-server's plugin directory as a new version; the server's key comes from the address or the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history). -`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders -must exist) confine the files the agent's commands touch: reads only inside a read or write root, -writes only inside a write root, everything else refused. A running app confines itself: start it -with `figcraft --control PORT` and the same flags (`--attach` refuses them). +`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders +must exist) confine the files a subcommand touches: reads only inside a read or write root, writes +only inside a write root, everything else refused -- so giving no write root denies writing +altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`, +`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes +as the user who started it, as before. A running app confines itself: start it with +`figcraft --control PORT` and the same flags (`--attach` refuses them). See docs/cli.md and docs/mcp.md."; +/// The automation roots a file subcommand was given, if any. Omitting both leaves the run +/// unconfined, as before; giving either confines every file the subcommand touches (#833). +const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"]; + +fn roots_of(a: &Args) -> CliResult>> { + let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); + if read.is_empty() && write.is_empty() { + return Ok(None); + } + let roots = figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?; + Ok(Some(std::sync::Arc::new(roots))) +} + +/// Runs `f` confined to the roots `a` carries. +fn confined(a: &Args, f: impl FnOnce() -> CliResult) -> CliResult { + let roots = roots_of(a)?; + figcraft_engine::file_access::confined(roots.as_ref(), f) +} + fn main() -> ExitCode { let argv: Vec = std::env::args().skip(1).collect(); match run(&argv) { @@ -98,35 +121,38 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "info" => { - let a = Args::parse(rest, &[], &["json"])?; + let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?; let file = a.one_positional("info ")?; - files::info(&file, a.flag("json")) + confined(&a, || files::info(&file, a.flag("json"))) } "export" => { - let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?; + let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; let target = files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? }; - files::export(&file, &out, &target) + confined(&a, || files::export(&file, &out, &target)) } "convert" => { - let a = Args::parse(rest, &[], &[])?; + let a = Args::parse(rest, &ROOT_FLAGS, &[])?; match a.positional.as_slice() { - [i, o] => files::convert(i, o), + [i, o] => confined(&a, || files::convert(i, o)), _ => Err(CliError::Usage("convert ".into())), } } "render" => { - let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?; + let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; let file = a.one_positional("render -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".into()))?; - files::render(&file, &out, a.scale()?, a.value("page").as_deref()) + confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref())) } "run" => { - let a = Args::parse(rest, &["in", "out"], &[])?; + let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; let script = a.one_positional("run ")?; - files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()) + confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())) } "commands" => { let a = Args::parse(rest, &["filter"], &["json"])?; diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 7eaf051f..399e57bb 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -172,3 +172,70 @@ fn commands_json_lists_everything() { let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); assert!(v.as_array().unwrap().len() > 200); } + +/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a +/// root, and a write outside a write root, are refused and leave no file behind. Without the flags +/// the same commands behave as they always did. +#[test] +fn file_subcommands_are_confined_to_automation_roots() { + let d = tmp("file-roots"); + let _ = std::fs::remove_dir_all(&d); + for sub in ["in", "out"] { + std::fs::create_dir_all(d.join(sub)).unwrap(); + } + let p = |rel: &str| d.join(rel).to_string_lossy().into_owned(); + let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned(); + + // A document to work on, made unconfined. + std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap(); + let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap(); + assert!(made.status.success(), "{}", out_of(&made)); + + // Inside the roots: renders as usual. + let ok = bin() + .args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")]) + .output() + .unwrap(); + assert!(ok.status.success(), "{}", out_of(&ok)); + assert!(d.join("out/ok.png").is_file()); + + // A write outside the write root is refused, and nothing is written. + let escaped = d.join("escaped.png"); + let refused = bin() + .args([ + "render", + &p("in/doc.figcraft"), + "-o", + &escaped.to_string_lossy(), + "--automation-read-root", + &p("in"), + "--automation-write-root", + &p("out"), + ]) + .output() + .unwrap(); + assert!(!refused.status.success()); + assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused)); + assert!(!escaped.exists(), "the refused render wrote a file anyway"); + + // No write root at all: writing is refused outright, reading still works. + let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap(); + assert!(!no_write.status.success()); + assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write)); + + // A read outside every root is refused: `info` on the document with only `out` readable. + let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap(); + assert!(!read_refused.status.success()); + assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused)); + + // `convert` and `run` take them as well, and a root that is not a folder is refused. + let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap(); + assert!(!missing.status.success()); + assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing)); + + // Unconfined, the same render still writes wherever it is told. + let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap(); + assert!(plain.status.success(), "{}", out_of(&plain)); + assert!(escaped.is_file()); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/docs/cli.md b/docs/cli.md index 7af73fd6..699b6ba2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -153,6 +153,28 @@ figcraft-cli run start.json --out dashboard.figcraft figcraft-cli export dashboard.figcraft -o dashboard.png ``` +### Confining a run: `--automation-read-root DIR` / `--automation-write-root DIR` + +`info`, `export`, `convert`, `render` and `run` take these two flags, with the same meaning they +have on [`mcp`](#mcp---attach-port---automation-read-root-dir---automation-write-root-dir): each is +repeatable, each folder must exist, and together they confine every file that subcommand touches. +Reads are allowed inside a read or write root, writes only inside a write root, and everything else +is refused — so a run given only a read root cannot write at all. Without either flag a subcommand +reads and writes as the user who started it. + +```sh +# Renders in/doc.figcraft to out/ok.png and can touch nothing else. +figcraft-cli render in/doc.figcraft -o out/ok.png \ + --automation-read-root in --automation-write-root out + +figcraft-cli render in/doc.figcraft -o ./escaped.png \ + --automation-read-root in --automation-write-root out +# error: automation path rejected: . is outside the folders that may be written: …/out +``` + +This is for driving the CLI from a script or an agent that should not reach the rest of the disk; +the flags leave the output identical when nothing strays outside the roots. + ### `commands [--json] [--filter TEXT]` Every command with its label, shortcut, undoable flag (`*`) and parameter docs; `--json` for From 35cc685e90e42bce8ce30a049c13bd8985dd6bf9 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 19 Oct 2026 00:52:40 +0500 Subject: [PATCH 03/10] cli: an unconfined write still follows a symbolic link Routing the CLI's writes through `file_access` changed what happens when the output path is a symbolic link. `file_access::write` stages the bytes and renames them into place, and a rename replaces the link with a regular file, where `std::fs::write` followed it and wrote to its target. A render to a link reported success while the target kept its old contents. Unconfined writes go back to `std::fs::write`. Confined ones keep the staged write, which is right there: `Roots::check` resolves the path first and has already refused a link leading out of the root, so the write lands on the resolved target inside it. --- apps/figcraft-cli/src/files.rs | 15 ++++++++++++--- apps/figcraft-cli/tests/cli.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 9dc357b1..46215599 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -29,13 +29,22 @@ fn image_format(e: &str) -> Option { } } -/// Writes through [`figcraft_engine::file_access`], so a run given automation roots can only -/// write inside a write root (#833). Unconfined it writes as before. +/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a +/// confined run can only write inside a write root (#833). +/// +/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the +/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of +/// writing through it to its target -- a change of behaviour for anyone whose output path is a +/// link, and nothing to do with confinement. Inside a root the staged write is the right one: +/// `Roots::check` has already refused a link that leads out of the root. fn write(path: &str, bytes: &[u8]) -> CliResult<()> { if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?; } - figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string())) + if figcraft_engine::file_access::current().is_some() { + return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string())); + } + std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}"))) } fn page_named(doc: &Document, name: Option<&str>) -> CliResult { diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 399e57bb..eb42b8d8 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -239,3 +239,31 @@ fn file_subcommands_are_confined_to_automation_roots() { assert!(escaped.is_file()); let _ = std::fs::remove_dir_all(&d); } + +/// An output path that is a symbolic link is written through, to its target, as `std::fs::write` +/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have +/// replaced the link with a regular file, because that write stages the bytes and renames them +/// into place. +#[cfg(unix)] +#[test] +fn an_unconfined_write_follows_a_symbolic_link() { + let d = tmp("symlink-out"); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("real")).unwrap(); + let p = |rel: &str| d.join(rel).to_string_lossy().into_owned(); + std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap(); + let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap(); + assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr)); + + let target = d.join("real/target.png"); + std::fs::write(&target, b"placeholder").unwrap(); + let link = d.join("link.png"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link"); + let written = std::fs::read(&target).unwrap(); + assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder"); + let _ = std::fs::remove_dir_all(&d); +} From 63dc418cc288bd78dfbd009b4499153495e53a7d Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Sun, 18 Oct 2026 23:50:27 +0200 Subject: [PATCH 04/10] M7: a web file picker closed without a cancel event settles on refocus (#759) Co-authored-by: Cursor --- apps/figcraft-web/src/web.rs | 62 ++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 01511252..dd767116 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -162,19 +162,11 @@ async fn pick_file(accept: &str) -> Result)>, wasm_bindg input.set_hidden(true); // Some browsers (older Safari) open the picker only for an input in the page. body.append_child(&input)?; - // Settles on `change` (a file was picked) or `cancel` (the picker was closed without one). - let mut settle = |resolve: js_sys::Function, _reject: js_sys::Function| { - let done = wasm_bindgen::closure::Closure::once_into_js(move || { - let _ = resolve.call0(&wasm_bindgen::JsValue::NULL); - }); - let _ = input.add_event_listener_with_callback("change", done.unchecked_ref()); - let _ = input.add_event_listener_with_callback("cancel", done.unchecked_ref()); - }; - let closed = js_sys::Promise::new(&mut settle); + let (closed, picker) = PickerClosed::new(&window, input.clone()); input.click(); let result = wasm_bindgen_futures::JsFuture::from(closed).await; let file = input.files().and_then(|files| files.get(0)); - input.remove(); + drop(picker); result?; let Some(file) = file else { return Ok(None) }; if file.size() > MAX_PICKED_BYTES { @@ -184,6 +176,56 @@ async fn pick_file(accept: &str) -> Result)>, wasm_bindg Ok(Some((file.name(), js_sys::Uint8Array::new(&buffer).to_vec()))) } +/// How long after the page gets focus back the file picker counts as closed (milliseconds): a +/// picked file's `change` can come just after the focus, and must win. +const PICKER_FOCUS_SETTLE_MS: i32 = 1_000; + +/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed +/// without one) or shortly after the page gets focus back, for browsers that never send `cancel` +/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one. +/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer. +struct PickerClosed { + window: web_sys::Window, + input: web_sys::HtmlInputElement, + _settle: wasm_bindgen::closure::Closure, + on_focus: wasm_bindgen::closure::Closure, + timer: std::rc::Rc>>, +} + +impl PickerClosed { + fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) { + let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::)); + let slot = resolve.clone(); + let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res)); + let settle = wasm_bindgen::closure::Closure::::new(move || { + if let Some(res) = resolve.borrow_mut().take() { + let _ = res.call0(&wasm_bindgen::JsValue::NULL); + } + }); + let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref()); + let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref()); + let timer = std::rc::Rc::new(std::cell::Cell::new(None)); + let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::().clone()); + let on_focus = wasm_bindgen::closure::Closure::::new(move || { + if t.get().is_none() { + t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok()); + } + }); + let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref()); + (closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer }) + } +} + +impl Drop for PickerClosed { + fn drop(&mut self) { + if let Some(t) = self.timer.take() { + self.window.clear_timeout_with_handle(t); + } + let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref()); + self.input.remove(); + } +} + fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; From 544b2455c9ab82fbba44c95a3b3396da9c22bdc2 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 16:49:34 +0200 Subject: [PATCH 05/10] M8: a path in a root reached through a link is told why it is refused When a path can't be resolved (a link that leads nowhere, `..` below a missing folder), the reason is given only for paths inside a root. That check compared the path as typed with the roots resolved, so a root reached through a link (`/tmp/work` on macOS is `/private/tmp/work`) called its own dangling links "outside the folders that may be written: /private/tmp/work", and a link out of a root still had its reason told. The existing part of the path is now resolved before the comparison, as the roots were. This is also why `links_that_lead_out_of_a_root_are_outside` and `outside_paths_are_refused_alike_whatever_exists_there` failed on macOS, where the temp folder is under `/var` -> `/private/var`. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_access.rs | 22 +++++++++++++++++----- crates/engine/src/file_access_tests.rs | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 8abeaee5..709b34a7 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -62,7 +62,7 @@ impl Roots { Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved), Ok(_) => {} // Why a path can't be resolved says what exists there: only told for paths inside a root. - Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")), + Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")), Err(_) => {} } if roots.is_empty() { @@ -79,9 +79,10 @@ impl Roots { } } -/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one -/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix). -fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool { +/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and +/// its longest leading part that exists resolved, so links on the way are followed as the roots' +/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it). +fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool { let Ok(abs) = std::path::absolute(path) else { return false }; let mut lexical = PathBuf::new(); for c in abs.components() { @@ -93,7 +94,18 @@ fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool { c => lexical.push(c), } } - roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r))) + let mut rest: Vec<&OsStr> = Vec::new(); + let mut at = lexical.as_path(); + let resolved = loop { + if let Ok(mut base) = std::fs::canonicalize(at) { + base.extend(rest.iter().rev()); + break base; + } + let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false }; + rest.push(name); + at = parent; + }; + roots.iter().any(|r| resolved.starts_with(r)) } /// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed); diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/file_access_tests.rs index a05ad88c..ba55e134 100644 --- a/crates/engine/src/file_access_tests.rs +++ b/crates/engine/src/file_access_tests.rs @@ -348,6 +348,10 @@ fn outside_paths_are_refused_alike_whatever_exists_there() { let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")]; if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) { outside.push(f.p("outside/dangling.txt")); + // Reached through a link in the root, it is outside all the same. + if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) { + outside.push(f.p("root/escape/dangling.txt")); + } } for p in &outside { let e = refusal(p); @@ -359,6 +363,19 @@ fn outside_paths_are_refused_alike_whatever_exists_there() { } } +#[test] +fn a_root_given_through_a_link_tells_why_paths_in_it_are_refused() { + let f = Fixture::new("linked-root"); + if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) { + eprintln!("skipped: this system doesn't let tests make symbolic links"); + return; + } + // Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`. + let r = Roots::new(&[], &[f.p("via")]).unwrap(); + let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err(); + assert!(e.contains("leads nowhere"), "{e}"); +} + #[test] fn only_resolved_paths_count_as_the_apps_own_folder() { let f = Fixture::new("own"); From 40d7ce14ce94a86f6ad251624be43961f0adeb3c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:18:38 +0000 Subject: [PATCH 06/10] ci(deps): bump actions/checkout Bumps the actions group with 1 update in the / directory: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 4 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e613617a..9700a8e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: name: test (ubuntu-latest) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Linux system dependencies (egui/winit/rfd) run: | @@ -74,7 +74,7 @@ jobs: name: wasm32 check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown From 5738ce673c890541b519af4e3f41b48009fec98b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 22:20:37 +0700 Subject: [PATCH 07/10] #839: mcp reuses roots_of for its automation roots --- apps/figcraft-cli/src/main.rs | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index d9f757d0..badc5bc3 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -56,18 +56,17 @@ See docs/cli.md and docs/mcp.md."; /// unconfined, as before; giving either confines every file the subcommand touches (#833). const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"]; -fn roots_of(a: &Args) -> CliResult>> { +fn roots_of(a: &Args) -> CliResult> { let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); if read.is_empty() && write.is_empty() { return Ok(None); } - let roots = figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?; - Ok(Some(std::sync::Arc::new(roots))) + Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?)) } /// Runs `f` confined to the roots `a` carries. fn confined(a: &Args, f: impl FnOnce() -> CliResult) -> CliResult { - let roots = roots_of(a)?; + let roots = roots_of(a)?.map(std::sync::Arc::new); figcraft_engine::file_access::confined(roots.as_ref(), f) } @@ -163,23 +162,20 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "mcp" => { - let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?; + let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } - let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); - let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) { - (true, _) => None, - // The app runs the commands: only its own roots can confine them. - (false, Some(_)) => { - return Err(CliError::Usage( - "--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)" - .into(), - )); - } - (false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?), - }; - mcp(a.value("attach").as_deref(), roots) + let attach = a.value("attach"); + // The app runs the commands: only its own roots can confine them. + if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) { + return Err(CliError::Usage( + "--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)" + .into(), + )); + } + mcp(attach.as_deref(), roots_of(&a)?) } "plugin" => { let a = Args::parse(rest, &["server"], &[])?; From bafc814712761919efbba4ae80ff473650d84a46 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 22:22:32 +0700 Subject: [PATCH 08/10] #830: drop the progress-doc edit (docs/hardware-parity.md) --- docs/hardware-parity.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index bc0de68d..df822981 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the web build opens files: File ▸ Open, Import and Place image through the browser's file picker, dropped files read (#759); macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -43,14 +43,13 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR | | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | -| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser: File ▸ Open, Import and Place image use its file picker, dropped files are read (a drop on the home screen opens, a drop on the canvas imports, as on the desktop), saving and exports are downloads; pickers for libraries, variables, plugin manifests and image fills still need a path and do nothing there; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | +| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | ## Revision history | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Opening files in the web build (#759): File ▸ Open, Import and Place image did nothing there (the pickers return a path, which a browser never has) and dropped files were ignored (eframe hands their contents over asynchronously). A platform can now pick a file's name and contents (`Services::pick_open_contents`; the browser's ``), which `file.open` and `file.import` take as `data` and `name`, and a dropped file without a path waits behind the opening card until its contents arrive, or says it couldn't be read (`async_dialog_tests`, `home_tests`; the browser half built for wasm, not run in a browser). Numbers unchanged | | 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | | 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged | | 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | From b744ff3e145353ee271d2c792f575ef58fce5f94 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 22:39:23 +0700 Subject: [PATCH 09/10] #830: cap files read in the browser at 512 MiB, and say why a file isn't opened --- apps/figcraft-web/src/web.rs | 25 ++++---- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/async_dialog_tests.rs | 32 +++++++++- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/home.rs | 7 ++- crates/ui-egui/src/home_tests.rs | 11 ++-- crates/ui-egui/src/lib.rs | 74 ++++++++++++++++++------ crates/ui-egui/src/pending_open.rs | 13 ++--- 22 files changed, 136 insertions(+), 43 deletions(-) diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index dd767116..f08ca90f 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -147,12 +147,10 @@ impl Services for WebServices { } } -/// The largest file read from the picker (browsers read at most 4 GiB into one buffer). -const MAX_PICKED_BYTES: f64 = u32::MAX as f64; - /// The file picked in the browser's file picker (`accept`: the extensions it offers), with its -/// name; `None` when the picker is cancelled. -async fn pick_file(accept: &str) -> Result)>, wasm_bindgen::JsValue> { +/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file +/// can't be read. +async fn pick_file(accept: &str) -> Result), String>>, wasm_bindgen::JsValue> { let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; let body = document.body().ok_or("no body")?; @@ -169,15 +167,22 @@ async fn pick_file(accept: &str) -> Result)>, wasm_bindg drop(picker); result?; let Some(file) = file else { return Ok(None) }; - if file.size() > MAX_PICKED_BYTES { - return Err(format!("{} is too large to read in the browser", file.name()).into()); + let name = file.name(); + if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) { + return Ok(Some(Err(why))); } - let buffer = wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await?; - Ok(Some((file.name(), js_sys::Uint8Array::new(&buffer).to_vec()))) + Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await { + Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())), + Err(e) => { + log::error!("couldn't read {name}: {e:?}"); + Err(figcraft_ui_egui::unreadable(&name)) + } + })) } /// How long after the page gets focus back the file picker counts as closed (milliseconds): a -/// picked file's `change` can come just after the focus, and must win. +/// picked file's `change` can come just after the focus, and must win. A `change` later than this +/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel. const PICKER_FOCUS_SETTLE_MS: i32 = 1_000; /// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 98051314..8ef21966 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1629,6 +1629,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "Zapomenuto v tomto počítači: {name}; soubor zůstává na serveru {server}" "{name} is on the server." = "{name} je na serveru." "{name} is spotlighting" = "{name} sdílí pohled" +"{name} is too large to open in the browser (over 512 MB)." = "Soubor {name} je příliš velký na otevření v prohlížeči (více než 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKliknutím použijete na výběr" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPřetáhněte na plátno nebo kliknutím umístěte doprostřed zobrazení.\nPři puštění na instanci podržte Alt (Option) a vyměňte ji." "{name} (from {library})" = "{name} (z {library})" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 07669c1a..2294e82d 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1583,6 +1583,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} wurde auf diesem Computer vergessen; die Datei bleibt auf {server}" "{name} is on the server." = "{name} liegt auf dem Server." "{name} is spotlighting" = "{name} hat das Spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} ist zu groß, um im Browser geöffnet zu werden (über 512 MB)." "{name} was removed from the server" = "{name} wurde vom Server entfernt" "{name} · deleted on main" = "{name} · in der Hauptdatei gelöscht" "{name} · deleted on the branch" = "{name} · im Branch gelöscht" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 5d5b1856..9a7aba63 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1668,6 +1668,7 @@ name = "English" "{name} is forgotten on this computer; the file stays on {server}" = "{name} is forgotten on this computer; the file stays on {server}" "{name} is on the server." = "{name} is on the server." "{name} is spotlighting" = "{name} is spotlighting" +"{name} is too large to open in the browser (over 512 MB)." = "{name} is too large to open in the browser (over 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nClick to apply to the selection" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." "{name} (from {library})" = "{name} (from {library})" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 56b6d29c..b4c7c335 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1669,6 +1669,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en este ordenador; el archivo se queda en {server}" "{name} is on the server." = "{name} está en el servidor." "{name} is spotlighting" = "{name} está en el foco" +"{name} is too large to open in the browser (over 512 MB)." = "{name} es demasiado grande para abrirlo en el navegador (más de 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nHaz clic para aplicarlo a la selección" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nArrastra al lienzo o haz clic para colocarlo en el centro de la vista.\nMantén Alt (Opción) al soltar sobre una instancia para intercambiarla." "{name} (from {library})" = "{name} (de {library})" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 602574d7..2521e46c 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1676,6 +1676,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} on unohdettu tällä tietokoneella. Tiedosto jää palvelimelle {server}." "{name} is on the server." = "{name} on palvelimella." "{name} is spotlighting" = "{name} nostaa näkymäänsä esiin" +"{name} is too large to open in the browser (over 512 MB)." = "{name} on liian suuri avattavaksi selaimessa (yli 512 Mt)." "{name} was removed from the server" = "{name} poistettiin palvelimelta" "{name} · deleted on main" = "{name} · poistettu päähaarassa" "{name} · deleted on the branch" = "{name} · poistettu haarassa" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5edf1a62..1faff4c3 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1665,6 +1665,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}" "{name} is on the server." = "{name} est sur le serveur." "{name} is spotlighting" = "{name} est sous les projecteurs" +"{name} is too large to open in the browser (over 512 MB)." = "{name} est trop volumineux pour être ouvert dans le navigateur (plus de 512 Mo)." "{name} was removed from the server" = "{name} a été retiré du serveur" "{name} · deleted on main" = "{name} · supprimé dans le principal" "{name} · deleted on the branch" = "{name} · supprimé dans la branche" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 65a9939c..705484d8 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1633,6 +1633,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} dilupakan di komputer ini; file tetap ada di {server}" "{name} is on the server." = "{name} ada di server." "{name} is spotlighting" = "{name} sedang spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} terlalu besar untuk dibuka di browser (lebih dari 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKlik untuk menerapkan ke pilihan" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nSeret ke kanvas, atau klik untuk menaruh di tengah tampilan.\nTahan Alt (Option) saat melepas di atas instance untuk menukarnya." "{name} (from {library})" = "{name} (dari {library})" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index def8ebc4..069eb09a 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1650,6 +1650,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} の記録をこのコンピューターから消去済み。ファイルは {server} に残る" "{name} is on the server." = "{name} はサーバーにある。" "{name} is spotlighting" = "{name} がスポットライト中" +"{name} is too large to open in the browser (over 512 MB)." = "{name} は大きすぎるため、ブラウザーで開けません(512 MB 超)。" "{name} was removed from the server" = "{name} をサーバーから削除済み" "{name} · deleted on main" = "{name} · メインで削除" "{name} · deleted on the branch" = "{name} · ブランチで削除" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 3e191623..e3d8bae8 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1574,6 +1574,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name}의 기록을 이 컴퓨터에서 지움. 파일은 {server}에 남음" "{name} is on the server." = "{name}이(가) 서버에 있음." "{name} is spotlighting" = "{name} 님이 스포트라이트 중" +"{name} is too large to open in the browser (over 512 MB)." = "{name}은(는) 너무 커서 브라우저에서 열 수 없습니다(512MB 초과)." "{name} was removed from the server" = "{name}을(를) 서버에서 제거함" "{name} · deleted on main" = "{name} · 메인에서 삭제됨" "{name} · deleted on the branch" = "{name} · 브랜치에서 삭제됨" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 8d8cac68..7a8b41f4 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1673,6 +1673,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} is op deze computer vergeten; het bestand blijft op {server}" "{name} is on the server." = "{name} staat op de server." "{name} is spotlighting" = "{name} staat in de spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} is te groot om in de browser te openen (meer dan 512 MB)." "{name} was removed from the server" = "{name} is van de server verwijderd" "{name} · deleted on main" = "{name} · verwijderd op main" "{name} · deleted on the branch" = "{name} · verwijderd op de branch" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d970db14..1fdbd1a3 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1573,6 +1573,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} foi esquecido neste computador; o arquivo continua em {server}" "{name} is on the server." = "{name} está no servidor." "{name} is spotlighting" = "{name} está em destaque" +"{name} is too large to open in the browser (over 512 MB)." = "{name} é grande demais para abrir no navegador (mais de 512 MB)." "{name} was removed from the server" = "{name} foi removido do servidor" "{name} · deleted on main" = "{name} · excluído no principal" "{name} · deleted on the branch" = "{name} · excluído na branch" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 8d371cbe..17e50e77 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1635,6 +1635,7 @@ name = "Русский" "{name} is forgotten on this computer; the file stays on {server}" = "{name} забыт на этом компьютере; файл остаётся на {server}" "{name} is on the server." = "{name} находится на сервере." "{name} is spotlighting" = "{name} показывает экран всем" +"{name} is too large to open in the browser (over 512 MB)." = "Файл {name} слишком велик для открытия в браузере (больше 512 МБ)." "{name}\nClick to apply to the selection" = "{name}\nНажмите, чтобы применить к выделению" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nПеретащите на холст или нажмите, чтобы разместить в центре вида.\nУдерживайте Alt (Option) при переносе на экземпляр, чтобы заменить его." "{name} (from {library})" = "{name} (из {library})" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d57bb25a..c57f2f6a 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1650,6 +1650,7 @@ name = "Svenska" "{name} is forgotten on this computer; the file stays on {server}" = "{name} har glömts på den här datorn; filen finns kvar på {server}" "{name} is on the server." = "{name} finns på servern." "{name} is spotlighting" = "{name} har spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} är för stor för att öppnas i webbläsaren (över 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKlicka för att använda på markeringen" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDra till arbetsytan, eller klicka för att placera i mitten av vyn.\nHåll ned Alt (Option) när du släpper på en instans för att byta ut den." "{name} (from {library})" = "{name} (från {library})" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a9da62ef..83b63c8c 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1633,6 +1633,7 @@ name = "Türkçe" "{name} is forgotten on this computer; the file stays on {server}" = "{name} bu bilgisayarda unutuldu; dosya {server} üzerinde kalır" "{name} is on the server." = "{name} sunucuda." "{name} is spotlighting" = "{name} öne çıkarıyor" +"{name} is too large to open in the browser (over 512 MB)." = "{name}, tarayıcıda açılamayacak kadar büyük (512 MB'tan fazla)." "{name}\nClick to apply to the selection" = "{name}\nSeçime uygulamak için tıklayın" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nTuvale sürükleyin veya görünümün ortasına yerleştirmek için tıklayın.\nBir örneği değiştirmek için üzerine bırakırken Alt (Option) tuşunu basılı tutun." "{name} (from {library})" = "{name} ({library} kitaplığından)" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a90c1c03..57f2d44b 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1568,6 +1568,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "已在这台电脑上忘记 {name};文件仍保留在 {server} 上" "{name} is on the server." = "{name} 已在服务器上。" "{name} is spotlighting" = "{name} 正在聚焦" +"{name} is too large to open in the browser (over 512 MB)." = "{name} 太大,无法在浏览器中打开(超过 512 MB)。" "{name}\nClick to apply to the selection" = "{name}\n点击应用于所选项" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\n拖到画布上,或点击放置在视图中央。\n拖放到实例上时按住 Alt(Option)可替换该实例。" "{name} (from {library})" = "{name}(来自 {library})" diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs index ad5dcfbc..f38ac824 100644 --- a/crates/ui-egui/src/async_dialog_tests.rs +++ b/crates/ui-egui/src/async_dialog_tests.rs @@ -87,10 +87,40 @@ impl Services for Contents { panic!("a platform that picks contents is never asked for a path") } fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { - Some(Box::pin(std::future::ready(Some((self.0.to_string(), self.1.clone()))))) + Some(Box::pin(std::future::ready(Some(Ok((self.0.to_string(), self.1.clone())))))) } } +/// The browser, with a picked file it won't read. +struct Unreadable(String); + +impl Services for Unreadable { + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + Some(Box::pin(std::future::ready(Some(Err(self.0.clone()))))) + } +} + +#[test] +fn a_picked_file_that_cannot_be_read_says_why_and_opens_nothing() { + let why = crate::browser_can_read("Huge.fig", 600.0 * 1024.0 * 1024.0).unwrap_err(); + let mut app = FigApp::new(Session::empty(), Box::new(Unreadable(why.clone()))); + app.open_dialog(); + app.poll_dialog(&egui::Context::default()); + assert!(app.session.docs.is_empty()); + assert_eq!(app.ui.last_error.as_deref(), Some(why.as_str())); + assert!(why.contains("Huge.fig"), "{why}"); +} + +#[test] +fn the_browser_reads_files_up_to_512_mib() { + let max = (512u64 << 20) as f64; + assert!(crate::browser_can_read("a", 0.0).is_ok()); + assert!(crate::browser_can_read("a", max).is_ok()); + assert!(crate::browser_can_read("a", max + 1.0).is_err()); + assert!(crate::browser_can_read("a", f64::NAN).is_err()); + assert!(crate::browser_can_read("a", f64::INFINITY).is_err()); +} + #[test] fn open_dialog_opens_picked_contents_as_a_new_unsaved_document() { let bytes = figcraft_format::save_bytes(&figcraft_doc::Document::new("Picked"), None).unwrap(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ce003f66..fba8be68 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -510,7 +510,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { let (params, read) = if path.exists() { (json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None) } else { - (json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f))) + (json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f, &name))) }; let Some(target) = app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() }) diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 0d372d1b..f945b27d 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -187,8 +187,11 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } // `opens` saw an extension, so there is a file name to tell the format by. let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let (params, read) = - if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(&f))) }; + let (params, read) = if path.exists() { + (json!({"path": path.to_string_lossy()}), None) + } else { + (json!({"name": name}), Some(crate::dropped_bytes(&f, &name))) + }; app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read }); } } diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index ea812ea0..0606c2e5 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -573,9 +573,12 @@ fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() { assert!(!path.exists(), "nothing is written"); } -/// A drop whose contents arrive later, as every drop in the browser does: `None` until `arrived` +/// What a drop's read gives, once it has arrived. +type Arrival = std::rc::Rc, String>>>>; + +/// A drop whose contents arrive later, as every drop in the browser does: pending until `arrived` /// holds them. -fn later(arrived: &std::rc::Rc>>>>) -> crate::ReadFuture { +fn later(arrived: &Arrival) -> crate::ReadFuture { let arrived = arrived.clone(); Box::pin(std::future::poll_fn(move |_| match arrived.borrow_mut().take() { Some(bytes) => std::task::Poll::Ready(bytes), @@ -603,7 +606,7 @@ fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() { assert!(has_text(&s, "Browser drop.svg"), "the card shows while the contents are read"); } assert!(u.app.session.docs.is_empty(), "nothing opens before the contents arrive"); - *arrived.borrow_mut() = Some(Some(svg)); + *arrived.borrow_mut() = Some(Ok(svg)); for _ in 0..3 { u.frame(vec![]); } @@ -617,7 +620,7 @@ fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() { #[test] fn a_dropped_file_that_cannot_be_read_says_so_and_opens_nothing() { - let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(None))); + let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(Err(crate::unreadable("Unreadable.figcraft"))))); let mut u = first_run(); u.app.ui.home = true; u.app.ui.pending_opens.push(crate::pending_open::PendingOpen { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 026228dc..4b44c861 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -295,32 +295,55 @@ pub trait Services { } pub type DialogFuture = Pin>>>; -/// A picked file's name and contents ([`Services::pick_open_contents`]). -pub type ContentsFuture = Pin)>>>>; -/// A dropped file's contents, read once they arrive (`None`: they couldn't be read). -pub type ReadFuture = Pin>>>>; +/// A picked file's name and contents ([`Services::pick_open_contents`]); `None` when the picker +/// was cancelled, `Err` with what to tell the user when the file couldn't be read. +pub type ContentsFuture = Pin), String>>>>>; +/// A dropped file's contents, read once they arrive (`Err`: what to tell the user when they +/// couldn't be read). +pub type ReadFuture = Pin, String>>>>; type PickFuture = Pin>>>; type DialogDone = Box; -/// What a dialog picked: a path, or a file's name and contents where there are no paths. +/// What a dialog picked: a path, or a file's name and contents where there are no paths, or why +/// the picked file couldn't be read. enum Picked { Path(PathBuf), Contents(String, Vec), + Unreadable(String), } impl Picked { - /// The file as `file.open` and `file.import` take it. - fn file_params(self) -> Value { + /// The file as `file.open` and `file.import` take it (`Err`: what to tell the user instead). + fn file_params(self) -> Result { match self { - Picked::Path(p) => json!({"path": p.to_string_lossy()}), + Picked::Path(p) => Ok(json!({"path": p.to_string_lossy()})), Picked::Contents(name, bytes) => { use base64::Engine as _; - json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name}) + Ok(json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name})) } + Picked::Unreadable(why) => Err(why), } } } +/// What the user is told about a file whose contents couldn't be read. +pub fn unreadable(name: &str) -> String { + i18n::trf("Couldn't read {name}.", &[("name", &name)]) +} + +/// The largest file read in the browser: wasm has at most 4 GiB of memory, and a file's contents +/// are held about three times on their way to `file.open` (the bytes, base64, decoded again). +/// As large as one member of a `.figcraft` file may be. +const MAX_BROWSER_FILE: f64 = (512u64 << 20) as f64; + +/// Checks a file of `size` bytes before the browser reads it (`Err`: what to tell the user). +pub fn browser_can_read(name: &str, size: f64) -> Result<(), String> { + if size.is_nan() || size > MAX_BROWSER_FILE { + return Err(i18n::trf("{name} is too large to open in the browser (over 512 MB).", &[("name", &name)])); + } + Ok(()) +} + /// A native dialog in flight. The app keeps working while it is open, so its result goes to the /// file and window it was opened for, not to whichever is in front when it closes. struct PendingDialog { @@ -809,6 +832,11 @@ impl FigApp { fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) { let msg = e.to_string(); log::warn!("{id}: {msg}"); + self.show_error(msg); + } + + /// Show an error as a toast, kept as the last error. + pub(crate) fn show_error(&mut self, msg: String) { self.toast(msg.clone()); self.ui.last_error = Some(msg); } @@ -1154,7 +1182,9 @@ impl FigApp { self.start_pick( |services| -> PickFuture { match services.pick_open_contents(title, exts) { - Some(read) => Box::pin(async move { read.await.map(|(name, bytes)| Picked::Contents(name, bytes)) }), + Some(read) => { + Box::pin(async move { read.await.map(|r| r.map_or_else(Picked::Unreadable, |(name, bytes)| Picked::Contents(name, bytes))) }) + } None => { let pick = services.pick_open(title, exts); Box::pin(async move { pick.await.map(Picked::Path) }) @@ -1162,7 +1192,10 @@ impl FigApp { } }, pin, - move |app, picked| done(app, picked.file_params()), + move |app, picked| match picked.file_params() { + Ok(file) => done(app, file), + Err(why) => app.show_error(why), + }, ); } @@ -1751,17 +1784,22 @@ pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } -/// Reads the contents of a dropped file that has no file on disk to read by path. +/// Reads the contents of dropped file `name` that has no file on disk to read by path. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture { - Box::pin(std::future::ready(f.bytes().ok())) +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture { + Box::pin(std::future::ready(f.bytes().map_err(|_| unreadable(name)))) } -/// Reads the contents of a dropped file; the browser hands them over asynchronously. +/// Reads the contents of dropped file `name`; the browser hands them over asynchronously. #[cfg(target_arch = "wasm32")] -pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture { - let f = f.clone(); - Box::pin(async move { f.bytes_async().await.ok() }) +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture { + let (f, name) = (f.clone(), name.to_string()); + Box::pin(async move { + if let Some(file) = f.web_file() { + browser_can_read(&name, file.size())?; + } + f.bytes_async().await.map_err(|_| unreadable(&name)) + }) } /// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle, diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index ef798eca..9575a1ea 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -72,8 +72,7 @@ fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { /// An import that has no destination left: nothing is imported, and the user is told why. fn cancel(app: &mut FigApp, message: String) { - app.ui.last_error = Some(message.clone()); - app.toast(message); + app.show_error(message); } /// Frames the card is drawn before the files open: egui lays a new card out invisibly on its first @@ -94,20 +93,20 @@ fn read_contents(app: &mut FigApp, ctx: &egui::Context) -> bool { reading = true; true } - Poll::Ready(Some(bytes)) => { + Poll::Ready(Ok(bytes)) => { use base64::Engine as _; pending.params["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)); pending.read = None; true } - Poll::Ready(None) => { - unread.push(pending.label.clone()); + Poll::Ready(Err(why)) => { + unread.push(why); false } } }); - for name in unread { - cancel(app, trf("Couldn't read {name}.", &[("name", &name)])); + for why in unread { + app.show_error(why); } reading } From 80c8c7fa8775cc02ba520a4e242b4f58baa6a6c2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 22:39:25 +0700 Subject: [PATCH 10/10] #830: "Couldn't read {name}." in every full catalog --- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + 13 files changed, 13 insertions(+) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 8ef21966..c4cd98f4 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -310,6 +310,7 @@ status = "draft — needs native review" "Corner radius" = "Poloměr rohu" "Corner smoothing" = "Vyhlazení rohů" "Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači." +"Couldn't read {name}." = "Soubor {name} se nepodařilo přečíst." "Count" = "Počet" "Create" = "Vytvořit" "Create a variable collection first" = "Nejprve vytvořte kolekci proměnných" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 2294e82d..51afd442 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -306,6 +306,7 @@ status = "draft — needs native review" "Corner smoothing" = "Eckenglättung" "Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." +"Couldn't read {name}." = "{name} konnte nicht gelesen werden." "Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" "Count" = "Anzahl" "Create" = "Erstellen" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 2521e46c..db5d2579 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -321,6 +321,7 @@ status = "draft — needs native review" "Corner smoothing" = "Kulman pehmennys" "Couldn't download the variables" = "Muuttujia ei voitu ladata" "Couldn't reach {server}: showing what this computer remembers." = "Palvelimeen {server} ei saatu yhteyttä. Näytetään tämän tietokoneen muistamat tiedot." +"Couldn't read {name}." = "Tiedostoa {name} ei voitu lukea." "Couldn't save automatically: {error}" = "Automaattinen tallennus epäonnistui: {error}" "Count" = "Määrä" "Create" = "Luo" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 1faff4c3..98c860ba 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -318,6 +318,7 @@ status = "draft — needs native review" "Corner smoothing" = "Lissage des angles" "Couldn't download the variables" = "Impossible de télécharger les variables" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." +"Couldn't read {name}." = "Impossible de lire {name}." "Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" "Count" = "Nombre" "Create" = "Créer" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 705484d8..0b559861 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -314,6 +314,7 @@ status = "draft — needs native review" "Corner radius" = "Radius sudut" "Corner smoothing" = "Penghalusan sudut" "Couldn't reach {server}: showing what this computer remembers." = "Tidak dapat terhubung ke {server}: menampilkan data yang diingat komputer ini." +"Couldn't read {name}." = "Tidak dapat membaca {name}." "Count" = "Jumlah" "Create" = "Buat" "Create a variable collection first" = "Buat koleksi variabel terlebih dahulu" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 069eb09a..712a6f3f 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -316,6 +316,7 @@ status = "draft — needs native review" "Corner radius" = "角の半径" "Corner smoothing" = "角のスムージング" "Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。" +"Couldn't read {name}." = "{name} を読み込めませんでした。" "Couldn't save automatically: {error}" = "自動保存できませんでした:{error}" "Count" = "数" "Create" = "作成" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index e3d8bae8..cfdb018d 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -305,6 +305,7 @@ status = "draft — needs native review" "Corner radius" = "모서리 반경" "Corner smoothing" = "모서리 스무딩" "Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함." +"Couldn't read {name}." = "{name}을(를) 읽을 수 없습니다." "Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}" "Count" = "개수" "Create" = "만들기" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 7a8b41f4..da645e7d 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -321,6 +321,7 @@ status = "draft — needs native review" "Corner smoothing" = "Hoekafvlakking" "Couldn't download the variables" = "De variabelen konden niet worden gedownload" "Couldn't reach {server}: showing what this computer remembers." = "Kon {server} niet bereiken: toont wat deze computer onthoudt." +"Couldn't read {name}." = "Kon {name} niet lezen." "Couldn't save automatically: {error}" = "Kan niet automatisch opslaan: {error}" "Count" = "Aantal" "Create" = "Maken" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 1fdbd1a3..343463f3 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -304,6 +304,7 @@ status = "draft — needs native review" "Corner smoothing" = "Suavização de cantos" "Couldn't download the variables" = "Não foi possível baixar as variáveis" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." +"Couldn't read {name}." = "Não foi possível ler {name}." "Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}" "Count" = "Quantidade" "Create" = "Criar" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 17e50e77..eb264cd9 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -311,6 +311,7 @@ name = "Русский" "Corner radius" = "Радиус углов" "Corner smoothing" = "Сглаживание углов" "Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере." +"Couldn't read {name}." = "Не удалось прочитать {name}." "Count" = "Количество" "Create" = "Создать" "Create a variable collection first" = "Сначала создайте коллекцию переменных" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index c57f2f6a..97e4cb41 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -315,6 +315,7 @@ name = "Svenska" "Corner smoothing" = "Hörnutjämning" "Couldn't download the variables" = "Det gick inte att hämta variablerna" "Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns." +"Couldn't read {name}." = "Det gick inte att läsa {name}." "Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}" "Count" = "Antal" "Create" = "Skapa" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 83b63c8c..e9fbf7f0 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -314,6 +314,7 @@ name = "Türkçe" "Corner radius" = "Köşe yarıçapı" "Corner smoothing" = "Köşe yumuşatma" "Couldn't reach {server}: showing what this computer remembers." = "{server} sunucusuna ulaşılamadı: bu bilgisayarın hatırladıkları gösteriliyor." +"Couldn't read {name}." = "{name} okunamadı." "Count" = "Sayı" "Create" = "Oluştur" "Create a variable collection first" = "Önce bir değişken koleksiyonu oluşturun" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 57f2d44b..81874aeb 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -306,6 +306,7 @@ status = "draft — needs native review" "Corner radius" = "圆角半径" "Corner smoothing" = "圆角平滑" "Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。" +"Couldn't read {name}." = "无法读取 {name}。" "Couldn't save automatically: {error}" = "无法自动保存:{error}" "Count" = "数量" "Create" = "创建"