Files
figcraft/crates/engine/src/plugin_directory.rs
T
bflatastic 01bc5a9dac 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).
2026-10-12 06:15:00 +07:00

311 lines
14 KiB
Rust

//! Plugins from a server's plugin directory (`figcraft-server`): listing it, installing and updating
//! plugins from it, and publishing and unpublishing them with the server's key. See
//! `docs/plugins.md` and `docs/server.md`.
//!
//! Everything a server sends is untrusted. An install needs the user's say-so (`confirm: true`,
//! which the app passes only from its confirmation step; nothing here opens a dialog), and the
//! 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 (the UI runs them without
//! waiting: [`crate::background`]).
use std::cmp::Ordering;
pub use figcraft_collab::message::PluginEntry;
use figcraft_collab::message::{PluginReply, PluginReq};
pub use figcraft_plugin::bundle;
pub use figcraft_plugin::bundle::newer as newer_version;
use figcraft_plugin::bundle::{Asset, Bundle, MAX_ASSETS, check_asset_name, compare_versions, newer, sha256_hex};
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};
/// Why there's no plugin directory here (the browser build, sessions without the network).
pub const NO_DIRECTORY: &str = "plugin directories on a server need the desktop app, figcraft-cli or the MCP server";
/// Largest bundle downloaded or published (a server's own cap is at most this).
pub const MAX_BUNDLE: usize = 32 << 20;
/// The module's file name in an installed plugin's folder, and the folder its assets go in.
const MODULE: &str = "module.wasm";
const ASSETS: &str = "assets";
/// A directory as it was listed last.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Listing {
/// Its address (`ws://host:port/`, without a key).
pub server: String,
pub entries: Vec<PluginEntry>,
}
/// A server address without its query (the key), as it's shown and remembered.
pub fn address(server: &str) -> String {
server.trim().split('?').next().unwrap_or_default().to_string()
}
/// The installed version of plugin `id`, if it is installed.
pub fn installed_version<'a>(s: &'a Session, id: &str) -> Option<&'a str> {
s.prefs.plugins.iter().find(|p| p.manifest.id == id).map(|p| p.manifest.version.as_str())
}
/// Entry `e` with whether it's installed (its version) and whether that's older.
fn describe(s: &Session, e: &PluginEntry) -> Value {
let mut v = serde_json::to_value(e).unwrap_or_else(|_| json!({"id": e.id}));
let have = installed_version(s, &e.id);
v["installed"] = json!(have);
v["update"] = json!(have.is_some_and(|h| newer(&e.version, h)));
v
}
/// `server`, or the directory browsed last.
fn server(s: &Session, cmd: &str, p: &Value) -> Result<String> {
string(p, "server")
.filter(|v| !v.trim().is_empty())
.or_else(|| s.prefs.plugin_directory.clone())
.ok_or_else(|| bad(cmd, "`server` required: the server's address, like ws://host:47900/"))
}
/// One request to the directory at `server`; a refusal is an error.
fn request(s: &mut Session, server: &str, req: PluginReq) -> Result<PluginReply> {
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())),
}
}
fn unexpected(cmd: &str) -> EngineError {
EngineError::Other(format!("{cmd}: the server answered something else"))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plugin.directory.list";
let server = server(s, cmd, p)?;
let query = string(p, "query").unwrap_or_default();
let PluginReply::List(entries) = request(s, &server, PluginReq::List { query })? else { return Err(unexpected(cmd)) };
let server = address(&server);
let plugins: Vec<Value> = entries.iter().map(|e| describe(s, e)).collect();
s.prefs.plugin_directory = Some(server.clone());
s.plugins.directory = Some(Listing { server: server.clone(), entries });
Ok(json!({"server": server, "plugins": plugins}))
}
/// Download, check and install a plugin (see the module docs).
fn install(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plugin.directory.install";
let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required (see plugin.directory.list)"))?;
if get(p, "confirm") != Some(&Value::Bool(true)) {
return Err(bad(
cmd,
"installing a plugin runs its code on this machine: check the permissions it asks for (plugin.directory.list) and pass `confirm: true`",
));
}
let server = server(s, cmd, p)?;
let version = string(p, "version");
let PluginReply::Bundle { entry, bundle: bytes } = request(s, &server, PluginReq::Fetch { id: id.clone(), version: version.clone() })? else {
return Err(unexpected(cmd));
};
let fail = |why: &str| EngineError::Other(format!("{id} wasn't installed: {why}"));
if entry.id != id || version.as_deref().is_some_and(|v| compare_versions(&entry.version, v) != Some(Ordering::Equal)) {
return Err(fail("the server sent another plugin than the one asked for"));
}
if bytes.len() > MAX_BUNDLE {
return Err(fail("the download is too large"));
}
let sha = sha256_hex(&bytes);
if sha != entry.sha256 {
return Err(fail("the download doesn't match the SHA-256 the directory lists"));
}
if string(p, "sha256").is_some_and(|want| !want.trim().eq_ignore_ascii_case(&sha)) {
return Err(fail("the download isn't the version that was shown (its SHA-256 differs)"));
}
let bundle = Bundle::decode(&bytes, MAX_BUNDLE).map_err(|e| fail(&e))?;
let mut manifest = bundle.check().map_err(|e| fail(&e))?;
if manifest.id != entry.id || manifest.version != entry.version {
return Err(fail("its manifest doesn't match the directory's entry"));
}
let source = address(&server);
let path = save(s, &mut manifest, &bundle).map_err(|e| fail(&e))?;
let out = json!({
"installed": manifest.id,
"name": manifest.name,
"version": manifest.version,
"permissions": manifest.permissions,
"sha256": sha,
"path": path,
"source": source,
});
let replaced = register(s, InstalledPlugin { manifest, path, source: Some(source) }, bundle.wasm);
let mut out = out;
out["replaced"] = json!(replaced);
Ok(out)
}
/// Keep an installed plugin's files in its own folder under the install folder (named by the hash
/// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without
/// an install folder (the browser, the CLI and tests) the plugin lasts for the session.
fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option<String>, String> {
let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) };
#[cfg(not(target_arch = "wasm32"))]
{
use std::fs;
let hash = sha256_hex(m.id.as_bytes());
let folder = root.join(hash.get(..32).unwrap_or(&hash));
let partial = folder.with_extension("partial");
let _ = fs::remove_dir_all(&partial);
m.main = MODULE.into();
let write = || -> std::io::Result<()> {
fs::create_dir_all(&partial)?;
fs::write(partial.join(MODULE), &b.wasm)?;
if !b.assets.is_empty() {
fs::create_dir_all(partial.join(ASSETS))?;
for a in &b.assets {
// Checked plain names (`Bundle::check`): never a path.
fs::write(partial.join(ASSETS).join(&a.name), &a.data)?;
}
}
fs::write(partial.join(MANIFEST_FILE), serde_json::to_vec_pretty(&*m).map_err(std::io::Error::other)?)
};
let done = write().and_then(|()| {
if folder.exists() {
fs::remove_dir_all(&folder)?;
}
fs::rename(&partial, &folder)
});
if let Err(e) = done {
let _ = fs::remove_dir_all(&partial);
return Err(format!("couldn't write it to {}: {e}", root.display()));
}
Ok(Some(folder.join(MANIFEST_FILE).to_string_lossy().into_owned()))
}
#[cfg(target_arch = "wasm32")]
{
let _ = (root, m, b);
Ok(None)
}
}
/// Delete the folder of a plugin installed from a directory (only one inside the install folder).
pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
#[cfg(not(target_arch = "wasm32"))]
if let (Some(_), Some(path), Some(root)) = (&p.source, &p.path, &s.plugins.install_dir)
&& let Some(folder) = std::path::Path::new(path).parent()
&& folder.parent() == Some(root.as_path())
&& let Err(e) = std::fs::remove_dir_all(folder)
{
log::warn!("plugins: couldn't delete {}: {e}", folder.display());
}
#[cfg(target_arch = "wasm32")]
let _ = (s, p);
}
/// The bundle of the plugin folder (or manifest) at `path`: its manifest as written, its module and
/// the files in its `assets` folder.
#[cfg(not(target_arch = "wasm32"))]
fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
use crate::plugins::{module_file, read_file};
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
if file.is_dir() {
file.push(MANIFEST_FILE);
}
let text =
String::from_utf8(read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?).map_err(|_| bad(cmd, "the manifest isn't UTF-8 text"))?;
let m = Manifest::parse(&text).map_err(|e| bad(cmd, e))?;
let wasm = module_file(&file, &m)?;
let mut assets = Vec::new();
let dir = file.parent().map(|d| d.join(ASSETS));
if let Some(entries) = dir.as_ref().and_then(|d| std::fs::read_dir(d).ok()) {
let mut names: Vec<(String, std::path::PathBuf)> =
entries.flatten().filter(|e| e.path().is_file()).map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
names.sort();
if names.len() > MAX_ASSETS {
return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files")));
}
for (name, path) in names {
check_asset_name(&name).map_err(|e| bad(cmd, e))?;
assets.push(Asset { name, data: read_file(&path, MAX_BUNDLE)? });
}
}
Ok(Bundle { manifest: text, wasm, assets })
}
#[cfg(target_arch = "wasm32")]
fn bundle_from_disk(cmd: &str, _path: &str) -> Result<Bundle> {
Err(bad(cmd, "there are no files in the browser: publish an installed plugin with `plugin`"))
}
/// Publish a plugin folder (or an installed plugin) as a new version (needs the server's key).
fn publish(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plugin.directory.publish";
let server = string(p, "server").ok_or_else(|| bad(cmd, "`server` required: ws://host:port/?key=… (`figcraft-server key` prints it)"))?;
let bundle = match (string(p, "path"), string(p, "plugin")) {
(Some(path), _) => bundle_from_disk(cmd, &path)?,
(None, Some(id)) => {
let ip = installed(s, cmd, &id)?.clone();
match &ip.path {
Some(path) => bundle_from_disk(cmd, path)?,
None => {
let wasm = crate::plugins::module_of(s, &ip)?;
let manifest = serde_json::to_string(&ip.manifest).map_err(|e| EngineError::Other(e.to_string()))?;
Bundle { manifest, wasm: wasm.to_vec(), assets: Vec::new() }
}
}
}
(None, None) => return Err(bad(cmd, "pass `path` (a plugin folder or its figcraft-plugin.json) or `plugin` (an installed plugin's id)")),
};
// Checked here too, for a clear message before anything is sent.
bundle.check().map_err(|e| bad(cmd, e))?;
let bytes = bundle.encode();
if bytes.len() > MAX_BUNDLE {
return Err(bad(cmd, format!("the plugin is larger than {} MiB", MAX_BUNDLE >> 20)));
}
let PluginReply::Published(entry) = request(s, &server, PluginReq::Publish { bundle: bytes })? else { return Err(unexpected(cmd)) };
Ok(json!({"published": entry, "server": address(&server)}))
}
fn unpublish(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plugin.directory.unpublish";
let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required"))?;
let server = string(p, "server").ok_or_else(|| bad(cmd, "`server` required: ws://host:port/?key=…"))?;
let PluginReply::Removed(versions) = request(s, &server, PluginReq::Unpublish { id: id.clone(), version: string(p, "version") })? else {
return Err(unexpected(cmd));
};
Ok(json!({"id": id, "removed": versions}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"plugin.directory.list",
"Browse plugin directory",
"{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}",
false,
list
),
spec!(
"plugin.directory.install",
"Install plugin from directory",
"{id, version? (default: the latest), confirm: true (required: installing runs the plugin's code), server?, sha256? (the one shown)} downloads, checks the SHA-256 and installs or updates it → {installed, name, version, permissions, sha256, path, source, replaced}",
false,
install
),
spec!(
"plugin.directory.publish",
"Publish plugin to directory",
"{path (a plugin folder or its figcraft-plugin.json) | plugin (an installed plugin's id), server (ws://host:port/?key=…)} → {published: entry, server}",
false,
publish
),
spec!(
"plugin.directory.unpublish",
"Unpublish plugin from directory",
"{id, version? (default: every version), server (ws://host:port/?key=…)} → {id, removed: [versions]}",
false,
unpublish
),
]
}