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).
This commit is contained in:
bflatastic 2026-10-12 05:47:14 +07:00 committed by BFlat
parent 9f13cb4ba2
commit 01bc5a9dac
25 changed files with 1119 additions and 145 deletions

1
Cargo.lock generated
View File

@ -1422,6 +1422,7 @@ dependencies = [
"log",
"rfd",
"serde_json",
"wat",
"webbrowser",
"windows",
"winresource",

View File

@ -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<u8> {
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);
}

View File

@ -71,6 +71,8 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
Ok(Running { hosting })
}
#[cfg(test)]
mod background_tests;
#[cfg(test)]
mod cli_tests;
#[cfg(test)]

View File

@ -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"

View File

@ -19,6 +19,8 @@ mod services;
mod args_tests;
#[cfg(test)]
mod recovery_tests;
#[cfg(test)]
mod services_tests;
use std::sync::{Arc, Mutex};

View File

@ -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<arboard::Clipboard>,
/// Where the preferences live ([`config_dir`]).
dir: Option<PathBuf>,
pub prefs: Option<Prefs>,
/// The system's reduce-motion setting (read in the background at startup).
reduce_motion: Arc<AtomicBool>,
@ -31,10 +33,13 @@ pub fn config_dir() -> Option<PathBuf> {
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<PathBuf>, 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::<Prefs>(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);

View File

@ -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::<Vec<_>>(), [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);
}

View File

@ -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<Inbox>,
/// 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<AtomicBool>,
@ -163,10 +165,7 @@ impl LiveLink for NetLink {
}
fn upload(&mut self, o: PublishOptions) -> Result<Published, String> {
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<LibraryReply, String> {
@ -174,9 +173,24 @@ impl LiveLink for NetLink {
}
fn plugins(&mut self, server: &str, req: PluginReq) -> Result<PluginReply, String> {
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<Published, String> {
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<PluginReply, String> {
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();

View File

@ -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<u64>,
/// The answers so far, with the requests they answer.
answers: Vec<(NetRequest, NetReply)>,
/// The request out now.
out: Option<NetRequest>,
}
/// 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<bool>,
out: Option<NetRequest>,
}
/// The commands waiting for servers, and the outcomes the UI hasn't taken yet.
#[derive(Debug, Default)]
pub struct Background {
next: u64,
jobs: Vec<Job>,
run: Option<Run>,
done: Vec<(String, Result<Value>)>,
}
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<Option<Value>> {
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<Value>)> {
(!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<Option<Value>> {
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<NetRequest>)> {
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;

View File

@ -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<Event>,
waited: usize,
/// The team library the scripted server keeps (an archive), if any.
library: Option<Vec<u8>>,
}
struct Link(Arc<Mutex<Wire>>);
impl LiveLink for Link {
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
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<Event> {
std::mem::take(&mut self.0.lock().unwrap().answers)
}
fn plugins(&mut self, _: &str, req: PluginReq) -> Result<PluginReply, String> {
self.0.lock().unwrap().waited += 1;
Ok(directory(req))
}
fn library(&mut self, _: &str, req: LibraryReq) -> Result<LibraryReply, String> {
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<u8> {
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap()
}
fn bundle() -> Vec<u8> {
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<Mutex<Wire>>) {
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<Mutex<Wire>>) -> 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<u8> {
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"));
}

View File

@ -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<Value> {
// 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<Value> {
(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 {

View File

@ -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(),
}
}

View File

@ -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<LibraryReply> {
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<LibraryReq>)> {
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<LibraryReply> {
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<Arc<PublishedLibrary>> {
@ -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)
}

View File

@ -193,6 +193,63 @@ pub enum Event {
link: String,
reply: std::result::Result<LibraryReply, String>,
},
/// The answer to request `job` sent in the background ([`LiveLink::background`]).
Net {
job: u64,
reply: Box<NetReply>,
},
}
/// 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<PluginReply, String>),
Library(std::result::Result<LibraryReply, String>),
Upload(std::result::Result<Published, String>),
}
/// 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<PluginReply, String> {
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),
}
}

View File

@ -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<String> {
/// One request to the directory at `server`; a refusal is an error.
fn request(s: &mut Session, server: &str, req: PluginReq) -> Result<PluginReply> {
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())),
}
}

View File

@ -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));

View File

@ -55,6 +55,9 @@ pub struct Runtime {
pub install_dir: Option<std::path::PathBuf>,
/// The plugin directory listed last (Plugins ▸ Manage plugins ▸ Browse shows it).
pub directory: Option<crate::plugin_directory::Listing>,
/// 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<u8>) -
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<Value> {
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) {

View File

@ -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"

View File

@ -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"

View File

@ -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::<PublishForm>(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::<Arc<Value>>(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<NodeId> {

View File

@ -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<String> {
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<figcraft_engine::UiRequest>,
/// 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<dyn FnOnce(&mut FigApp, &egui::Context, Value)>;
impl FigApp {
pub fn new(session: Session, services: Box<dyn Services>) -> 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<Value> {
match self.session.execute(id, &params) {
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, &params);
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;

View File

@ -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<Mutex<usize>>, Vec<u8>);
/// 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<SessionInfo, String> {
Err("no".into())
@ -32,25 +54,62 @@ impl LiveLink for Link {
Vec::new()
}
fn plugins(&mut self, _: &str, req: PluginReq) -> Result<PluginReply, String> {
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<Event>,
}
struct Later(Arc<Mutex<Wire>>);
impl LiveLink for Later {
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
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<Event> {
std::mem::take(&mut self.0.lock().unwrap().answers)
}
fn plugins(&mut self, _: &str, _: PluginReq) -> Result<PluginReply, String> {
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<Mutex<Wire>>, 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<Mutex<Vec<figcraft_engine::Prefs>>>);
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<u8> {
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");
}

View File

@ -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<Manage> {
fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option<Manage> {
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<Manage> {
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<Manage> {
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<Manage> {
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) });
}),
);
}
}

View File

@ -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.

View File

@ -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