Merge pull request #2695 from storytold/fix/2541-plugins-persist

Fix #2541: installed plug-ins stay installed after a restart
This commit is contained in:
BFlat
2026-10-17 18:04:04 +07:00
committed by GitHub
10 changed files with 117 additions and 19 deletions
+3 -2
View File
@@ -159,11 +159,12 @@ fn main() -> ExitCode {
}
}
/// A headless session that remembers what the user keeps between runs: the PDF presets saved and
/// the ICC profiles loaded (#2168, #2174), in the folders next to the preferences that the desktop
/// A headless session that remembers what the user keeps between runs: the PDF presets saved, the
/// ICC profiles loaded and the plug-ins installed (#2168, #2174, #2541), in the folders next to the preferences that the desktop
/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there.
fn with_stores(mut h: Headless) -> Headless {
prefs_dir::attach_profile_store(&mut h.session);
prefs_dir::attach_plugin_store(&mut h.session);
prefs_dir::attach_pdf_preset_store(&mut h.session);
h
}
+1
View File
@@ -858,6 +858,7 @@ fn main() -> std::process::ExitCode {
let mut session = Session::new();
// The profiles loaded before come back before the preferences name any of them.
prefs_dir::attach_profile_store(&mut session);
prefs_dir::attach_plugin_store(&mut session);
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
load_prefs(&mut app, saved);
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
+8
View File
@@ -41,6 +41,14 @@ pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) {
}
}
/// Install the plug-ins Install Plug-in kept in the `Plug-ins` folder (#2541), and keep the ones
/// installed from now on there.
pub fn attach_plugin_store(session: &mut vectorcraft_engine::Session) {
if let Some(dir) = store_dir("Plug-ins") {
session.attach_plugin_store(dir);
}
}
/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from
/// now on there. After the preferences were restored: the folder is the one place the presets live.
pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) {
+18 -10
View File
@@ -1,7 +1,8 @@
//! `plugin.*` commands: sandboxed WebAssembly plug-ins (`vectorcraft-plugins`).
//!
//! Plug-ins are installed per process (`plugin.install`, or every `*.wasm` in the Additional
//! Plug-ins Folder preference) and listed with `plugin.list` / `plugin.info`. Object filters run
//! Plug-ins are installed with `plugin.install` (kept across restarts in the apps' plug-in folder,
//! [`Session::attach_plugin_store`]) or from every `*.wasm` in the Additional Plug-ins Folder
//! preference, and listed with `plugin.list` / `plugin.info`. Object filters run
//! with `plugin.run` on the selected paths and compound paths (one undo step, transactional: a
//! plug-in that fails leaves the document untouched); live-effect plug-ins are applied like any
//! effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`.
@@ -85,19 +86,20 @@ const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
/// The `.wasm` extension plug-in files have (File › Open installs them).
pub const EXTS: &[&str] = &["wasm"];
fn install(_: &mut Session, p: &Value) -> Result<Value> {
fn install(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.install";
let plugin = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
let (plugin, bytes) = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
(_, Some(data)) => {
if data.len() > MAX_DATA_CHARS {
return Err(bad(C, "`dataBase64` is larger than the module limit"));
}
let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(C, "`dataBase64` is not valid base64"))?;
let plugin = Plugin::load(&bytes, vectorcraft_plugins::Limits::default()).map_err(err)?;
match str_param(p, "name") {
let plugin = match str_param(p, "name") {
Some(name) => plugin.with_source(name),
None => plugin,
}
};
(plugin, bytes)
}
(Some(path), None) => load_path(path)?,
(None, None) => return Err(bad(C, "give `path` (a .wasm file) or `dataBase64` (the module)")),
@@ -105,17 +107,22 @@ fn install(_: &mut Session, p: &Value) -> Result<Value> {
if !p.get("replace").and_then(Value::as_bool).unwrap_or(true) && registry::get(plugin.id()).is_some() {
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
}
// Installed plug-ins stay installed after a restart (#2541).
s.store_plugin(plugin.id(), &bytes);
Ok(describe(&registry::install(plugin)))
}
/// The plug-in in the `.wasm` file `path`, and its module.
#[cfg(not(target_arch = "wasm32"))]
fn load_path(path: &str) -> Result<Plugin> {
fn load_path(path: &str) -> Result<(Plugin, Vec<u8>)> {
crate::file_access::check_read(path).map_err(EngineError::Other)?;
registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err)
let limits = vectorcraft_plugins::Limits::default();
let bytes = crate::store::read_capped(std::path::Path::new(path), limits.max_module_bytes as u64).map_err(EngineError::Other)?;
Ok((Plugin::load(&bytes, limits).map_err(err)?.with_source(path), bytes))
}
#[cfg(target_arch = "wasm32")]
fn load_path(_: &str) -> Result<Plugin> {
fn load_path(_: &str) -> Result<(Plugin, Vec<u8>)> {
Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into()))
}
@@ -186,8 +193,9 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> {
Ok(v)
}
fn remove(_: &mut Session, p: &Value) -> Result<Value> {
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
s.unstore_plugin(id);
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
}
+4
View File
@@ -1252,6 +1252,9 @@ pub struct Session {
/// Where the ICC profiles `color.loadProfile` loads are kept; `None`: only for the process
/// ([`Session::attach_profile_store`]).
profile_dir: Option<std::path::PathBuf>,
/// Where the plug-ins `plugin.install` installs are kept; `None`: only for the process
/// ([`Session::attach_plugin_store`]).
plugin_dir: Option<std::path::PathBuf>,
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
pub recent_urls: Vec<String>,
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
@@ -1346,6 +1349,7 @@ impl Session {
libraries: Default::default(),
pdf_preset_dir: None,
profile_dir: None,
plugin_dir: None,
recent_urls: vec![],
ui_language: None,
journal_note: Default::default(),
+50 -3
View File
@@ -1,6 +1,7 @@
//! The user's own PDF presets and ICC profiles, kept in folders next to the preferences so that
//! they outlive the process (#2168, #2174). The apps decide where ([`Session::attach_pdf_preset_store`],
//! [`Session::attach_profile_store`]); a session nobody attached a folder to keeps nothing, which
//! The user's own PDF presets, ICC profiles and plug-ins, kept in folders next to the preferences
//! so that they outlive the process (#2168, #2174, #2541). The apps decide where
//! ([`Session::attach_pdf_preset_store`], [`Session::attach_profile_store`],
//! [`Session::attach_plugin_store`]); a session nobody attached a folder to keeps nothing, which
//! is how the engine's own tests and embedders run.
//!
//! * **PDF presets**: one `.vcpdfpresets` file per preset (the format `pdf.preset.export` writes,
@@ -10,6 +11,10 @@
//! * **ICC profiles**: the bytes of each profile `color.loadProfile` registered, as `.icc` files
//! named after the profile, never the path they came from (a profile in a system folder may
//! move or go away). Attaching the folder registers them again.
//! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named
//! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it.
//! Attaching the folder installs them again. The Additional Plug-ins Folder preference is not
//! copied: it is loaded from where it is.
//!
//! The files are untrusted input like any other: sizes and counts are capped, a file that does not
//! parse is skipped with a log record, and every write is atomic.
@@ -32,6 +37,10 @@ pub const MAX_PROFILE_FILE: u64 = 32 << 20;
pub const MAX_PROFILE_STORE: u64 = 256 << 20;
/// The extension of a stored ICC profile.
const PROFILE_EXT: &str = "icc";
/// The most stored plug-in bytes read at startup, all files together (the rest are skipped).
pub const MAX_PLUGIN_STORE: u64 = 512 << 20;
/// The extension of a stored plug-in.
const PLUGIN_EXT: &str = "wasm";
/// The file name (without extension) a stored item called `name` gets: its name made safe for any
/// file system, then a hash of the name, so that names that differ only in characters a file name
@@ -181,6 +190,44 @@ impl Session {
take(dir, &format!("{}.{PROFILE_EXT}", file_stem(name)));
}
}
/// Keep the plug-ins this session installs in `dir`, and install the ones already there (a
/// module that doesn't load is skipped with a log record).
pub fn attach_plugin_store(&mut self, dir: PathBuf) {
let limits = vectorcraft_plugins::Limits::default();
let mut left = MAX_PLUGIN_STORE;
for path in list_files(&dir, PLUGIN_EXT) {
if left == 0 {
log::warn!("skipping the other stored plug-ins: they hold more than {MAX_PLUGIN_STORE} bytes together");
break;
}
let loaded = read_capped(&path, (limits.max_module_bytes as u64).min(left)).and_then(|bytes| {
left -= bytes.len() as u64;
vectorcraft_plugins::Plugin::load(&bytes, limits.clone()).map_err(|e| e.to_string())
});
match loaded {
Ok(plugin) => {
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
}
Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()),
}
}
self.plugin_dir = Some(dir);
}
/// Write the module `bytes` of the plug-in `id` to the store.
pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) {
if let Some(dir) = &self.plugin_dir {
put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes);
}
}
/// Remove the plug-in `id` from the store.
pub(crate) fn unstore_plugin(&self, id: &str) {
if let Some(dir) = &self.plugin_dir {
take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)));
}
}
}
/// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a
+26
View File
@@ -212,3 +212,29 @@ fn plugin_management_commands() {
assert_eq!(run(&mut s, "plugin.list", json!({}))["folder"]["loaded"], json!(["test.engine.folder"]));
std::fs::remove_dir_all(&dir).ok();
}
/// #2541: a plug-in installed in a session with a plug-in store is installed again in the next
/// session that attaches the store, and Remove Plug-in takes it out of the store.
#[test]
fn installed_plugins_are_kept_in_the_store() {
let folder = crate::tests_links::Folder::new("plugin-store");
let id = "test.engine.stored";
let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#);
let mut s = session();
s.attach_plugin_store(folder.0.clone());
install_wat(&mut s, &src);
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "one file in the store");
vectorcraft_plugins::registry::remove(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store");
run(&mut next, "plugin.remove", json!({"id": id}));
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 0, "removed from the store");
// A file that isn't a plug-in is skipped; the store still attaches.
std::fs::write(folder.0.join("broken.wasm"), b"\0asm?").unwrap();
let mut third = session();
third.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_none());
}
+3 -1
View File
@@ -3611,7 +3611,9 @@ to `object.setBounds`, which scales the other dimension by the same factor; agen
## Plug-ins
WebAssembly plug-ins ([plugins.md](plugins.md)) are installed per process and reached through commands:
WebAssembly plug-ins ([plugins.md](plugins.md)) are reached through commands; the desktop app and an unconfined
`vectorcraft-cli` keep the ones `plugin.install` installs in a `Plug-ins` folder next to the preferences and install
them again at startup (`plugin.remove` deletes them there):
`plugin.install {path | dataBase64}` installs one, `plugin.list` / `plugin.info {id}` describe them (parameters,
schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one
undo step; it may change, delete or add objects, and the result is selected). A live-effect plug-in is an effect with
+2 -2
View File
@@ -23,8 +23,8 @@ This page is the contract for plug-in authors and the reference for the host (`c
| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, size, source}], "folder": report}` |
| `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). |
| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected. |
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true` | Validates and installs a module; returns its `plugin.list` entry. |
| `plugin.remove` | `{"id"}` | Uninstalls. |
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). |
| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. |
| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped. |
Live effects are applied like any effect: `effect.apply {"effect": "plugin.<id>", "params": {…}}`, edited with
File diff suppressed because one or more lines are too long