mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(®istry::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()))) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user