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