From 01bc5a9dac244cde010edfebe24a951259f357dd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 05:47:14 +0700 Subject: [PATCH] M11: plugin installs saved at once; directory and library requests off the UI thread - **Installed plugins survive a crash.** Importing, installing, updating or removing a plugin, and a plugin changing its settings, now sets `Runtime::unsaved`; at the end of each frame the app hands the preferences to a new `Services::save_prefs`, and the desktop app writes `state.json` at once (temporary file + rename, the same atomic write the quit path uses, now shared). Nothing waits for quit any more. - **The UI never waits on the network.** New `Session::start_background(id, params)`: the command runs as usual until it makes a server request; the request goes out on the link's own thread (`LiveLink::background`, implemented by the desktop `NetLink` with one short-lived thread per request) and the command stops; when the answer arrives (`Event::Net`, taken by `live_poll`, which the network's waker already triggers) the command runs again from the start on the file it started on, answered from the answers so far, until it needs no new request. All server requests go through one funnel (`Session::net`), so `plugin.directory.*`, `library.publishToServer`, `library.add` (links), `library.usage` and library fetches work this way without being split. A re-run of `library.publishToServer` caches the library it actually sent (a fresh copy differs in its time and, on a first publish, its id). One job per command; `cancel_background` drops a late answer. - **UI:** `FigApp::run_background(ctx, id, params, done)` with a boxed completion. Manage plugins > Browse shows "Fetching from the server..." with Cancel instead of Browse; the install confirmation shows "Installing..." with Cancel and stays open until the download is in (an error leaves it open to retry); row buttons wait during an install; closing the window cancels. Assets > Libraries: Publish to server shows "Publishing..." + Cancel, Show usage and pasting a team library link show "Fetching from the server..." + Cancel. No polling repaints: the window draws again when the answer's waker fires; pending state is a scan of a short Vec, no per-frame allocation. - **Programmatic callers are unchanged:** MCP, CLI, control channel and scripts still run the same commands synchronously (and the browser link, which has no background requests, too). - New string "Installing..." (en/es); docs: plugins.md (immediate saves, Browse/Install never wait), server.md (team library requests never wait). --- Cargo.lock | 1 + apps/figcraft-server/src/background_tests.rs | 95 ++++++++ apps/figcraft-server/src/lib.rs | 2 + apps/figcraft/Cargo.toml | 3 + apps/figcraft/src/main.rs | 2 + apps/figcraft/src/services.rs | 43 +++- apps/figcraft/src/services_tests.rs | 68 ++++++ crates/collab-net/src/link.rs | 47 +++- crates/engine/src/background.rs | 152 ++++++++++++ crates/engine/src/background_tests.rs | 239 +++++++++++++++++++ crates/engine/src/cmd/library.rs | 19 +- crates/engine/src/lib.rs | 8 + crates/engine/src/library_server.rs | 45 ++-- crates/engine/src/live.rs | 71 ++++++ crates/engine/src/plugin_directory.rs | 12 +- crates/engine/src/plugin_panels_tests.rs | 4 + crates/engine/src/plugins.rs | 15 +- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/assets_panel.rs | 116 +++++---- crates/ui-egui/src/lib.rs | 77 ++++-- crates/ui-egui/src/plugins_browse_tests.rs | 164 +++++++++++-- crates/ui-egui/src/plugins_ui.rs | 65 ++++- docs/plugins.md | 9 +- docs/server.md | 5 + 25 files changed, 1119 insertions(+), 145 deletions(-) create mode 100644 apps/figcraft-server/src/background_tests.rs create mode 100644 apps/figcraft/src/services_tests.rs create mode 100644 crates/engine/src/background.rs create mode 100644 crates/engine/src/background_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8fd16e0..65c4493 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1422,6 +1422,7 @@ dependencies = [ "log", "rfd", "serde_json", + "wat", "webbrowser", "windows", "winresource", diff --git a/apps/figcraft-server/src/background_tests.rs b/apps/figcraft-server/src/background_tests.rs new file mode 100644 index 0000000..803dcc3 --- /dev/null +++ b/apps/figcraft-server/src/background_tests.rs @@ -0,0 +1,95 @@ +//! The app's way of talking to a server without waiting (`Session::start_background`), end to end +//! over loopback: browsing and installing from the plugin directory, publishing a team library, +//! adding it and asking for its usage each return at once and finish when the answers arrive; a +//! server that never answers doesn't hold the caller up. + +use std::net::TcpListener; +use std::time::{Duration, Instant}; + +use figcraft_engine::Session; +use figcraft_plugin::bundle::Bundle; +use serde_json::{Value, json}; + +use crate::store::Store; +use crate::test_util::{TempDir, config, publish, pump, session}; + +const ID: &str = "org.example.tiny"; + +fn bundle() -> Vec { + let wasm = + wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap(); + let manifest = json!({"id": ID, "name": "Tiny tool", "version": "1.0.0", "main": "tiny.wasm", "permissions": {"access": "read"}}); + Bundle { manifest: manifest.to_string(), wasm, assets: Vec::new() }.encode() +} + +/// Start `cmd` without waiting (it must not finish at once: its answer comes over the network), +/// then pump the session until it finished; its result. +fn background(s: &mut Session, cmd: &str, params: Value) -> Value { + let started = Instant::now(); + assert_eq!(s.start_background(cmd, params).unwrap(), None, "{cmd} waits for its answer"); + assert!(started.elapsed() < Duration::from_secs(1), "{cmd} returned at once"); + pump(&mut [&mut *s], cmd, |s| !s[0].waiting_for(cmd)); + let (done, r) = s.take_background_done().unwrap(); + assert_eq!(done, cmd); + r.unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> Value { + v["id"].clone() +} + +#[test] +fn browsing_and_installing_finish_in_the_background() { + let dir = TempDir::new("background-plugins"); + Store::open(dir.path(), config(dir.path()).store()).unwrap().directory().publish(&bundle()).unwrap(); + let mut server = crate::start(&config(dir.path())).unwrap(); + let plain = format!("ws://{}/", server.addr()); + let mut s = session("Bo"); + let listed = background(&mut s, "plugin.directory.list", json!({"server": plain})); + assert_eq!(listed["plugins"][0]["id"], ID); + assert_eq!(s.plugins.directory.as_ref().map(|l| l.entries.len()), Some(1), "Browse shows the listing"); + let sha = listed["plugins"][0]["sha256"].clone(); + let r = background(&mut s, "plugin.directory.install", json!({"id": ID, "confirm": true, "sha256": sha, "server": plain})); + assert_eq!(r["installed"], ID); + assert_eq!(s.prefs.plugins.len(), 1); + assert!(s.plugins.unsaved, "the app saves the plugin list at once"); + // Scripts still wait for their answer. + assert_eq!(s.execute("plugin.directory.list", &json!({})).unwrap()["plugins"][0]["installed"], "1.0.0"); + server.stop(); +} + +#[test] +fn team_library_requests_finish_in_the_background() { + let dir = TempDir::new("background-library"); + let mut server = crate::start(&config(dir.path())).unwrap(); + let addr = server.addr(); + let mut a = session("Ada"); + let f = a.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})).unwrap(); + a.execute("component.create", &json!({"ids": [id(&f)]})).unwrap(); + let p = publish(&mut a, dir.path(), addr); + // Fetching the server's current library, then publishing: two answers. + assert_eq!(background(&mut a, "library.publishToServer", json!({}))["version"], 1); + let mut b = session("Bo"); + let added = background(&mut b, "library.add", json!({"path": p.view})); + assert_eq!(added["components"].as_array().map(Vec::len), Some(1)); + assert_eq!(b.doc().unwrap().libraries.enabled.len(), 1); + let usage = background(&mut a, "library.usage", json!({})); + assert!(usage["files"].is_array(), "{usage}"); + server.stop(); +} + +#[test] +fn a_server_that_never_answers_holds_nothing_up() { + // It never accepts: connections wait in its backlog, and no answer ever comes. + let hole = TcpListener::bind("127.0.0.1:0").unwrap(); + let server = format!("ws://{}/", hole.local_addr().unwrap()); + let mut s = session("Cy"); + let started = Instant::now(); + assert_eq!(s.start_background("plugin.directory.list", json!({"server": server})).unwrap(), None); + assert!(started.elapsed() < Duration::from_secs(1), "the caller didn't wait"); + assert!(s.waiting_for("plugin.directory.list")); + // Closing the window stops waiting; the answer, when the request gives up, is dropped. + s.cancel_background("plugin.directory.list"); + assert!(!s.waiting_for("plugin.directory.list") && s.take_background_done().is_none()); + drop(hole); +} diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index a246cb9..41e43b3 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -71,6 +71,8 @@ pub fn start(cfg: &Config) -> Result { Ok(Running { hosting }) } +#[cfg(test)] +mod background_tests; #[cfg(test)] mod cli_tests; #[cfg(test)] diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index 167d864..b64b68d 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -31,5 +31,8 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col [target.'cfg(windows)'.dependencies] windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] } +[dev-dependencies] +wat = { workspace = true } + [build-dependencies] winresource = "0.1" diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 821d945..5d4eac3 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -19,6 +19,8 @@ mod services; mod args_tests; #[cfg(test)] mod recovery_tests; +#[cfg(test)] +mod services_tests; use std::sync::{Arc, Mutex}; diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 59befcb..2f98515 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -1,6 +1,6 @@ //! Desktop services: native file dialogs, clipboard, URLs, persisted preferences. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -10,6 +10,8 @@ use serde_json::{Value, json}; pub struct Desktop { clipboard: Option, + /// Where the preferences live ([`config_dir`]). + dir: Option, pub prefs: Option, /// The system's reduce-motion setting (read in the background at startup). reduce_motion: Arc, @@ -31,10 +33,13 @@ pub fn config_dir() -> Option { impl Desktop { pub fn load(recovery: crate::recovery::Shared) -> Self { - let state: Value = config_dir() - .and_then(|d| std::fs::read_to_string(d.join("state.json")).ok()) - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or(json!({})); + Self::load_from(config_dir(), recovery) + } + + /// With the preferences kept in `dir`. + pub fn load_from(dir: Option, recovery: crate::recovery::Shared) -> Self { + let state: Value = + dir.as_ref().and_then(|d| std::fs::read_to_string(d.join(STATE)).ok()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({})); let prefs = state.get("prefs").and_then(|p| serde_json::from_value::(p.clone()).ok()).map(|mut p| { // Recent files lived next to the preferences before they moved into them. if p.recent_files.is_empty() @@ -48,7 +53,7 @@ impl Desktop { let reduce_motion = Arc::new(AtomicBool::new(false)); let flag = reduce_motion.clone(); std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed)); - Self { clipboard: arboard::Clipboard::new().ok(), prefs, reduce_motion, language: system_language(), recovery } + Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery } } } @@ -126,13 +131,24 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool { .is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want) } +/// The preferences' file in the config folder. +const STATE: &str = "state.json"; + pub fn save_state(app: &FigApp) { - let Some(dir) = config_dir() else { return }; - let _ = std::fs::create_dir_all(&dir); - let v = json!({"prefs": app.session.prefs}); + if let Some(dir) = config_dir() { + write_prefs(&dir, &app.session.prefs); + } +} + +/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves +/// either version whole). +fn write_prefs(dir: &Path, prefs: &Prefs) { let tmp = dir.join("state.json.tmp"); - if std::fs::write(&tmp, v.to_string()).is_ok() { - let _ = std::fs::rename(&tmp, dir.join("state.json")); + let written = std::fs::create_dir_all(dir) + .and_then(|()| std::fs::write(&tmp, json!({"prefs": prefs}).to_string())) + .and_then(|()| std::fs::rename(&tmp, dir.join(STATE))); + if let Err(e) = written { + log::warn!("preferences: couldn't save them in {}: {e}", dir.display()); } } @@ -171,6 +187,11 @@ impl Services for Desktop { fn open_url(&mut self, url: &str) { let _ = webbrowser::open(url); } + fn save_prefs(&mut self, prefs: &Prefs) { + if let Some(dir) = &self.dir { + write_prefs(dir, prefs); + } + } fn emergency_save(&mut self, session: &Session) { if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() { r.write(session, true); diff --git a/apps/figcraft/src/services_tests.rs b/apps/figcraft/src/services_tests.rs new file mode 100644 index 0000000..2043299 --- /dev/null +++ b/apps/figcraft/src/services_tests.rs @@ -0,0 +1,68 @@ +//! The preferences are kept as plugins change, not only when the app quits: a plugin imported +//! (then removed) is still there (gone) when the app is killed and starts again. + +use std::sync::{Arc, Mutex}; + +use figcraft_engine::Session; +use figcraft_ui_egui::FigApp; +use serde_json::json; + +use crate::services::Desktop; + +const ID: &str = "org.example.tiny"; + +fn root(name: &str) -> std::path::PathBuf { + let r = std::env::temp_dir().join(format!("figcraft-prefs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&r); + std::fs::create_dir_all(&r).unwrap(); + r +} + +/// A plugin folder (manifest and module) in `root`. +fn plugin(root: &std::path::Path) -> std::path::PathBuf { + let dir = root.join("tiny"); + std::fs::create_dir_all(&dir).unwrap(); + let manifest = json!({"id": ID, "name": "Tiny tool", "version": "1.0.0", "main": "tiny.wasm", "permissions": {"access": "read"}}); + std::fs::write(dir.join("figcraft-plugin.json"), manifest.to_string()).unwrap(); + let wasm = + wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap(); + std::fs::write(dir.join("tiny.wasm"), wasm).unwrap(); + dir +} + +/// The app as it starts with its preferences in `dir`. +fn start(dir: &std::path::Path) -> FigApp { + let svc = Desktop::load_from(Some(dir.to_path_buf()), Arc::new(Mutex::new(None))); + let mut s = Session::new(); + if let Some(prefs) = svc.prefs.clone() { + s.prefs = prefs; + } + FigApp::new(s, Box::new(svc)) +} + +/// Draw two frames (the first only sets the fonts up). +fn frames(fig: &mut FigApp, ctx: &egui::Context) { + for _ in 0..2 { + ctx.run_ui(egui::RawInput::default(), |ui| fig.ui(ui)).textures_delta.clear(); + } +} + +#[test] +fn plugins_are_kept_when_the_app_is_killed() { + let dir = root("killed"); + let folder = plugin(&dir); + let ctx = egui::Context::default(); + let mut fig = start(&dir); + assert!(fig.run("plugin.import", json!({"path": folder.to_string_lossy()})).is_some()); + frames(&mut fig, &ctx); + // Killed: no quit, so nothing saves on the way out. + drop(fig); + let mut fig = start(&dir); + assert_eq!(fig.session.prefs.plugins.iter().map(|p| p.manifest.id.as_str()).collect::>(), [ID], "the plugin is still installed"); + assert!(fig.run("plugin.remove", json!({"id": ID})).is_some()); + frames(&mut fig, &ctx); + drop(fig); + assert!(start(&dir).session.prefs.plugins.is_empty(), "the removal was kept too"); + assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index db83c5d..45bb43b 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -6,7 +6,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq}; -use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo}; +use figcraft_engine::live::{ + Bind, Event, HostOptions, LiveLink, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo, +}; use crate::guest::{Guest, GuestConfig, Publish, upload_file}; use crate::host::{Host, HostConfig}; @@ -35,7 +37,7 @@ pub struct NetLink { inbox: Option, /// Tweaks for joining (tests shorten the reconnect pauses). guest: fn(GuestConfig) -> GuestConfig, - /// Answers to library requests sent in the background (outlives sessions). + /// Answers to requests sent in the background (outlives sessions). libraries: Inbox, /// Background library requests are out. fetching: Arc, @@ -163,10 +165,7 @@ impl LiveLink for NetLink { } fn upload(&mut self, o: PublishOptions) -> Result { - let (server, query) = Endpoint::parse(&o.server)?; - let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty()); - let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?; - upload_file(&server, &key, &o.name, &o.doc, &self.limits, CONNECT).map(|(_, published)| published) + upload(o, &self.limits) } fn library(&mut self, link: &str, req: LibraryReq) -> Result { @@ -174,9 +173,24 @@ impl LiveLink for NetLink { } fn plugins(&mut self, server: &str, req: PluginReq) -> Result { - let (server, query) = Endpoint::parse(server)?; - let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).unwrap_or_default(); - manage::plugins(&server, key.trim(), req, &self.limits, CONNECT) + plugins(server, req, &self.limits) + } + + fn background(&mut self, job: u64, req: NetRequest) -> bool { + let (inbox, limits) = (self.libraries.clone(), self.limits); + let (failed, failure) = (inbox.clone(), req.failure()); + let spawned = std::thread::Builder::new().name("server-request".into()).spawn(move || { + let reply = match req { + NetRequest::Plugins { server, req } => NetReply::Plugins(plugins(&server, req, &limits)), + NetRequest::Library { link, req } => NetReply::Library(manage::library(&link, req, &limits, CONNECT)), + NetRequest::Upload(o) => NetReply::Upload(upload(o, &limits)), + }; + inbox.push(Event::Net { job, reply: Box::new(reply) }); + }); + if let Err(e) = spawned { + failed.push(Event::Net { job, reply: Box::new(failure(format!("can't start a thread for the request: {e}"))) }); + } + true } fn library_background(&mut self, jobs: Vec<(String, LibraryReq)>) { @@ -229,6 +243,21 @@ impl LiveLink for NetLink { } } +/// Keep `o.doc` on a server as a new file (see [`LiveLink::upload`]). +fn upload(o: PublishOptions, limits: &NetLimits) -> Result { + let (server, query) = Endpoint::parse(&o.server)?; + let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty()); + let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?; + upload_file(&server, &key, &o.name, &o.doc, limits, CONNECT).map(|(_, published)| published) +} + +/// One request to the plugin directory at `server` (its key in the address's query, if any). +fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result { + let (server, query) = Endpoint::parse(server)?; + let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).unwrap_or_default(); + manage::plugins(&server, key.trim(), req, limits, CONNECT) +} + impl Drop for NetLink { fn drop(&mut self) { self.leave(); diff --git a/crates/engine/src/background.rs b/crates/engine/src/background.rs new file mode 100644 index 0000000..aa2eef0 --- /dev/null +++ b/crates/engine/src/background.rs @@ -0,0 +1,152 @@ +//! Commands the UI runs without waiting on a server ([`Session::start_background`]): browsing and +//! installing from a plugin directory, publishing, adding and checking team libraries. +//! +//! Such a command runs as usual until it makes a server request ([`Session::net`]). Then the +//! request goes out on the link's own thread ([`crate::live::LiveLink::background`]) and the +//! command stops (its partial edit is rolled back like any failed command's). When the +//! answer arrives ([`Event::Net`](crate::live::Event::Net), taken by [`Session::live_poll`]) the +//! command runs again from the start, on the file it started on, and each request it makes is +//! answered from the answers so far, until it needs no new one: then its result is kept for the UI +//! ([`Session::take_background_done`]). A command's steps before its requests are cheap, so +//! running them again costs nothing worth noting. +//! +//! Every other caller (scripts, the CLI, the MCP server, the control channel) runs commands as +//! before, waiting for each answer; so does a link that can't send in the background. Nothing here +//! starts a request: only a command does, when something asks for it. + +use serde_json::Value; + +use crate::live::{NetReply, NetRequest}; +use crate::{EngineError, Result, Session}; + +/// A command waiting for a server. +#[derive(Debug)] +struct Job { + id: u64, + command: String, + params: Value, + /// The file it started on (its uid), if one was open. + doc: Option, + /// The answers so far, with the requests they answer. + answers: Vec<(NetRequest, NetReply)>, + /// The request out now. + out: Option, +} + +/// The run of a job in progress: the answers it may use (and which it used) and the request it +/// sent, if any. +#[derive(Debug)] +struct Run { + job: u64, + answers: Vec<(NetRequest, NetReply)>, + used: Vec, + out: Option, +} + +/// The commands waiting for servers, and the outcomes the UI hasn't taken yet. +#[derive(Debug, Default)] +pub struct Background { + next: u64, + jobs: Vec, + run: Option, + done: Vec<(String, Result)>, +} + +impl Session { + /// Run command `id` as [`execute`](Self::execute) does, but without waiting on a server (see + /// the module docs): `Ok(Some(result))` when it finished at once, `Ok(None)` while its request + /// is out (the outcome arrives through [`take_background_done`](Self::take_background_done)), + /// or its error. A command already waiting is cancelled first: one job per command. + pub fn start_background(&mut self, id: &str, params: Value) -> Result> { + self.cancel_background(id); + self.background.next += 1; + let job = + Job { id: self.background.next, command: id.to_string(), params, doc: self.ds().ok().map(|d| d.uid), answers: Vec::new(), out: None }; + self.run_job(job) + } + + /// Command `id` is waiting for a server. + pub fn waiting_for(&self, id: &str) -> bool { + self.background.jobs.iter().any(|j| j.command == id) + } + + /// Stop waiting for command `id`: its answer is ignored when it arrives. + pub fn cancel_background(&mut self, id: &str) { + self.background.jobs.retain(|j| j.command != id); + } + + /// The outcome of a command run in the background that finished since the last call: the + /// command and its result. + pub fn take_background_done(&mut self) -> Option<(String, Result)> { + (!self.background.done.is_empty()).then(|| self.background.done.remove(0)) + } + + /// Run `job`'s command, answering its requests from its answers so far; it waits again when it + /// sent a new one. + fn run_job(&mut self, mut job: Job) -> Result> { + let shown = self.ds().ok().map(|d| d.uid); + if let Some(uid) = job.doc { + // A closed file leaves no document to act on. + self.active = self.docs.iter().position(|d| d.uid == uid).unwrap_or(self.docs.len()); + } + let used = vec![false; job.answers.len()]; + self.background.run = Some(Run { job: job.id, answers: std::mem::take(&mut job.answers), used, out: None }); + let result = self.execute(&job.command, &job.params); + let run = self.background.run.take(); + if let Some(i) = shown.and_then(|uid| self.docs.iter().position(|d| d.uid == uid)) { + self.active = i; + } + match run { + Some(Run { answers, out: Some(out), .. }) => { + job.answers = answers; + job.out = Some(out); + self.background.jobs.push(job); + Ok(None) + } + _ => result.map(Some), + } + } + + /// The answer to background request `job` arrived: run its command again (an answer for a + /// cancelled command is dropped). + pub(crate) fn background_answer(&mut self, job: u64, reply: NetReply) { + let Some(i) = self.background.jobs.iter().position(|j| j.id == job) else { return }; + let mut j = self.background.jobs.remove(i); + let Some(sent) = j.out.take() else { return }; + j.answers.push((sent, reply)); + let command = j.command.clone(); + match self.run_job(j) { + Ok(None) => {} + Ok(Some(v)) => self.background.done.push((command, Ok(v))), + Err(e) => self.background.done.push((command, Err(e))), + } + } + + /// One request to a server, for a command: answered from an earlier answer when the command + /// runs again in the background, sent on the link's thread when it runs there for the first + /// time ([`EngineError::Waiting`]), else waited for. With the answer, the request that was + /// answered when it was an earlier one (what it carried then). + pub(crate) fn net(&mut self, req: NetRequest) -> Result<(NetReply, Option)> { + let Some(link) = self.live.link() else { return Err(EngineError::Other(req.unavailable().into())) }; + if let Some(run) = self.background.run.as_mut() { + let earlier = run.answers.iter().zip(run.used.iter_mut()).find(|((sent, _), used)| !**used && sent.stands_for(&req)); + if let Some(((sent, reply), used)) = earlier { + *used = true; + return Ok((reply.clone(), Some(sent.clone()))); + } + // One request out at a time: anything after it waits for the next run. + if run.out.is_some() { + return Err(EngineError::Waiting); + } + if link.background(run.job, req.clone()) { + run.out = Some(req); + return Err(EngineError::Waiting); + } + } + Ok((link.request(req), None)) + } +} + +#[cfg(test)] +#[path = "background_tests.rs"] +mod tests; diff --git a/crates/engine/src/background_tests.rs b/crates/engine/src/background_tests.rs new file mode 100644 index 0000000..82f1c56 --- /dev/null +++ b/crates/engine/src/background_tests.rs @@ -0,0 +1,239 @@ +//! Commands run without waiting on a server, against a scripted link that holds each background +//! request until the test answers it: nothing is waited for, scripts still wait, answers resume the +//! command (twice for a command with two requests), a cancelled command ignores its answer, errors +//! are its outcome, a re-run acts on the file it started on, and a link that can't send in the +//! background is waited on as before. + +use std::sync::{Arc, Mutex}; + +use figcraft_collab::message::{ClientMsg, LibraryReply, LibraryReq, PluginEntry, PluginReply, PluginReq}; +use figcraft_plugin::bundle::{Bundle, sha256_hex}; +use serde_json::json; + +use crate::library_server::{archive, read_archive}; +use crate::live::{Event, HostOptions, LiveLink, NetReply, NetRequest, Presence, SessionInfo}; +use crate::{EngineError, Session}; + +const ID: &str = "org.example.tiny"; +const SERVER: &str = "ws://127.0.0.1:9/"; +const LINK: &str = "ws://127.0.0.1:9/?file=f&token=t"; + +/// What the scripted link saw: background requests out, answers to hand back, waited-for calls. +#[derive(Default)] +struct Wire { + background: bool, + out: Vec<(u64, NetRequest)>, + answers: Vec, + waited: usize, + /// The team library the scripted server keeps (an archive), if any. + library: Option>, +} + +struct Link(Arc>); + +impl LiveLink for Link { + fn host(&mut self, _: HostOptions) -> Result { + Err("no".into()) + } + fn join(&mut self, _: &str, _: &str) -> Result<(), String> { + Err("no".into()) + } + fn leave(&mut self) {} + fn presence(&mut self, _: &Presence) {} + fn send(&mut self, _: ClientMsg) {} + fn poll(&mut self) -> Vec { + std::mem::take(&mut self.0.lock().unwrap().answers) + } + fn plugins(&mut self, _: &str, req: PluginReq) -> Result { + self.0.lock().unwrap().waited += 1; + Ok(directory(req)) + } + fn library(&mut self, _: &str, req: LibraryReq) -> Result { + let mut w = self.0.lock().unwrap(); + w.waited += 1; + Ok(library(&mut w, req)) + } + fn background(&mut self, job: u64, req: NetRequest) -> bool { + let mut w = self.0.lock().unwrap(); + if w.background { + w.out.push((job, req)); + } + w.background + } +} + +fn wasm() -> Vec { + wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap() +} + +fn bundle() -> Vec { + let manifest = json!({"id": ID, "name": "Tiny tool", "version": "1.0.0", "main": "t.wasm", "permissions": {"access": "write", "commands": ["shape.rectangle"]}}); + Bundle { manifest: manifest.to_string(), wasm: wasm(), assets: Vec::new() }.encode() +} + +fn directory(req: PluginReq) -> PluginReply { + let bytes = bundle(); + let entry = + PluginEntry { id: ID.into(), name: "Tiny tool".into(), version: "1.0.0".into(), sha256: sha256_hex(&bytes), ..PluginEntry::default() }; + match req { + PluginReq::List { .. } => PluginReply::List(vec![entry]), + PluginReq::Fetch { .. } => PluginReply::Bundle { entry, bundle: bytes }, + _ => PluginReply::Failed { reason: "no".into() }, + } +} + +fn library(w: &mut Wire, req: LibraryReq) -> LibraryReply { + match (req, &w.library) { + (LibraryReq::Fetch { .. }, Some(a)) => LibraryReply::Library { version: 1, archive: a.clone() }, + (LibraryReq::Fetch { .. }, None) => LibraryReply::Empty, + (LibraryReq::Publish { archive }, _) => { + w.library = Some(archive); + LibraryReply::Published { version: 1 } + } + (LibraryReq::Usage, _) => LibraryReply::Usage(Vec::new()), + } +} + +fn session(background: bool) -> (Session, Arc>) { + let wire = Arc::new(Mutex::new(Wire { background, ..Wire::default() })); + let mut s = Session::new(); + s.live.install(Box::new(Link(wire.clone()))); + (s, wire) +} + +/// Answer the requests out (as the scripted server would) and let the session take the answers; +/// how many there were. +fn answer(s: &mut Session, wire: &Arc>) -> usize { + let out = std::mem::take(&mut wire.lock().unwrap().out); + for (job, req) in &out { + let mut w = wire.lock().unwrap(); + let reply = match req.clone() { + NetRequest::Plugins { req, .. } => NetReply::Plugins(Ok(directory(req))), + NetRequest::Library { req, .. } => NetReply::Library(Ok(library(&mut w, req))), + NetRequest::Upload(_) => NetReply::Upload(Err("no".into())), + }; + w.answers.push(Event::Net { job: *job, reply: Box::new(reply) }); + } + s.live_poll(); + out.len() +} + +/// A library file's published library as the archive a server keeps. +fn published() -> Vec { + let mut s = Session::new(); + let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": "Chip"})).unwrap(); + s.execute("component.create", &json!({"ids": [f["id"]]})).unwrap(); + s.execute("library.publish", &json!({})).unwrap(); + archive(s.doc().unwrap().published.as_ref().unwrap()).unwrap() +} + +#[test] +fn browsing_and_installing_wait_for_nothing_and_scripts_still_wait() { + let (mut s, wire) = session(true); + assert_eq!(s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap(), None); + assert!(s.waiting_for("plugin.directory.list") && s.plugins.directory.is_none()); + assert_eq!(wire.lock().unwrap().waited, 0, "nothing was waited for"); + // A script (the MCP server, the control channel) still gets its answer at once. + let r = s.execute("plugin.directory.list", &json!({"server": SERVER})).unwrap(); + assert_eq!(r["plugins"][0]["id"], ID); + assert_eq!(wire.lock().unwrap().waited, 1); + assert!(s.take_background_done().is_none(), "no answer yet"); + assert_eq!(answer(&mut s, &wire), 1); + let (cmd, r) = s.take_background_done().unwrap(); + assert_eq!((cmd.as_str(), r.unwrap()["plugins"][0]["id"].as_str()), ("plugin.directory.list", Some(ID))); + assert!(!s.waiting_for("plugin.directory.list") && s.take_background_done().is_none()); + + let install = json!({"id": ID, "confirm": true, "server": SERVER}); + assert_eq!(s.start_background("plugin.directory.install", install).unwrap(), None); + assert!(s.prefs.plugins.is_empty() && !s.plugins.unsaved); + answer(&mut s, &wire); + let (_, r) = s.take_background_done().unwrap(); + assert_eq!(r.unwrap()["installed"], ID); + assert_eq!(s.prefs.plugins.len(), 1); + assert!(s.plugins.unsaved, "the app is told to save the plugin list at once"); + assert_eq!(wire.lock().unwrap().waited, 1, "the background path never waited"); +} + +#[test] +fn cancelled_commands_drop_their_answer_and_errors_are_the_outcome() { + let (mut s, wire) = session(true); + s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap(); + s.cancel_background("plugin.directory.list"); + assert!(!s.waiting_for("plugin.directory.list")); + answer(&mut s, &wire); + assert!(s.take_background_done().is_none() && s.plugins.directory.is_none(), "the late answer is ignored"); + + // Starting it again replaces the one waiting: only the newer answer counts. + s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap(); + s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap(); + assert_eq!(answer(&mut s, &wire), 2); + assert!(s.take_background_done().is_some() && s.take_background_done().is_none()); + + s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap(); + let (job, _) = wire.lock().unwrap().out.pop().unwrap(); + wire.lock().unwrap().answers.push(Event::Net { job, reply: Box::new(NetReply::Plugins(Err("the server can't be reached".into()))) }); + s.live_poll(); + let (_, r) = s.take_background_done().unwrap(); + assert!(r.unwrap_err().to_string().contains("can't be reached")); + // Bad parameters fail at once, without a request. + assert!(s.start_background("plugin.directory.install", json!({"id": ID})).is_err()); + assert!(wire.lock().unwrap().out.is_empty()); +} + +#[test] +fn a_command_with_two_requests_resumes_twice_and_keeps_what_it_sent() { + let (mut s, wire) = session(true); + let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": "Chip"})).unwrap(); + s.execute("component.create", &json!({"ids": [f["id"]]})).unwrap(); + // The server's current library first, then the publish. + assert_eq!(s.start_background("library.publishToServer", json!({"server": LINK})).unwrap(), None); + assert_eq!(answer(&mut s, &wire), 1); + assert!(s.take_background_done().is_none() && s.waiting_for("library.publishToServer")); + assert!(matches!(&wire.lock().unwrap().out[..], [(_, NetRequest::Library { req: LibraryReq::Publish { .. }, .. })])); + assert_eq!(answer(&mut s, &wire), 1); + let (_, r) = s.take_background_done().unwrap(); + assert_eq!(r.unwrap()["version"], 1); + let kept = read_archive(wire.lock().unwrap().library.as_ref().unwrap()).unwrap(); + let cached = s.libraries.cached(LINK).unwrap().unwrap(); + assert_eq!(cached.manifest, kept.manifest, "the library kept here is the one the server got"); + assert_eq!(wire.lock().unwrap().waited, 0); +} + +#[test] +fn a_rerun_acts_on_the_file_it_started_on() { + let (mut s, wire) = session(true); + wire.lock().unwrap().library = Some(published()); + let started = s.ds().unwrap().uid; + assert_eq!(s.start_background("library.add", json!({"path": LINK})).unwrap(), None); + s.execute("file.new", &json!({})).unwrap(); + let other = s.ds().unwrap().uid; + answer(&mut s, &wire); + let (_, r) = s.take_background_done().unwrap(); + assert_eq!(r.unwrap()["name"], "Untitled"); + assert_eq!(s.ds().unwrap().uid, other, "the file shown stays shown"); + let enabled = |s: &Session, uid: u64| s.docs.iter().find(|d| d.uid == uid).unwrap().doc.libraries.enabled.len(); + assert_eq!((enabled(&s, started), enabled(&s, other)), (1, 0), "the library went to the file it was added in"); + + // A file closed meanwhile leaves nothing to add the library to. + s.libraries = crate::library::Cache::default(); + s.execute("file.new", &json!({})).unwrap(); + s.start_background("library.add", json!({"path": LINK})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + let shown = s.ds().unwrap().uid; + answer(&mut s, &wire); + let (_, r) = s.take_background_done().unwrap(); + assert!(matches!(r, Err(EngineError::NoDocument)), "{r:?}"); + assert_eq!(s.ds().unwrap().uid, shown); +} + +#[test] +fn a_link_without_background_requests_is_waited_on() { + let (mut s, wire) = session(false); + let r = s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap().unwrap(); + assert_eq!(r["plugins"][0]["id"], ID); + assert!(!s.waiting_for("plugin.directory.list")); + assert_eq!(wire.lock().unwrap().waited, 1); + // Without any link, the usual error. + let mut bare = Session::new(); + assert!(bare.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap_err().to_string().contains("desktop app")); +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3923775..0cecf04 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -13,7 +13,7 @@ use figcraft_collab::message::{LibraryReply, LibraryReq}; use super::CommandSpec; use crate::library::*; use crate::library_server::{archive, is_server_link, read_archive, shown_path}; -use crate::live::PublishOptions; +use crate::live::{NetReply, NetRequest, PublishOptions}; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; @@ -361,8 +361,10 @@ fn publish_to_server(s: &mut Session, p: &Value) -> Result { // The server's address with its key: the file is kept there first. Some(server) if key.is_some() || (server.contains("key=") && !server.contains("token=")) => { let opts = PublishOptions { server, key, name: doc.name.clone(), me: String::new(), doc: doc.clone() }; - let net = s.live.link().ok_or_else(|| EngineError::Other(crate::live::NO_TEAM_LIBRARIES.into()))?; - let file = net.upload(opts).map_err(|e| bad(cmd, e))?; + let file = match s.net(NetRequest::Upload(opts))?.0 { + NetReply::Upload(r) => r.map_err(|e| bad(cmd, e))?, + _ => return Err(EngineError::Other("the server answered something else".into())), + }; let edit = file.edit.clone(); kept = Some(file); edit @@ -386,10 +388,13 @@ fn publish_to_server(s: &mut Session, p: &Value) -> Result { (Err(_), Some(own)) if current.is_none() && !sel.is_empty() => (**own).clone(), (Err(e), _) => return Err(e), }; - match s.library_request(&link, LibraryReq::Publish { archive: archive(&lib)? })? { - LibraryReply::Published { .. } => {} - other => return Err(unexpected(&other)), - } + let lib = match s.library_exchange(&link, LibraryReq::Publish { archive: archive(&lib)? })? { + // Run again in the background: what the server keeps is what went out then (a new copy + // differs at least in its time). + (LibraryReply::Published { .. }, Some(LibraryReq::Publish { archive })) => read_archive(&archive).map_err(EngineError::Other)?, + (LibraryReply::Published { .. }, _) => lib, + (other, _) => return Err(unexpected(&other)), + }; let mut out = published_json(&sel, &lib); s.libraries.insert(&link, Ok(Arc::new(lib))); if let Some(f) = kept { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 60caff9..e5e8141 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod al_keys; pub mod annotations; pub mod arc_handles; +pub mod background; pub mod booleans; pub mod branches; pub mod catalog; @@ -77,6 +78,10 @@ pub enum EngineError { NoNode(NodeId), #[error("{0}")] Other(String), + /// A command run in the background sent a request to a server and runs again with the answer + /// ([`background`]); never returned to other callers. + #[error("waiting for the server")] + Waiting, /// A bug: the command panicked. The document was rolled back. #[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")] Internal { cmd: String, msg: String }, @@ -622,6 +627,8 @@ pub struct Session { pub plugins: plugins::Runtime, /// The live session (collaboration): status, peers, presence. pub live: live::Live, + /// Commands the UI runs without waiting on a server ([`Session::start_background`]). + pub background: background::Background, } impl Default for Session { @@ -654,6 +661,7 @@ impl Session { libraries: library::Cache::default(), plugins: plugins::Runtime::default(), live: live::Live::default(), + background: background::Background::default(), } } diff --git a/crates/engine/src/library_server.rs b/crates/engine/src/library_server.rs index cce0f5a..6dcc246 100644 --- a/crates/engine/src/library_server.rs +++ b/crates/engine/src/library_server.rs @@ -6,9 +6,10 @@ //! link takes the place of a library file's path in [`LibraryLink::path`], so everything else //! (the Assets panel, placing components, updates with overrides kept) works as for library files. //! The engine never opens a socket: requests go through the app's [`LiveLink`](crate::live::LiveLink). -//! A command that needs a library it hasn't fetched yet waits for the server; the Assets panel -//! refreshes in the background ([`Session::refresh_server_libraries`]) and keeps the last library -//! fetched while the server can't be reached. +//! A command that needs a library it hasn't fetched yet waits for the server (the UI runs such +//! commands without waiting: [`crate::background`]); the Assets panel refreshes in the background +//! ([`Session::refresh_server_libraries`]) and keeps the last library fetched while the server +//! can't be reached. use std::io::Cursor; use std::sync::Arc; @@ -16,7 +17,7 @@ use std::sync::Arc; use figcraft_collab::message::{LibraryReply, LibraryReq, Usage}; use figcraft_doc::{Document, NodeId, NodeKind, PublishedLibrary}; -use crate::live::NO_TEAM_LIBRARIES; +use crate::live::{NetReply, NetRequest}; use crate::{EngineError, Result, Session}; /// What a server library that hasn't been fetched yet says. @@ -95,18 +96,28 @@ impl Session { self.live_doc(uid).then(|| info.link.clone()) } - /// One request about the team library behind share link `link`, waiting for the server (a - /// refusal is an error). - // shortcut: commands wait on the calling thread (at most the 5 s connect timeout when the server - // is down; the panel's checks run in the background); queue them on the link if that shows. - pub(crate) fn library_request(&mut self, link: &str, req: LibraryReq) -> Result { - let net = self.live.link().ok_or_else(|| EngineError::Other(NO_TEAM_LIBRARIES.into()))?; - match net.library(link, req).map_err(EngineError::Other)? { - LibraryReply::Failed { reason } => Err(EngineError::Other(reason)), - r => Ok(r), + /// One request about the team library behind share link `link` (a refusal is an error), with + /// the request answered when a command running in the background got its answer earlier + /// ([`crate::background`]). Callers outside the background wait for the server (at most the + /// connect timeout when it can't be reached). + pub(crate) fn library_exchange(&mut self, link: &str, req: LibraryReq) -> Result<(LibraryReply, Option)> { + let (reply, sent) = self.net(NetRequest::Library { link: link.to_string(), req })?; + let sent = match sent { + Some(NetRequest::Library { req, .. }) => Some(req), + _ => None, + }; + match reply { + NetReply::Library(Ok(LibraryReply::Failed { reason }) | Err(reason)) => Err(EngineError::Other(reason)), + NetReply::Library(Ok(r)) => Ok((r, sent)), + _ => Err(EngineError::Other("the server answered something else".into())), } } + /// [`library_exchange`](Self::library_exchange) without the request. + pub(crate) fn library_request(&mut self, link: &str, req: LibraryReq) -> Result { + self.library_exchange(link, req).map(|(r, _)| r) + } + /// The published library at `path`: a library file (read again when it changed), or a server /// library link (fetched once; then the background refresh keeps it current). pub fn load_library(&mut self, path: &str) -> Result> { @@ -129,9 +140,11 @@ impl Session { let id = d.libraries.enabled.iter().find(|l| l.path == path).map(|l| l.id.clone()).unwrap_or_default(); usage(d, &id) }); - let reply = match self.live.link() { - Some(net) => net.library(path, LibraryReq::Fetch { have, usage }), - None => Err(NO_TEAM_LIBRARIES.into()), + let reply = match self.net(NetRequest::Library { link: path.to_string(), req: LibraryReq::Fetch { have, usage } }) { + Ok((NetReply::Library(r), _)) => r, + Ok(_) => Err("the server answered something else".into()), + Err(EngineError::Waiting) => return Err(EngineError::Waiting), + Err(e) => Err(e.to_string()), }; self.take_library(path, reply).map_err(EngineError::Other) } diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index b68b067..7b6749b 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -193,6 +193,63 @@ pub enum Event { link: String, reply: std::result::Result, }, + /// The answer to request `job` sent in the background ([`LiveLink::background`]). + Net { + job: u64, + reply: Box, + }, +} + +/// A request to a server outside a live session: its plugin directory, a team library, or keeping +/// a file there. +#[derive(Clone, Debug)] +pub enum NetRequest { + /// To the plugin directory of the server at `server` (`ws://host:port/`, with `?key=…` to + /// publish). + Plugins { server: String, req: PluginReq }, + /// About the team library of the file behind share link `link`. + Library { link: String, req: LibraryReq }, + /// Keep a file on a server without joining it ([`LiveLink::upload`]). + Upload(PublishOptions), +} + +impl NetRequest { + /// What a session without the network says. + pub(crate) fn unavailable(&self) -> &'static str { + match self { + NetRequest::Plugins { .. } => crate::plugin_directory::NO_DIRECTORY, + NetRequest::Library { .. } | NetRequest::Upload(_) => NO_TEAM_LIBRARIES, + } + } + + /// How to answer that this request failed (and why). + pub fn failure(&self) -> fn(String) -> NetReply { + match self { + NetRequest::Plugins { .. } => |why| NetReply::Plugins(Err(why)), + NetRequest::Library { .. } => |why| NetReply::Library(Err(why)), + NetRequest::Upload(_) => |why| NetReply::Upload(Err(why)), + } + } + + /// The answer to this request stands for `other` when a command runs again: the same kind of + /// request to the same place (what it carries may differ: a usage report, a fresh timestamp). + pub(crate) fn stands_for(&self, other: &NetRequest) -> bool { + use std::mem::discriminant; + match (self, other) { + (NetRequest::Plugins { server: a, req: x }, NetRequest::Plugins { server: b, req: y }) => a == b && discriminant(x) == discriminant(y), + (NetRequest::Library { link: a, req: x }, NetRequest::Library { link: b, req: y }) => a == b && discriminant(x) == discriminant(y), + (NetRequest::Upload(a), NetRequest::Upload(b)) => a.server == b.server, + _ => false, + } + } +} + +/// The answer to a [`NetRequest`] (an error: the server couldn't be reached or refused). +#[derive(Clone, Debug, PartialEq)] +pub enum NetReply { + Plugins(std::result::Result), + Library(std::result::Result), + Upload(std::result::Result), } /// Which network interface a hosted session listens on. @@ -272,6 +329,19 @@ pub trait LiveLink: Send { fn plugins(&mut self, _server: &str, _req: PluginReq) -> std::result::Result { Err(crate::plugin_directory::NO_DIRECTORY.into()) } + /// Send `req` and wait for the answer (through the methods above). + fn request(&mut self, req: NetRequest) -> NetReply { + match req { + NetRequest::Plugins { server, req } => NetReply::Plugins(self.plugins(&server, req)), + NetRequest::Library { link, req } => NetReply::Library(self.library(&link, req)), + NetRequest::Upload(o) => NetReply::Upload(self.upload(o)), + } + } + /// Send `req` on a thread of its own and return at once: the answer arrives as [`Event::Net`] + /// with `job` (a failure to send too). False when this link can't, and nothing was sent. + fn background(&mut self, _job: u64, _req: NetRequest) -> bool { + false + } /// End the current session (stop hosting or leave). fn leave(&mut self); /// Your latest presence (the link throttles and coalesces). @@ -634,6 +704,7 @@ impl Session { live.published = Some(p); } Event::Library { link, reply } => self.library_answer(&link, reply), + Event::Net { job, reply } => self.background_answer(job, *reply), } } diff --git a/crates/engine/src/plugin_directory.rs b/crates/engine/src/plugin_directory.rs index a3f97a3..c3bf7dc 100644 --- a/crates/engine/src/plugin_directory.rs +++ b/crates/engine/src/plugin_directory.rs @@ -7,7 +7,8 @@ //! download must match the SHA-256 the directory lists before anything else is read: then the //! bundle is decoded within a size cap, its manifest and module are checked like an imported //! plugin's (the module compiled, never run) and it runs in the same sandbox with its manifest's -//! permissions. Nothing here reaches the network except these commands. +//! permissions. Nothing here reaches the network except these commands (the UI runs them without +//! waiting: [`crate::background`]). use std::cmp::Ordering; @@ -19,6 +20,7 @@ use figcraft_plugin::bundle::{Asset, Bundle, MAX_ASSETS, check_asset_name, compa use serde_json::{Value, json}; use crate::cmd::CommandSpec; +use crate::live::{NetReply, NetRequest}; use crate::params::{get, string}; use crate::plugins::{InstalledPlugin, MANIFEST_FILE, Manifest, installed, register}; use crate::{EngineError, Result, Session, bad, spec}; @@ -68,10 +70,10 @@ fn server(s: &Session, cmd: &str, p: &Value) -> Result { /// One request to the directory at `server`; a refusal is an error. fn request(s: &mut Session, server: &str, req: PluginReq) -> Result { - let link = s.live.link().ok_or_else(|| EngineError::Other(NO_DIRECTORY.into()))?; - match link.plugins(server, req).map_err(EngineError::Other)? { - PluginReply::Failed { reason } => Err(EngineError::Other(reason)), - r => Ok(r), + match s.net(NetRequest::Plugins { server: server.to_string(), req })?.0 { + NetReply::Plugins(Ok(PluginReply::Failed { reason }) | Err(reason)) => Err(EngineError::Other(reason)), + NetReply::Plugins(Ok(r)) => Ok(r), + _ => Err(EngineError::Other("the server answered something else".into())), } } diff --git a/crates/engine/src/plugin_panels_tests.rs b/crates/engine/src/plugin_panels_tests.rs index 4eb01c5..3ba1d9f 100644 --- a/crates/engine/src/plugin_panels_tests.rs +++ b/crates/engine/src/plugin_panels_tests.rs @@ -180,8 +180,11 @@ fn settings_persist_across_runs_per_plugin_and_are_capped() { let wasm = w.build(&steps); install(&mut s, v2("org.test.a"), &wasm); install(&mut s, v2("org.test.b"), &wasm); + assert!(std::mem::take(&mut s.plugins.unsaved), "installing asks the app to save the preferences at once"); assert_eq!(ex(&mut s, "plugin.run", json!({"id": "org.test.a"}))["output"]["command"], "run", "nothing stored yet: it stores"); + assert!(std::mem::take(&mut s.plugins.unsaved), "a changed setting is saved at once too"); assert_eq!(ex(&mut s, "plugin.run", json!({"id": "org.test.a"}))["output"], json!({"name": "Ocean"}), "the next run reads it back"); + assert!(!s.plugins.unsaved, "storing the same value again changes nothing"); assert_eq!(ex(&mut s, "plugin.run", json!({"id": "org.test.b"}))["output"]["command"], "run", "plugins don't see each other's settings"); // Saved with the preferences and read back. let saved = serde_json::to_value(&s.prefs).unwrap(); @@ -191,6 +194,7 @@ fn settings_persist_across_runs_per_plugin_and_are_capped() { // Removing a plugin forgets its settings. ex(&mut s, "plugin.remove", json!({"id": "org.test.b"})); assert!(!s.prefs.plugin_settings.contains_key("org.test.b")); + assert!(s.plugins.unsaved); // 64 KB per plugin; keys and values are checked. let big = format!("\"{}\"", "x".repeat(30_000)); diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index 5a17ff8..f1c4075 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -55,6 +55,9 @@ pub struct Runtime { pub install_dir: Option, /// The plugin directory listed last (Plugins ▸ Manage plugins ▸ Browse shows it). pub directory: Option, + /// The installed plugins or their settings changed since the app last saved the preferences + /// (it saves them at once, so a crash keeps them). + pub unsaved: bool, } impl Runtime { @@ -195,11 +198,13 @@ impl figcraft_plugin::Host for EngineHost<'_> { fn set_setting(&mut self, key: &str, value: Option<&str>) -> std::result::Result<(), String> { let (all, id) = (&mut self.s.prefs.plugin_settings, &self.manifest.id); let Some(text) = value else { - if let Some(mine) = all.get_mut(id) { - mine.remove(key); + if let Some(mine) = all.get_mut(id) + && mine.remove(key).is_some() + { if mine.is_empty() { all.remove(id); } + self.s.plugins.unsaved = true; } return Ok(()); }; @@ -216,7 +221,9 @@ impl figcraft_plugin::Host for EngineHost<'_> { if others + key.len() + v.to_string().len() > MAX_SETTINGS_BYTES { return Err(too_big()); } - all.entry(id.clone()).or_default().insert(key.to_string(), v); + if all.entry(id.clone()).or_default().insert(key.to_string(), v.clone()).as_ref() != Some(&v) { + self.s.plugins.unsaved = true; + } Ok(()) } } @@ -347,6 +354,7 @@ pub(crate) fn register(s: &mut Session, entry: InstalledPlugin, wasm: Vec) - s.plugins.modules.remove(&id); } close_panel(s, &id); + s.plugins.unsaved = true; let list = &mut s.prefs.plugins; match list.iter_mut().find(|x| x.manifest.id == id) { Some(slot) => { @@ -367,6 +375,7 @@ fn remove(s: &mut Session, p: &Value) -> Result { crate::plugin_directory::delete_installed(s, &p); s.prefs.plugins.retain(|x| x.manifest.id != id); s.prefs.plugin_settings.remove(&id); + s.plugins.unsaved = true; s.plugins.modules.remove(&id); close_panel(s, &id); if s.plugins.last.as_ref().is_some_and(|(last, ..)| *last == id) { diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index e9f69f4..270cf7a 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -566,6 +566,7 @@ name = "English" "Install" = "Install" "Installed {name}" = "Installed {name}" "Installed" = "Installed" +"Installing…" = "Installing…" "Instance" = "Instance" "Instance properties" = "Instance properties" "Instance swap" = "Instance swap" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 2017c3e..f8b2bc7 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -567,6 +567,7 @@ status = "draft — needs native review" "Install" = "Instalar" "Installed {name}" = "{name} instalado" "Installed" = "Instalados" +"Installing…" = "Instalando…" "Instance" = "Instancia" "Instance properties" = "Propiedades de instancia" "Instance swap" = "Intercambio de instancia" diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 108ad29..c402bed 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -46,6 +46,10 @@ const SERVER_REFRESH: f64 = 30.0; /// Components listed in a team library's usage. const USAGE_ROWS: usize = 8; const PAD: f32 = 16.0; +/// The team library commands the panel runs without waiting for the server. +const TO_SERVER: &str = "library.publishToServer"; +const USAGE: &str = "library.usage"; +const ADD: &str = "library.add"; /// Where an asset comes from: this file or an enabled library (its id). #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -858,7 +862,12 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let (mut publish, mut to_server) = (false, false); ui.horizontal(|ui| { ui.add_space(PAD); - if server.is_some() { + if server.is_some() && app.session.waiting_for(TO_SERVER) { + crate::widgets::label(ui, tr("Publishing…"), Tokens::get(ui.ctx()).text_secondary); + if crate::widgets::button(ui, tr("Cancel"), false, true).clicked() { + app.cancel_background(TO_SERVER); + } + } else if server.is_some() { to_server = crate::widgets::button(ui, tr("Publish to server"), true, any).clicked(); } else { publish = crate::widgets::button(ui, if m.is_some() { tr("Publish changes") } else { tr("Publish") }, true, any).clicked(); @@ -877,42 +886,55 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { } None => {} } - if to_server { - let description = form.description.clone().unwrap_or_else(|| description.clone()); - if let Some(r) = app.run("library.publishToServer", json!({"description": description, "include": include})) { - form = PublishForm::default(); - app.toast(trf("Published version {v} to the server", &[("v", &r["version"].as_u64().unwrap_or(0))])); - } - } - if publish { - let description = form.description.clone().unwrap_or(description); - if let Some(r) = app.run("library.publish", json!({"description": description, "include": include})) { - form = PublishForm::default(); - // Other files read the saved file. - app.save(); - let saved = app.session.ds().is_ok_and(|d| d.path.is_some() && !d.is_dirty()); - let v = r["version"].as_u64().unwrap_or(0); - app.toast(if saved { - trf("Published version {v}", &[("v", &v)]) - } else { - trf("Published version {v}: save the file so other files can use it", &[("v", &v)]) - }); - } + let params = (publish || to_server).then(|| json!({"description": form.description.clone().unwrap_or(description), "include": include})); + if publish && let Some(r) = params.clone().and_then(|p| app.run("library.publish", p)) { + form = PublishForm::default(); + // Other files read the saved file. + app.save(); + let saved = app.session.ds().is_ok_and(|d| d.path.is_some() && !d.is_dirty()); + let v = r["version"].as_u64().unwrap_or(0); + app.toast(if saved { + trf("Published version {v}", &[("v", &v)]) + } else { + trf("Published version {v}: save the file so other files can use it", &[("v", &v)]) + }); } ui.data_mut(|d| d.insert_temp(form_id, form)); + // After the form is kept: a publish that finishes at once clears it. + if to_server && let Some(params) = params { + let done: crate::Done = Box::new(move |app, ctx, r| { + ctx.data_mut(|d| d.remove::(form_id)); + app.toast(trf("Published version {v} to the server", &[("v", &r["version"].as_u64().unwrap_or(0))])); + }); + app.run_background(&ui.ctx().clone(), TO_SERVER, params, done); + } } /// How other files use this file's library on its server (asked for with a button: it is a /// request to the server). fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { let usage_id = Id::new(("asset_library_usage", link)); - let mut ask = false; + let loading = app.session.waiting_for(USAGE); + let (mut ask, mut stop) = (false, false); ui.horizontal(|ui| { ui.add_space(PAD); - ask = crate::widgets::button(ui, tr("Show usage"), false, true).clicked(); + if loading { + stop = crate::widgets::button(ui, tr("Cancel"), false, true).clicked(); + } else { + ask = crate::widgets::button(ui, tr("Show usage"), false, true).clicked(); + } }); - if ask && let Some(v) = app.run("library.usage", json!({})) { - ui.data_mut(|d| d.insert_temp(usage_id, Arc::new(v))); + if loading { + hint(ui, tr("Fetching from the server…")); + } + if stop { + app.cancel_background(USAGE); + } + if ask { + let done: crate::Done = Box::new(move |_, ctx, v| { + ctx.data_mut(|d| d.insert_temp(usage_id, Arc::new(v))); + }); + app.run_background(&ui.ctx().clone(), USAGE, json!({}), done); } let Some(v) = ui.data(|d| d.get_temp::>(usage_id)) else { ui.add_space(8.0); @@ -1012,15 +1034,24 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { add = crate::widgets::button(ui, tr("Add library file…"), false, true).clicked(); }); ui.add_space(4.0); - ui.horizontal(|ui| { - ui.add_space(PAD); - let pasted = crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)")); - if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) - && let Some(r) = app.run("library.add", json!({"path": link})) - { - app.toast(trf("Added the library \"{name}\"", &[("name", &r["name"].as_str().unwrap_or_default())])); - } - }); + let pasted = ui + .horizontal(|ui| { + ui.add_space(PAD); + crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)")) + }) + .inner; + if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) { + app.run_background(&ui.ctx().clone(), ADD, json!({"path": link}), Box::new(|app, _, r| added(app, &r))); + } + if app.session.waiting_for(ADD) { + hint(ui, tr("Fetching from the server…")); + ui.horizontal(|ui| { + ui.add_space(PAD); + if crate::widgets::button(ui, tr("Cancel"), false, true).clicked() { + app.cancel_background(ADD); + } + }); + } if let Some(id) = remove { app.run("library.remove", json!({"id": id})); } @@ -1035,14 +1066,19 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { trn("Swapped {n} assets; {missed} had no match and still use the old library", moved as usize, &[("missed", &missed)]) }); } - if add && let Some(p) = app.services.pick_open(tr("Add library"), &["figcraft"]) { - let path = p.to_string_lossy().to_string(); - if let Some(r) = app.run("library.add", json!({"path": path})) { - app.toast(trf("Added the library \"{name}\"", &[("name", &r["name"].as_str().unwrap_or_default())])); - } + if add + && let Some(p) = app.services.pick_open(tr("Add library"), &["figcraft"]) + && let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) + { + added(app, &r); } } +/// "Added the library …" after `library.add` answered `r`. +fn added(app: &mut FigApp, r: &Value) { + app.toast(trf("Added the library \"{name}\"", &[("name", &r["name"].as_str().unwrap_or_default())])); +} + /// Tests: the components whose thumbnails are cached (for this file's assets). #[cfg(test)] pub(crate) fn cached_thumbnails(ctx: &egui::Context) -> Vec { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dcd66e5..b33fbb3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -163,6 +163,9 @@ pub trait Services { } /// After a panic in a frame: keep recovery copies of unsaved files. fn emergency_save(&mut self, _session: &Session) {} + /// Keep the preferences now (the installed plugins or their settings changed), as the app + /// does when it quits. + fn save_prefs(&mut self, _prefs: &figcraft_engine::Prefs) {} /// The OS or browser language as a tag ("es-ES"), for the "system" language preference. fn system_language(&self) -> Option { None @@ -437,8 +440,13 @@ pub struct FigApp { pub windows: windows::Windows, /// `window.new` / `window.focus` asked for by commands, for the end of the frame. window_requests: Vec, + /// Commands waiting for a server ([`FigApp::run_background`]) and what to do with their results. + waiting: Vec<(String, Done)>, } +/// What to do with the result of a command run in the background. +pub type Done = Box; + impl FigApp { pub fn new(session: Session, services: Box) -> Self { Self { @@ -457,23 +465,55 @@ impl FigApp { frame_stats: frame_stats::FrameStats::default(), windows: windows::Windows::default(), window_requests: Vec::new(), + waiting: Vec::new(), } } /// Run a command, surfacing errors as a toast. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Option { - match self.session.execute(id, ¶ms) { - Ok(v) => { - self.handle_ui_requests(); - Some(v) - } - Err(e) => { - self.handle_ui_requests(); - let msg = e.to_string(); - log::warn!("{id}: {msg}"); - self.toast(msg.clone()); - self.ui.last_error = Some(msg); - None + let r = self.session.execute(id, ¶ms); + self.handle_ui_requests(); + r.map_err(|e| self.failed(id, &e)).ok() + } + + /// Show command `id`'s error. + fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) { + let msg = e.to_string(); + log::warn!("{id}: {msg}"); + self.toast(msg.clone()); + self.ui.last_error = Some(msg); + } + + /// Run a command that may talk to a server without waiting for it (the engine's + /// [`start_background`](Session::start_background)): `done` gets the result, at once or when + /// the answer arrives (the network wakes the window then); errors show as with [`run`](Self::run). + /// [`Session::waiting_for`] tells the panels to show that it's on its way. + pub fn run_background(&mut self, ctx: &egui::Context, id: &str, params: Value, done: Done) { + self.waiting.retain(|(c, _)| c != id); + let r = self.session.start_background(id, params); + self.handle_ui_requests(); + match r { + Ok(Some(v)) => done(self, ctx, v), + Ok(None) => self.waiting.push((id.to_string(), done)), + Err(e) => self.failed(id, &e), + } + } + + /// Stop waiting for command `id` (Cancel, or its window closed): its answer is dropped. + pub fn cancel_background(&mut self, id: &str) { + self.session.cancel_background(id); + self.waiting.retain(|(c, _)| c != id); + } + + /// Hand the results of background commands that finished to what's waiting for them. + fn background_done(&mut self, ctx: &egui::Context) { + while let Some((id, r)) = self.session.take_background_done() { + self.handle_ui_requests(); + let done = self.waiting.iter().position(|(c, _)| *c == id).map(|i| self.waiting.remove(i).1); + match (r, done) { + (Ok(v), Some(done)) => done(self, ctx, v), + (Ok(_), None) => {} + (Err(e), _) => self.failed(&id, &e), } } } @@ -775,10 +815,11 @@ impl FigApp { return; } // What happened in a live session (the network woke us for it). - if self.session.live_poll() - && let Some(m) = self.session.message.take() - { - self.toast(m); + if self.session.live_poll() { + if let Some(m) = self.session.message.take() { + self.toast(m); + } + self.background_done(&ctx); } windows::begin(self, &ctx); let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone()); @@ -788,6 +829,10 @@ impl FigApp { windows::repaint_others(self, &ctx); } windows::show(self, &ctx); + // Plugins installed, removed or set up are kept at once: a crash doesn't lose them. + if std::mem::take(&mut self.session.plugins.unsaved) { + self.services.save_prefs(&self.session.prefs); + } #[cfg(not(target_arch = "wasm32"))] { self.ui.frame_ms = t0.elapsed().as_secs_f64() * 1000.0; diff --git a/crates/ui-egui/src/plugins_browse_tests.rs b/crates/ui-egui/src/plugins_browse_tests.rs index bcb04c1..4aea30c 100644 --- a/crates/ui-egui/src/plugins_browse_tests.rs +++ b/crates/ui-egui/src/plugins_browse_tests.rs @@ -1,13 +1,15 @@ //! The Manage plugins window's Browse tab, driven headless against a scripted directory: Install //! only asks (it shows the permissions, nothing is downloaded), the confirmation's Install installs, -//! and the installed plugin's row then offers Remove. +//! and the installed plugin's row then offers Remove. Against a directory that answers later (as the +//! network does), Browse and Install show that they're on their way and can be cancelled, the +//! window keeps working meanwhile, and the installed plugin is saved at once. use std::sync::{Arc, Mutex}; use egui::Pos2; use figcraft_engine::Session; use figcraft_engine::figcraft_collab::message::{ClientMsg, PluginEntry, PluginReply, PluginReq}; -use figcraft_engine::live::{Event, HostOptions, LiveLink, Presence, SessionInfo}; +use figcraft_engine::live::{Event, HostOptions, LiveLink, NetReply, NetRequest, Presence, SessionInfo}; use figcraft_engine::plugin_directory::bundle::{Bundle, sha256_hex}; use serde_json::json; @@ -18,6 +20,26 @@ const ID: &str = "org.example.grid"; struct Link(Arc>, Vec); +/// The scripted directory's answer to `req` for the bundle `bytes`. +fn reply(bytes: &[u8], req: PluginReq) -> PluginReply { + let entry = PluginEntry { + id: ID.into(), + name: "Grid maker".into(), + version: "1.0.0".into(), + author: "Ada".into(), + access: "write".into(), + commands: vec!["shape.rectangle".into()], + bytes: bytes.len() as u64, + sha256: sha256_hex(bytes), + ..PluginEntry::default() + }; + match req { + PluginReq::List { .. } => PluginReply::List(vec![entry]), + PluginReq::Fetch { .. } => PluginReply::Bundle { entry, bundle: bytes.to_vec() }, + _ => PluginReply::Failed { reason: "no".into() }, + } +} + impl LiveLink for Link { fn host(&mut self, _: HostOptions) -> Result { Err("no".into()) @@ -32,25 +54,62 @@ impl LiveLink for Link { Vec::new() } fn plugins(&mut self, _: &str, req: PluginReq) -> Result { - let entry = PluginEntry { - id: ID.into(), - name: "Grid maker".into(), - version: "1.0.0".into(), - author: "Ada".into(), - access: "write".into(), - commands: vec!["shape.rectangle".into()], - bytes: self.1.len() as u64, - sha256: sha256_hex(&self.1), - ..PluginEntry::default() - }; - Ok(match req { - PluginReq::List { .. } => PluginReply::List(vec![entry]), - PluginReq::Fetch { .. } => { - *self.0.lock().unwrap() += 1; - PluginReply::Bundle { entry, bundle: self.1.clone() } - } - _ => PluginReply::Failed { reason: "no".into() }, - }) + if matches!(req, PluginReq::Fetch { .. }) { + *self.0.lock().unwrap() += 1; + } + Ok(reply(&self.1, req)) + } +} + +/// A directory that answers later: the requests out and the answers handed back. +#[derive(Default)] +struct Wire { + out: Vec<(u64, PluginReq)>, + answers: Vec, +} + +struct Later(Arc>); + +impl LiveLink for Later { + fn host(&mut self, _: HostOptions) -> Result { + Err("no".into()) + } + fn join(&mut self, _: &str, _: &str) -> Result<(), String> { + Err("no".into()) + } + fn leave(&mut self) {} + fn presence(&mut self, _: &Presence) {} + fn send(&mut self, _: ClientMsg) {} + fn poll(&mut self) -> Vec { + std::mem::take(&mut self.0.lock().unwrap().answers) + } + fn plugins(&mut self, _: &str, _: PluginReq) -> Result { + Err("the window waited for the directory".into()) + } + fn background(&mut self, job: u64, req: NetRequest) -> bool { + if let NetRequest::Plugins { req, .. } = req { + self.0.lock().unwrap().out.push((job, req)); + } + true + } +} + +/// Answer the requests out, as the network would; how many there were. +fn deliver(wire: &Arc>, bytes: &[u8]) -> usize { + let mut w = wire.lock().unwrap(); + let out = std::mem::take(&mut w.out); + for (job, req) in &out { + w.answers.push(Event::Net { job: *job, reply: Box::new(NetReply::Plugins(Ok(reply(bytes, req.clone())))) }); + } + out.len() +} + +/// Services that keep what the app saved. +struct Saved(Arc>>); + +impl crate::Services for Saved { + fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) { + self.0.lock().unwrap().push(prefs.clone()); } } @@ -76,12 +135,21 @@ fn click(u: &mut Ui, at: Pos2) { u.button(at, false); } -#[test] -fn install_asks_first_and_then_installs() { +fn bundle() -> Vec { let wasm = wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap(); let manifest = json!({"id": ID, "name": "Grid maker", "version": "1.0.0", "main": "g.wasm", "permissions": {"access": "write", "commands": ["shape.rectangle"]}}); - let bytes = Bundle { manifest: manifest.to_string(), wasm, assets: Vec::new() }.encode(); + Bundle { manifest: manifest.to_string(), wasm, assets: Vec::new() }.encode() +} + +/// The Browse button: the lower of the two "Browse" (the tab is above it). +fn browse_button(u: &mut Ui) -> Pos2 { + named(u, "Browse").iter().map(centre).max_by(|a, b| a.y.total_cmp(&b.y)).unwrap() +} + +#[test] +fn install_asks_first_and_then_installs() { + let bytes = bundle(); let fetched = Arc::new(Mutex::new(0)); let mut s = Session::new(); s.live.install(Box::new(Link(fetched.clone(), bytes))); @@ -110,3 +178,51 @@ fn install_asks_first_and_then_installs() { u.frame(vec![]); assert!(named(&mut u, "Install").is_empty() && !named(&mut u, "Remove").is_empty(), "the row now offers Remove"); } + +#[test] +fn browsing_and_installing_show_progress_and_never_wait() { + let (wire, bytes) = (Arc::new(Mutex::new(Wire::default())), bundle()); + let mut s = Session::new(); + s.live.install(Box::new(Later(wire.clone()))); + let mut u = Ui::new(s); + let saved = Arc::new(Mutex::new(Vec::new())); + u.app.services = Box::new(Saved(saved.clone())); + u.app.ui.plugins_window = true; + u.app.ui.plugins_manage.tab = PluginsTab::Browse; + u.app.ui.plugins_manage.server = Some("ws://127.0.0.1:9/".into()); + u.ctx.enable_accesskit(); + u.frame(vec![]); + let browse = browse_button(&mut u); + click(&mut u, browse); + assert!(u.app.session.waiting_for("plugin.directory.list")); + assert!(shows(&mut u, "Fetching from the server…") && !named(&mut u, "Cancel").is_empty(), "Browse shows it's on its way"); + // Cancel stops waiting: the late answer is dropped. + let cancel = centre(&named(&mut u, "Cancel")[0]); + click(&mut u, cancel); + assert_eq!(deliver(&wire, &bytes), 1); + u.frame(vec![]); + assert!(u.app.session.plugins.directory.is_none() && !u.app.session.waiting_for("plugin.directory.list")); + // Browse again; the answer arrives and the listing shows. + let browse = browse_button(&mut u); + click(&mut u, browse); + assert_eq!(deliver(&wire, &bytes), 1); + u.frame(vec![]); + let row = named(&mut u, "Install"); + assert_eq!(row.len(), 1, "the listing arrived"); + let row_at = centre(&row[0]); + click(&mut u, row_at); + u.frame(vec![]); + let confirm = named(&mut u, "Install").into_iter().map(|n| centre(&n)).find(|p| p.distance(row_at) > 4.0).unwrap(); + click(&mut u, confirm); + assert!(u.app.session.waiting_for("plugin.directory.install")); + assert!(shows(&mut u, "Installing…"), "the confirmation shows the install on its way"); + assert!(u.app.ui.plugins_manage.confirm.is_some() && u.app.session.prefs.plugins.is_empty()); + assert!(saved.lock().unwrap().is_empty()); + assert_eq!(deliver(&wire, &bytes), 1); + u.frame(vec![]); + assert_eq!(u.app.session.prefs.plugins.len(), 1, "installed when the download arrived"); + assert_eq!(u.app.ui.plugins_manage.confirm, None); + assert!(u.app.ui.toast.as_ref().is_some_and(|(t, _)| t.contains("Installed Grid maker")), "{:?}", u.app.ui.toast); + let kept = saved.lock().unwrap(); + assert_eq!(kept.last().map(|p| p.plugins.len()), Some(1), "the plugin list was saved at once, not at quit"); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index ea421d0..0d4da40 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -232,8 +232,14 @@ enum Manage { Import, Browse(String), Confirm(String), + /// Stop waiting for the directory's listing. + StopBrowsing, } +/// The directory requests the window sends without waiting (the server answers later). +const LIST: &str = "plugin.directory.list"; +const INSTALL: &str = "plugin.directory.install"; + /// Width of the Manage plugins window, and of a plugin's text beside its button. const MANAGE_W: f32 = 400.0; const ROW_TEXT_W: f32 = 300.0; @@ -261,12 +267,20 @@ fn manage_window(app: &mut FigApp, ctx: &Context) { Some(Manage::Import) => import_dialog(app), Some(Manage::Browse(server)) => { app.ui.plugins_manage.confirm = None; - app.run("plugin.directory.list", json!({"server": server})); + app.cancel_background(INSTALL); + app.run_background(ctx, LIST, json!({"server": server}), Box::new(|_, _, _| {})); } Some(Manage::Confirm(id)) => app.ui.plugins_manage.confirm = Some(id), + Some(Manage::StopBrowsing) => app.cancel_background(LIST), None => {} } - if open && app.ui.plugins_manage.confirm.is_some() { + if !open { + // Closing the window stops waiting for its server. + app.cancel_background(LIST); + app.cancel_background(INSTALL); + app.ui.plugins_manage.confirm = None; + } + if app.ui.plugins_manage.confirm.is_some() { confirm_window(app, ctx, &t); } } @@ -306,6 +320,7 @@ fn installed_tab(app: &FigApp, ui: &mut Ui, t: &Tokens) -> Option { fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option { let mut asked = None; let mut server = app.ui.plugins_manage.server.clone().or_else(|| app.session.prefs.plugin_directory.clone()).unwrap_or_default(); + let loading = app.session.waiting_for(LIST); ui.horizontal(|ui| { // The field commits on Enter (which also browses) or when Browse takes the focus. let mut enter = false; @@ -316,10 +331,19 @@ fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option { server = s.clone(); app.ui.plugins_manage.server = Some(s); } - if (widgets::button(ui, tr("Browse"), true, true).clicked() || enter) && !server.trim().is_empty() { + if loading { + if widgets::button(ui, tr("Cancel"), false, true).clicked() { + asked = Some(Manage::StopBrowsing); + } + } else if (widgets::button(ui, tr("Browse"), true, true).clicked() || enter) && !server.trim().is_empty() { asked = Some(Manage::Browse(server.trim().to_string())); } }); + if loading { + ui.add_space(8.0); + widgets::label(ui, tr("Fetching from the server…"), t.text_secondary); + return asked; + } let Some(listing) = app.session.plugins.directory.as_ref() else { ui.add_space(8.0); widgets::label(ui, tr("Enter the address of a figcraft-server and press Browse to see the plugins it offers."), t.text_secondary); @@ -339,6 +363,8 @@ fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option { if shown.is_empty() { widgets::label(ui, &trf("No plugins on {server}.", &[("server", &listing.server)]), t.text_secondary); } + // One install at a time: the rows wait for it. + let installing = app.session.waiting_for(INSTALL); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { for (i, e) in shown.iter().enumerate() { if i > 0 { @@ -359,7 +385,7 @@ fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option { Some(_) => (tr("Remove"), false, Manage::Remove), }; ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { - if widgets::button(ui, label, primary, true).clicked() { + if widgets::button(ui, label, primary, !installing).clicked() { asked = Some(ask(e.id.clone())); } }); @@ -402,6 +428,7 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { return; }; let update = installed_version(&app.session, &e.id).is_some(); + let installing = app.session.waiting_for(INSTALL); let title = if update { tr("Update plugin") } else { tr("Install plugin") }; let (mut open, mut install, mut cancel) = (true, false, false); window(title, ctx).id(Id::new("plugin_install_confirm")).open(&mut open).show(ctx, |ui| { @@ -432,20 +459,32 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { widgets::label(ui, &trf("SHA-256: {sha}…", &[("sha", &e.sha256.get(..16).unwrap_or_default())]), t.text_secondary); ui.add_space(12.0); ui.horizontal(|ui| { - install = widgets::button(ui, if update { tr("Update") } else { tr("Install") }, true, true).clicked(); + if installing { + widgets::label(ui, tr("Installing…"), t.text_secondary); + } else { + install = widgets::button(ui, if update { tr("Update") } else { tr("Install") }, true, true).clicked(); + } cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); }); }); - if !open || cancel || install { + if !open || cancel { + app.cancel_background(INSTALL); app.ui.plugins_manage.confirm = None; } - if install - && let Some(v) = - app.run("plugin.directory.install", json!({"id": e.id, "version": e.version, "sha256": e.sha256, "server": server, "confirm": true})) - { - let name = v["name"].as_str().unwrap_or(tr("the plugin")).to_string(); - let args: &[(&str, &dyn std::fmt::Display)] = &[("name", &name)]; - app.toast(if update { trf("Updated {name}", args) } else { trf("Installed {name}", args) }); + if install { + // The window stays open (Installing…) until the download is in; an error leaves it open. + let params = json!({"id": e.id, "version": e.version, "sha256": e.sha256, "server": server, "confirm": true}); + app.run_background( + ctx, + INSTALL, + params, + Box::new(move |app, _, v| { + app.ui.plugins_manage.confirm = None; + let name = v["name"].as_str().unwrap_or(tr("the plugin")).to_string(); + let args: &[(&str, &dyn std::fmt::Display)] = &[("name", &name)]; + app.toast(if update { trf("Updated {name}", args) } else { trf("Installed {name}", args) }); + }), + ); } } diff --git a/docs/plugins.md b/docs/plugins.md index e2a62a1..3585472 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -31,6 +31,9 @@ permissions its manifest asks for, and nothing more. - **Plugins ▸ Development ▸ Import plugin from manifest…** picks a `figcraft-plugin.json`. The plugin is kept in the preferences and its module is read again on every run, so a rebuilt development plugin runs its new code without importing it again. +- The desktop app saves the preferences the moment a plugin is imported, installed, updated or + removed, or changes its settings (a temporary file renamed over the old one), so a crash or a + killed app never loses them; it doesn't wait until it quits. - The installed plugins head the **Plugins** menu: a plugin with one command is an item, one with several a submenu. A command with parameters (an ellipsis after its name) first opens a small form with a field per parameter, drawn with FigCraft's own widgets; **Run** runs it. @@ -129,7 +132,11 @@ The list shows each plugin's latest version, author (as the publisher typed it), access it asks for; the search filters it. **Install** opens a confirmation that lists what the plugin asks permission to do (read or edit the document, the commands it may call, whether it draws a widget), the server it comes from and its SHA-256; only its **Install** button downloads and -installs it. The download must match the SHA-256 the directory lists, and the bundle is then +installs it. The app never waits for the server: while the listing or the download is on its way, +Browse shows "Fetching from the server…" and the confirmation "Installing…", each with **Cancel**, +and the rest of the app keeps working (closing the window cancels too). The window is drawn again +only when the answer arrives. Scripts, the CLI, the control channel and the MCP server get their +answer from the same commands as before, waiting for it. The download must match the SHA-256 the directory lists, and the bundle is then checked like an imported plugin (manifest, plugin API version, the module compiled but not run, asset names) before anything is written. A plugin installed this way runs in the same sandbox, with the same limits and its manifest's permissions, as any other. diff --git a/docs/server.md b/docs/server.md index dc6a140..8de1225 100644 --- a/docs/server.md +++ b/docs/server.md @@ -97,6 +97,11 @@ which other files use without anyone copying the library file around. - **Swapping.** `library.swap` (the swap button by an enabled library) moves a file's instances, styles and variables from one library to another's of the same names, keeping overrides. +The app never waits for the server: publishing, adding a library by its link and **Show usage** +send their requests on a thread of their own, show that they're on their way (with **Cancel**) and +finish when the answer arrives, acting on the file they started in. Scripts, the CLI and the MCP +server run the same commands and wait for the answer. + The browser build can't use team libraries yet (it has no library support of its own). ## Plugin directory