diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 0d92da601..2ff5a37c3 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -24,7 +24,10 @@ //! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a //! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its //! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log -//! under its id. +//! under its id. One that lists `settings` or `cache` may keep settings and a cache of its own +//! (`vc_settings_*`, `vc_cache_*`), in the plug-in store under its id ([`crate::store`]): +//! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size +//! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them. use std::sync::{Arc, Mutex}; @@ -340,13 +343,27 @@ fn grant_param(c: &str, p: &Value) -> Result>> { Ok(Some(out)) } +/// Would installing `plugin` take another module's place: one installed with its id and other +/// bytes, or, with none installed, the settings and cache the plug-in store keeps for its id? +fn takes_over(s: &Session, plugin: &Plugin) -> bool { + match registry::get(plugin.id()) { + Some(installed) => installed.hash() != plugin.hash(), + None => s.has_plugin_storage(plugin.id()), + } +} + /// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless -/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id -/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant -/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. +/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted its id +/// unless `grant` lists them all, one that would take another module's place ([`takes_over`]) +/// unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records the grant with +/// the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; - let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?); + let replace = match p.get("replace") { + None | Some(Value::Null) => None, + Some(_) => Some(switch_or(p, "replace", false, C)?), + }; + let allow_heavy = switch_or(p, "allowHeavy", false, C)?; let grant = grant_param(C, p)?; let (plugin, bytes) = load_given(C, p)?; // `hash` pins the module the user was shown: one with other bytes is refused. @@ -355,9 +372,21 @@ fn install(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed")); } - if !replace && registry::get(plugin.id()).is_some() { + if replace == Some(false) && registry::get(plugin.id()).is_some() { return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); } + // Another module under the id gets the settings and cache kept for it: whoever installs it asks + // the user first. + if replace != Some(true) && takes_over(s, &plugin) { + let name = permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME); + return Err(bad( + C, + format!( + "“{name}” is another module than the plug-in with id {:?} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees", + plugin.id() + ), + )); + } // A heavy plug-in is installed once the user agrees: whoever installs it asks them first. if plugin.manifest().budget == Budget::Heavy && !allow_heavy { let (minutes, times) = vectorcraft_plugins::heavy_terms(); @@ -370,7 +399,8 @@ fn install(s: &mut Session, p: &Value) -> Result { )); } // A plug-in that lists permissions installs once the user grants them: whoever installs it - // asks them first, unless they already granted its id all of them. + // asks them first, unless they already granted all of them (with a plug-in store, to this + // module). let m = plugin.manifest(); let wanted = m.permissions(); if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { @@ -383,8 +413,9 @@ fn install(s: &mut Session, p: &Value) -> Result { registry::grant(g.clone()); s.store_grant(&m.id, Some(g)); } - // Installed plug-ins stay installed after a restart (#2541). + // Installed plug-ins stay installed after a restart (#2541), with their settings and cache. let stored = s.store_plugin(plugin.id(), &bytes); + s.attach_plugin_storage(plugin.id()); // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, // also when it is disabled by its stored file alone (it stopped a launch while loading). When // the new version could not replace the stored file, the old one stays disabled. @@ -399,15 +430,17 @@ fn install(s: &mut Session, p: &Value) -> Result { if changed { s.store_disabled_plugins(); } - Ok(describe(®istry::install(plugin))) + Ok(describe_installed(®istry::install(plugin))) } -/// `plugin.inspect`: what installing the module would install, without installing it, and whether -/// that needs the user to grant its permissions first (`needsApproval`). +/// `plugin.inspect`: what installing the module would install, without installing it, whether that +/// needs the user to grant its permissions first (`needsApproval`) and whether it would take +/// another module's place (`needsReplace`). fn inspect(s: &mut Session, p: &Value) -> Result { load_given("plugin.inspect", p).map(|(plugin, _)| { let mut v = describe(&plugin); v["needsApproval"] = json!(!s.plugin_approved(&plugin)); + v["needsReplace"] = json!(takes_over(s, &plugin)); v }) } @@ -478,6 +511,9 @@ fn load_folder(s: &Session, folder: &str) -> Value { }; match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { Ok(r) => { + for id in &r.loaded { + s.attach_plugin_storage(id); + } if r.disabled_changed { s.store_disabled_plugins(); } @@ -509,11 +545,18 @@ fn reload(s: &mut Session, p: &Value) -> Result { Ok(report) } +/// [`describe`] for an installed plug-in, with the bytes its cache holds (`cacheBytes`). +fn describe_installed(p: &Plugin) -> Value { + let mut v = describe(p); + v["cacheBytes"] = json!(registry::storage(p.id()).map_or(0, |st| st.cache_bytes())); + v +} + fn list(_: &mut Session, _: &Value) -> Result { let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); let disabled: Vec = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); Ok( - json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), + json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), ) } @@ -568,7 +611,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { fn info(_: &mut Session, p: &Value) -> Result { let plugin = plugin_by_id("plugin.info", p)?; - let mut v = describe(&plugin); + let mut v = describe_installed(&plugin); v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null); v["defaults"] = Value::Object(plugin.manifest().defaults()); v["lastError"] = json!(effect::last_error(plugin.id())); @@ -577,15 +620,48 @@ fn info(_: &mut Session, p: &Value) -> Result { fn remove(s: &mut Session, p: &Value) -> Result { let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?; + let installed = registry::get(id).is_some(); + // Settings and cache the store keeps for an id that isn't installed are deleted too. + let stored = !installed && vectorcraft_plugins::manifest::valid_id(id) && s.has_plugin_storage(id); + // Its settings and cache go first: when they can't be moved aside, the plug-in stays installed, + // as it was, and a second `plugin.remove` tries again. + if (installed || stored) + && let Err(e) = s.remove_plugin_storage(id) + { + if installed { + s.attach_plugin_storage(id); + } + return Err(EngineError::Other(e.to_string())); + } // Installed again, it asks again. registry::revoke(id); s.store_grant(id, None); s.unstore_plugin(id); - if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) } + if !stored && (!installed || !registry::remove(id)) { + return Err(err(vectorcraft_plugins::Error::NotFound(id.into()))); + } + Ok(json!({"removed": id})) +} + +/// `plugin.clearCache`: empties the cache of plug-in `id`, else of every installed plug-in → +/// `{cleared: [id], bytesFreed}`. Settings stay. +fn clear_cache(_: &mut Session, p: &Value) -> Result { + let plugins = match p.get("id").filter(|v| !v.is_null()) { + Some(_) => vec![plugin_by_id("plugin.clearCache", p)?], + None => registry::list(), + }; + let (mut cleared, mut freed) = (Vec::new(), 0_u64); + for plugin in plugins { + if let Some(st) = registry::storage(plugin.id()) { + freed += st.clear_cache().map_err(|e| EngineError::Other(e.to_string()))?; + } + cleared.push(plugin.id().to_string()); + } + Ok(json!({"cleared": cleared, "bytesFreed": freed})) } /// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove`, -/// `.reload`, `.safeMode` and `.setEnabled`. +/// `.reload`, `.clearCache`, `.safeMode` and `.setEnabled`. pub fn specs() -> Vec { vec![ cmd!( @@ -593,7 +669,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -620,7 +696,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count, each for the module with its hash alone), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), @@ -629,11 +705,28 @@ pub fn specs() -> Vec { "Inspect Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)", + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)", always, inspect ), - cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove), + cmd!( + query "plugin.remove", + "Remove Plug-in", + [], + None, + "{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}", + always, + remove + ), + cmd!( + query "plugin.clearCache", + "Clear Plug-in Cache", + [], + None, + "{id?} delete every entry in the cache of plug-in id (default: of every installed plug-in); its settings stay → {cleared: [id], bytesFreed}", + always, + clear_cache + ), cmd!( query "plugin.reload", "Reload Plug-ins", diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index c748240d1..27b9ad8ac 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -19,6 +19,11 @@ //! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins //! stored there that list permissions (id, module hash, permissions); a stored plug-in whose //! permissions it doesn't grant is skipped with a log record, and stays in the folder. +//! * **Plug-in settings and caches**: `storage/id-/` in the plug-in store, the id written as +//! [`case_safe`] writes it, holds what a plug-in +//! keeps with the `settings` and `cache` permissions ([`PluginStorage`]): `settings.json` and a +//! `cache` folder with one file per entry. The host names every file; a plug-in only names keys. +//! Replacing a plug-in keeps them, and `plugin.remove` deletes them. //! //! 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. @@ -26,9 +31,12 @@ use std::collections::BTreeMap; use std::io::Read; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use vectorcraft_pdf::{PdfPreset, builtin_preset}; use vectorcraft_plugins::safe_mode; +use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError}; use crate::Session; use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile}; @@ -55,6 +63,25 @@ pub const DISABLED_LOCK: &str = "disabled.lock"; const GRANTS_FILE: &str = "grants.json"; /// The largest grants file read. const MAX_GRANTS_FILE: u64 = 1 << 20; +/// The folder in the plug-in store that holds each plug-in's settings and cache. +const PLUGIN_DATA_DIR: &str = "storage"; +/// A plug-in's settings file, in its folder. +const SETTINGS_FILE: &str = "settings.json"; +/// Where a settings file that doesn't read is moved, replacing the one moved there before. +const BAD_SETTINGS_FILE: &str = "settings.unreadable.json"; +/// The folder of a plug-in's cache entries, in its folder. +const CACHE_DIR: &str = "cache"; +/// The extension of a cache entry's file. +const CACHE_EXT: &str = "bin"; +/// Items in a cache folder a write looks at, at most: the entries, and temporary files a write left. +/// A write to a folder that holds more is refused as full. +pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2; +/// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose +/// each other's settings. +static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); +/// The closed flag of each plug-in folder's storage, shared by every storage made for the folder +/// until the plug-in is removed, which sets it and drops it. +static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); /// 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 @@ -262,7 +289,8 @@ impl Session { /// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user /// disabled (matched by file name, which is made from the id). The launch marker names each /// file while it loads. A module that doesn't load, and one that lists permissions the grants - /// there don't grant it, is skipped with a log record. + /// there don't grant it, is skipped with a log record. A grant covers only the module it was + /// given to ([`grant_covers`]). pub fn load_stored_plugins(&mut self) { let Some(dir) = self.plugin_dir.clone() else { return }; if safe_mode::active() { @@ -294,15 +322,19 @@ impl Session { // module stored here load. Ok(plugin) if plugin.manifest().asks_permissions() => { let m = plugin.manifest(); - match grants.get(&m.id).filter(|g| g.covers(&m.permissions())) { + match grants.get(&m.id).filter(|g| grant_covers(g, &plugin)) { Some(g) => { vectorcraft_plugins::registry::grant(g.clone()); + vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id)); vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } - None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()), + None => { + log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display()) + } } } Ok(plugin) => { + vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id())); vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()), @@ -345,8 +377,9 @@ impl Session { } /// Whether `plugin` installs without asking the user: it lists no permissions, or its id was - /// granted every one it lists, in the grants of the attached plug-in store when there is one, - /// else in this process's ([`vectorcraft_plugins::registry::approved`]). + /// granted every one it lists, in the grants of the attached plug-in store when there is one + /// (for this module alone, [`grant_covers`]), else in this process's + /// ([`vectorcraft_plugins::registry::approved`]). pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool { self.plugin_approver()(plugin) } @@ -358,7 +391,7 @@ impl Session { move |plugin| { let m = plugin.manifest(); match &grants { - Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.permissions())), + Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| grant_covers(g, plugin)), _ => vectorcraft_plugins::registry::approved(plugin), } } @@ -422,6 +455,379 @@ impl Session { take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id))); } } + + /// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without + /// one, nowhere (its storage calls then fail as unavailable). + pub(crate) fn attach_plugin_storage(&self, id: &str) { + vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id)); + } + + /// Does the attached plug-in store have a folder of settings and cache for plug-in `id`? A + /// folder behind a `storage` folder that is a link is not counted: the plug-in's storage calls + /// can't reach it and removing the plug-in leaves it. + pub(crate) fn has_plugin_storage(&self, id: &str) -> bool { + self.plugin_dir + .as_deref() + .is_some_and(|dir| !is_link(&dir.join(PLUGIN_DATA_DIR)) && std::fs::symlink_metadata(plugin_data_dir(dir, id)).is_ok()) + } + + /// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it. + /// The folder is first renamed out of the way (`.removed`), and that fails, leaving it + /// as it was, when the rename does; what the deletion then leaves stays under that name, with a + /// log record, and is deleted at the plug-in's next removal. + pub(crate) fn remove_plugin_storage(&self, id: &str) -> Result<(), StorageError> { + vectorcraft_plugins::registry::set_storage(id, None); + let Some(dir) = &self.plugin_dir else { return Ok(()) }; + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let path = plugin_data_dir(dir, id); + // Every storage handed out for the folder, including those runs still hold, is closed once + // the folder is out of the way; until then they keep working. + let close = || { + if let Some(closed) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) { + closed.store(true, Ordering::Release); + } + }; + // A `storage` folder that is a link leads out of the store: nothing there is deleted. A link + // in place of the plug-in's own folder is removed itself (`remove_dir_all` doesn't follow it). + let data = dir.join(PLUGIN_DATA_DIR); + if is_link(&data) { + log::warn!("{} is a link: the plug-in settings and cache it leads to are left", data.display()); + close(); + return Ok(()); + } + let mut aside = path.clone().into_os_string(); + aside.push(".removed"); + let aside = PathBuf::from(aside); + let _ = std::fs::remove_dir_all(&aside); + match std::fs::rename(&path, &aside) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))), + Ok(()) => {} + } + close(); + if std::fs::symlink_metadata(&aside).is_ok() + && let Err(e) = std::fs::remove_dir_all(&aside) + { + log::warn!("can't delete {}: {e}", aside.display()); + } + Ok(()) + } +} + +/// `name` (a plug-in id or a cache entry name, ASCII) written for a file name that no other name +/// gets on a file system that ignores case: `_` is written `__`, each capital letter `_` and the +/// letter in lower case, and `.` `_0`. +fn case_safe(name: &str) -> String { + let mut file = String::with_capacity(name.len() * 2); + for c in name.chars() { + match c { + '_' => file.push_str("__"), + '.' => file.push_str("_0"), + c if c.is_ascii_uppercase() => { + file.push('_'); + file.push(c.to_ascii_lowercase()); + } + c => file.push(c), + } + } + file +} + +/// The folder of plug-in `id`'s settings and cache in the plug-in store `dir`. The `id-` in front +/// keeps an id such as `con` from naming a device on Windows. +pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf { + dir.join(PLUGIN_DATA_DIR).join(format!("id-{}", case_safe(id))) +} + +/// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`. +fn plugin_storage(dir: Option<&Path>, id: &str) -> Option> { + let dir = dir?; + let data = plugin_data_dir(dir, id); + let closed = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone(); + Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, closed })) +} + +/// The settings and cache of one plug-in, in its folder of the plug-in store (see the module docs). +/// Keys and entry names are [`storage::valid_name`]s; an entry's file name is its name as +/// [`case_safe`] writes it, so that names differing only in case get different files. A link +/// anywhere in the folder's path below the store is refused, and so is a cache file that is not a +/// plain file. +pub(crate) struct PluginStorage { + /// The plug-in store, and the plug-in's folder in it. + root: PathBuf, + dir: PathBuf, + /// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed; shared by every storage of the + /// folder ([`PLUGIN_DATA_OPEN`]). + closed: Arc, +} + +/// The sizes of `files` added up, at most `u64::MAX`: the sizes come from the file system. +fn sum_sizes(files: &[(PathBuf, u64)]) -> u64 { + files.iter().fold(0, |sum, (_, n)| sum.saturating_add(*n)) +} + +/// Is `name` the file name of a cache entry? Entry files end in `.bin` and never start with a `.` +/// ([`case_safe`] writes no `.`), while the temporary files of `write_atomic` do. +fn is_entry_file(name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|n| !n.starts_with('.') && n.ends_with(&format!(".{CACHE_EXT}"))) +} + +/// What a storage call is told when the file system fails. +fn io(e: &std::io::Error) -> StorageError { + StorageError::Io(e.to_string()) +} + +/// Does grant `g` cover `plugin`: was it given to this module (the same SHA-256) for every +/// permission the module lists? A module with other bytes under the id needs the user's approval +/// again, and so doesn't get the settings and cache kept for the id without it, and so does every +/// module when the grant records no hash. +fn grant_covers(g: &vectorcraft_plugins::permissions::Grant, plugin: &vectorcraft_plugins::Plugin) -> bool { + !g.hash.is_empty() && g.hash == plugin.hash() && g.covers(&plugin.manifest().permissions()) +} + +/// Is `path` a link (a symbolic link or, on Windows, a junction)? +fn is_link(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink() || reparse_point(&m)) +} + +#[cfg(windows)] +fn reparse_point(m: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn reparse_point(_: &std::fs::Metadata) -> bool { + false +} + +impl PluginStorage { + fn settings_path(&self) -> PathBuf { + self.dir.join(SETTINGS_FILE) + } + + fn cache_dir(&self) -> PathBuf { + self.dir.join(CACHE_DIR) + } + + /// The file of the cache entry `name`. + fn entry_path(&self, name: &str) -> PathBuf { + self.cache_dir().join(format!("{}.{CACHE_EXT}", case_safe(name))) + } + + /// Checks that no folder between the store and `path`, nor `path` itself, is a link. + fn no_links(&self, path: &Path) -> Result<(), StorageError> { + let mut at = path; + while at != self.root { + if is_link(at) { + return Err(StorageError::Unavailable); + } + match at.parent() { + Some(up) => at = up, + None => break, + } + } + Ok(()) + } + + /// The settings, by key. A file that is too large or doesn't parse is moved aside + /// ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is charged the + /// file's size before it is read. + fn read_settings(&self, meter: &mut Meter) -> Result>, StorageError> { + let path = self.settings_path(); + self.no_links(&path)?; + let size = match std::fs::metadata(&path) { + Ok(m) => m.len(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(e) => return Err(io(&e)), + }; + let readable = size <= storage::MAX_SETTINGS_BYTES as u64; + if readable { + meter.take(size)?; + } + let parsed = readable + .then(|| read_capped(&path, storage::MAX_SETTINGS_BYTES as u64).ok()) + .flatten() + .and_then(|bytes| serde_json::from_slice::>(&bytes).ok()) + .and_then(|stored| { + let decode = + |(k, v): (String, String)| if storage::valid_name(&k) { vectorcraft_format::base64_decode(&v).map(|v| (k, v)) } else { None }; + let settings: Option>> = stored.into_iter().map(decode).collect(); + settings.filter(|s| s.len() <= storage::MAX_SETTINGS && s.values().all(|v| v.len() <= storage::MAX_SETTING_BYTES)) + }); + match parsed { + Some(settings) => Ok(settings), + None => { + let aside = self.dir.join(BAD_SETTINGS_FILE); + log::warn!("the plug-in settings {} don't read: moved to {} and started empty", path.display(), aside.display()); + std::fs::rename(&path, &aside).map_err(|e| io(&e))?; + Ok(BTreeMap::new()) + } + } + } + + /// Writes `settings` as the settings file, or removes it when there are none. `meter` is charged + /// the new file's size before it is written. + fn write_settings(&self, settings: &BTreeMap>, meter: &mut Meter) -> Result<(), StorageError> { + let path = self.settings_path(); + if settings.is_empty() { + return match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(io(&e)), + _ => Ok(()), + }; + } + let stored: BTreeMap<&str, String> = settings.iter().map(|(k, v)| (k.as_str(), vectorcraft_format::base64_encode(v))).collect(); + let bytes = serde_json::to_vec(&stored).map_err(|e| StorageError::Io(e.to_string()))?; + if bytes.len() > storage::MAX_SETTINGS_BYTES { + return Err(StorageError::Full); + } + meter.take(bytes.len() as u64)?; + std::fs::create_dir_all(&self.dir).map_err(|e| io(&e))?; + self.no_links(&path)?; + write_atomic(&path, &bytes).map_err(|e| io(&e)) + } + + /// Fails with [`StorageError::Unavailable`] once the storage is closed. + fn open(&self) -> Result<(), StorageError> { + if self.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) } + } + + /// The entries of the cache folder (plain files named as [`Self::entry_path`] names them, which + /// leaves out the temporary files of writes) and their sizes, and whether the folder holds more + /// than [`MAX_CACHE_SCAN`] items, in which case only the entries among the first that many are + /// listed. + fn cache_files(&self) -> Result<(Vec<(PathBuf, u64)>, bool), StorageError> { + let dir = self.cache_dir(); + self.no_links(&dir)?; + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], false)), + Err(e) => return Err(io(&e)), + }; + let mut files = Vec::new(); + for (seen, e) in entries.enumerate() { + if seen == MAX_CACHE_SCAN { + return Ok((files, true)); + } + let e = e.map_err(|e| io(&e))?; + if is_entry_file(&e.file_name()) + && let Ok(m) = std::fs::symlink_metadata(e.path()) + && m.is_file() + && !reparse_point(&m) + { + files.push((e.path(), m.len())); + } + } + Ok((files, false)) + } +} + +impl Storage for PluginStorage { + fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + Ok(self.read_settings(meter)?.remove(key)) + } + + fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let mut settings = self.read_settings(meter)?; + if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) { + return Err(StorageError::Full); + } + settings.insert(key.to_string(), value.to_vec()); + self.write_settings(&settings, meter) + } + + fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let mut settings = self.read_settings(meter)?; + if settings.remove(key).is_none() { + return Ok(false); + } + self.write_settings(&settings, meter).map(|()| true) + } + + fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + self.no_links(&path)?; + match std::fs::symlink_metadata(&path) { + Ok(m) if m.is_file() && m.len() <= storage::MAX_CACHE_ENTRY as u64 => meter.take(m.len())?, + Ok(_) => return Err(StorageError::Unavailable), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(io(&e)), + } + read_capped(&path, storage::MAX_CACHE_ENTRY as u64).map(Some).map_err(StorageError::Io) + } + + fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + let (files, overfull) = self.cache_files()?; + if overfull { + return Err(StorageError::Full); + } + let old = files.iter().find(|(p, _)| *p == path).map(|(_, n)| *n); + let total = sum_sizes(&files).saturating_sub(old.unwrap_or(0)).saturating_add(value.len() as u64); + let too_many = old.is_none() && files.len() >= storage::MAX_CACHE_ENTRIES; + if value.len() > storage::MAX_CACHE_ENTRY || total > storage::MAX_CACHE_BYTES || too_many { + return Err(StorageError::Full); + } + meter.take(value.len() as u64)?; + std::fs::create_dir_all(self.cache_dir()).map_err(|e| io(&e))?; + self.no_links(&path)?; + write_atomic(&path, value).map_err(|e| io(&e)) + } + + fn remove_cache_entry(&self, name: &str) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + self.no_links(&path)?; + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(io(&e)), + } + } + + fn clear_cache(&self) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let dir = self.cache_dir(); + self.no_links(&dir)?; + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(io(&e)), + }; + // Every plain file, one at a time as the folder is read, past the ones a write looks at too. + let mut freed = 0_u64; + for e in entries { + let path = e.map_err(|e| io(&e))?.path(); + if let Ok(m) = std::fs::symlink_metadata(&path) + && m.is_file() + && !reparse_point(&m) + { + std::fs::remove_file(&path).map_err(|e| io(&e))?; + freed = freed.saturating_add(m.len()); + } + } + Ok(freed) + } + + fn cache_bytes(&self) -> u64 { + if self.open().is_err() { + return 0; + } + self.cache_files().map(|(files, _)| sum_sizes(&files)).unwrap_or(0) + } } /// What the grants file of a plug-in store holds. diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 7738820dc..dad65195f 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -17,8 +17,9 @@ fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } +/// Installs `wasm`, in place of any module installed with its id (`replace: true`). fn install(s: &mut Session, wasm: &[u8]) -> Value { - run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})) + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "replace": true})) } fn install_wat(s: &mut Session, src: &str) -> Value { @@ -578,8 +579,8 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec { /// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`), /// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's -/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same -/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the +/// hash. The grant is recorded with that hash: replacing the plug-in (`replace: true`) with one +/// that lists the same permissions needs no new grant, and one that lists another needs one. Removing it forgets the /// grant. A plug-in without `permissions` installs as before, with the ones its kind implies. #[test] fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { @@ -620,15 +621,15 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { // grant now records the new module's hash. let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false); - run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)})); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "replace": true})); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other)); // One that asks for more is refused until it is granted all of them. let more = data(r#"["doc.read","doc.write"]"#); assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": more}))["needsApproval"], true); - let e = s.execute("plugin.install", &json!({"dataBase64": more})).unwrap_err().to_string(); + let e = s.execute("plugin.install", &json!({"dataBase64": more, "replace": true})).unwrap_err().to_string(); assert!(e.contains("doc.read, doc.write") && e.contains("`grant`"), "{e}"); assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().manifest().permissions().len(), 1, "the one installed stays"); - run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"]})); + run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"], "replace": true})); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().permissions.len(), 2, "only what it lists is recorded"); // Removed, it is asked again. run(&mut s, "plugin.remove", json!({"id": id})); @@ -649,7 +650,7 @@ fn a_filter_does_only_what_its_permissions_allow() { let id = rect(&mut s, 10.0, "#ff0000"); let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]})); let grant = |s: &mut Session, wasm: &[u8], perms: Value| { - run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms})); + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms, "replace": true})); }; // Denied `doc.write`: renaming the object is refused, and the document and history stay. let reader = "test.engine.permissions.reader"; @@ -793,6 +794,59 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { run(&mut granted, "plugin.remove", json!({"id": id})); } +/// A grant covers the module it was given to: another module under the id, listing the same +/// permissions, isn't installed from the store at the next start, a folder load reports it as +/// needing approval, and `plugin.install` asks for `grant` again, even with `replace: true`. It +/// doesn't get the settings kept for the id until the user grants it. A kept grant without a hash +/// covers no module. +#[test] +fn a_grant_covers_only_the_module_it_was_given_to() { + let folder = crate::tests_links::Folder::new("plugin-store-grant-hash"); + let id = "test.engine.granthash"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + // The stored module is replaced on disk by one that reads the settings. + let other = storing(id, READS); + let stored: Vec<_> = + std::fs::read_dir(&folder.0).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|x| x == "wasm")).collect(); + assert_eq!(stored.len(), 1, "{stored:?}"); + std::fs::write(&stored[0], &other).unwrap(); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant"); + let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash"); + std::fs::write(dir.0.join("other.wasm"), &other).unwrap(); + let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([])); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval"), "{why}"); + let data = vectorcraft_format::base64_encode(&other); + let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string(); + assert!(e.contains("`grant`"), "{e}"); + // Granted, it reads the settings the first module set. + run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + + // A kept grant that records no hash, or an empty one, covers no module: the stored module isn't + // installed at the next start and `plugin.inspect` asks for approval. + for entry in [json!({"id": id, "permissions": ["settings", "cache"]}), json!({"id": id, "hash": "", "permissions": ["settings", "cache"]})] { + std::fs::write(folder.0.join("grants.json"), serde_json::to_vec(&json!([entry])).unwrap()).unwrap(); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut again = session(); + again.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "a grant without a hash doesn't load the module: {entry}"); + assert_eq!(run(&mut again, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], json!(true), "{entry}"); + next = again; + } + run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); + run(&mut next, "plugin.remove", json!({"id": id})); +} + /// A grants file entry this version can't use (a permission it doesn't know, an invalid id) is /// skipped without dropping the other grants, and recording a grant writes it back as it was, /// except an entry for the plug-in being granted or removed. A grants file that doesn't parse as a @@ -934,7 +988,7 @@ fn a_filter_run_made_elsewhere_keeps_its_document_data() { let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#); let install = |s: &mut Session, src: String| { let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap()); - run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true})); + run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true, "replace": true})); }; // It first keeps the pens it found with the document. install(&mut s, tpl::returning(&manifest, &json!({"objects": [], "documentData": {"pens": 2}}).to_string())); @@ -1000,3 +1054,379 @@ fn a_cancelled_filter_run_changes_nothing() { assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); assert_eq!(s.doc().unwrap().doc, before); } + +/// A filter `id` of API level 2 that lists `settings` and `cache`, whose `vc_run` runs `first` +/// (instructions that trap unless each storage call returns what it should), with the keys "zoom" +/// at 0x2000 and "tile" at 0x2004 and the value "hello" at 0x2100. +fn storing(id: &str, first: &str) -> Vec { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["settings","cache"]}}"#); + let data = format!(r#"{} (data (i32.const 0x2000) "zoomtile") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS); + wat::parse_str(tpl::with_imports(&manifest, &data, first, r#"{"objects":[]}"#)).unwrap() +} + +/// Sets "zoom" to "hello" and writes "hel" to the cache entry "tile". +const WRITES: &str = "(if (call $vc_settings_set (i32.const 0x2000) (i32.const 4) (i32.const 0x2100) (i32.const 5)) (then unreachable)) + (if (call $vc_cache_write (i32.const 0x2004) (i32.const 4) (i32.const 0x2100) (i32.const 3)) (then unreachable))"; + +/// Traps unless "zoom" is "hello". +const READS: &str = + "(if (i32.ne (call $vc_settings_get (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 16)) (i32.const 5)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 0x3000)) (i32.load (i32.const 0x2100))) (then unreachable))"; + +fn install_storing(s: &mut Session, id: &str, first: &str) -> Value { + let data = vectorcraft_format::base64_encode(&storing(id, first)); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"]})) +} + +/// [`install_storing`] in place of another module with the same id (`replace: true`). +fn replace_storing(s: &mut Session, id: &str, first: &str) -> Value { + let data = vectorcraft_format::base64_encode(&storing(id, first)); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})) +} + +/// A plug-in's settings and cache are kept in the plug-in store under its id: the next session +/// has them, replacing the plug-in keeps them, another plug-in can't read them, `plugin.clearCache` +/// empties the cache alone and `plugin.remove` deletes both. The caps and the bytes each call is +/// charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`], +/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test, since +/// clearing every plug-in's cache would empty theirs. +#[test] +fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() { + let folder = crate::tests_links::Folder::new("plugin-storage"); + let (id, other) = ("test.engine.storing", "test.engine.storing.other"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + let dir = crate::store::plugin_data_dir(&folder.0, id); + assert!(dir.join("settings.json").is_file()); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3)); + + // A restart: installed again from the store, and replaced by a module that reads. + vectorcraft_plugins::registry::remove(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + let listed = run(&mut next, "plugin.list", json!({})); + let entry = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap(); + assert_eq!(entry["cacheBytes"], json!(3)); + assert_eq!(replace_storing(&mut next, id, READS)["cacheBytes"], json!(3)); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + + // Another plug-in has settings of its own: "zoom" isn't set there. + install_storing(&mut next, other, READS); + assert!(next.execute("plugin.run", &json!({"id": other, "ids": []})).is_err()); + + // Clearing the cache leaves the settings. + assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3})); + assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0)); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + replace_storing(&mut next, other, WRITES); + run(&mut next, "plugin.run", json!({"id": other, "ids": []})); + let all = run(&mut next, "plugin.clearCache", json!({})); + let cleared = all["cleared"].as_array().unwrap(); + assert!(cleared.contains(&json!(id)) && cleared.contains(&json!(other)) && all["bytesFreed"] == json!(3), "{all}"); + assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(0)); + assert!(next.execute("plugin.clearCache", &json!({"id": "test.engine.storing.missing"})).is_err()); + + run(&mut next, "plugin.remove", json!({"id": id})); + assert!(!dir.exists(), "removed with the plug-in"); + run(&mut next, "plugin.remove", json!({"id": other})); + plugin_storage_is_capped_and_survives_a_damaged_settings_file(); + plugin_storage_calls_are_charged_the_files_they_read_and_write(); +} + +/// A `storage` folder in the plug-in store that is a link leads out of the store: the plug-in +/// installs without `replace` although a folder for its id is there, its storage calls fail as +/// unavailable, and removing the plug-in deletes nothing the link leads to. +#[test] +fn plugin_storage_behind_a_link_is_left_alone() { + let folder = crate::tests_links::Folder::new("plugin-storage-link"); + let outside = crate::tests_links::Folder::new("plugin-storage-outside"); + #[cfg(unix)] + let made = std::os::unix::fs::symlink(&outside.0, folder.0.join("storage")).is_ok(); + // Without Developer Mode or elevation Windows makes no symbolic link, but a junction. + #[cfg(windows)] + let made = std::os::windows::fs::symlink_dir(&outside.0, folder.0.join("storage")).is_ok() + || std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(folder.0.join("storage")) + .arg(&outside.0) + .output() + .is_ok_and(|o| o.status.success()); + #[cfg(not(any(unix, windows)))] + let made = false; + if !made { + eprintln!("skipped: symbolic links can't be made here"); + return; + } + let id = "test.engine.storagelink"; + let kept = crate::store::plugin_data_dir(&folder.0, id).join("settings.json"); + std::fs::create_dir_all(kept.parent().unwrap()).unwrap(); + std::fs::write(&kept, b"{}").unwrap(); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let m = &mut vectorcraft_plugins::storage::Meter::unlimited(); + assert_eq!(st.set_setting("zoom", b"hello", m), Err(vectorcraft_plugins::storage::StorageError::Unavailable)); + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(kept.is_file(), "left where the link leads"); +} + +/// `plugin.remove` with the id of a plug-in that isn't installed deletes the settings and cache the +/// plug-in store still keeps for it; with neither, it fails as before. +#[test] +fn removing_an_id_that_is_not_installed_deletes_its_storage() { + let folder = crate::tests_links::Folder::new("plugin-storage-orphan"); + let id = "test.engine.storageorphan"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + let dir = crate::store::plugin_data_dir(&folder.0, id); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + std::fs::write(dir.join("settings.json"), b"{}").unwrap(); + std::fs::write(dir.join("cache").join("tile.bin"), b"abc").unwrap(); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + assert_eq!(run(&mut s, "plugin.remove", json!({"id": id})), json!({"removed": id})); + assert!(!dir.exists(), "the storage of an id that isn't installed is deleted"); + assert!(s.execute("plugin.remove", &json!({"id": id})).is_err(), "nothing left to remove"); +} + +/// The settings file and the cache are checked: a file that doesn't read is moved aside and the +/// settings start empty; a write past a cap fails and changes nothing; cache entries whose names +/// differ only in case are two files; a removed plug-in's storage takes no more writes. +fn plugin_storage_is_capped_and_survives_a_damaged_settings_file() { + use vectorcraft_plugins::storage::{self, Meter, StorageError}; + let folder = crate::tests_links::Folder::new("plugin-storage-caps"); + let id = "test.engine.storagecaps"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let dir = crate::store::plugin_data_dir(&folder.0, id); + std::fs::create_dir_all(&dir).unwrap(); + let m = &mut Meter::unlimited(); + + // A truncated settings file. + std::fs::write(dir.join("settings.json"), br#"{"zoom": "aGVsbG"#).unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), None); + assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists()); + st.set_setting("zoom", b"hello", m).unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), Some(b"hello".to_vec())); + // Damaged again, it replaces the file moved aside before. + std::fs::write(dir.join("settings.json"), b"[").unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), None); + assert_eq!(std::fs::read(dir.join("settings.unreadable.json")).unwrap(), b"["); + st.set_setting("zoom", b"hello", m).unwrap(); + plugin_storage_writes_are_atomic(&*st, &dir, &folder.0); + + // At most 256 keys: a new one is refused, an existing one still changes. + let keys: std::collections::BTreeMap = (0..storage::MAX_SETTINGS).map(|i| (format!("k{i}"), "eA==".to_string())).collect(); + std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap(); + assert_eq!(st.set_setting("one-more", b"x", m), Err(StorageError::Full)); + st.set_setting("k0", b"y", m).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), Some(b"y".to_vec())); + plugin_storage_skips_oversize_files(&*st, &dir); + + // At most 1 MiB in all: eleven values of 64 KiB fit, a twelfth doesn't, and nothing changes. + let big = vec![7_u8; storage::MAX_SETTING_BYTES]; + let keys: std::collections::BTreeMap = (0..11).map(|i| (format!("k{i}"), vectorcraft_format::base64_encode(&big))).collect(); + std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap(); + assert_eq!(st.set_setting("k11", &big, m), Err(StorageError::Full)); + assert_eq!((st.setting("k11", m).unwrap(), st.setting("k0", m).unwrap()), (None, Some(big.clone()))); + + // The cache: entry size, then the total, here nearly reached by a file already there. + assert_eq!(st.set_cache_entry("big", &vec![0; storage::MAX_CACHE_ENTRY + 1], m), Err(StorageError::Full)); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + std::fs::File::create(dir.join("cache").join("filler.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES - 2).unwrap(); + // A temporary file a failed write left behind counts toward no cap, and clearing removes it. + let leftover = dir.join("cache").join(".tile.bin.1-0.tmp"); + std::fs::write(&leftover, b"tmp").unwrap(); + assert_eq!(st.set_cache_entry("tile", b"abc", m), Err(StorageError::Full)); + assert_eq!(st.cache_entry("tile", m).unwrap(), None); + st.set_cache_entry("tile", b"ab", m).unwrap(); + assert_eq!(st.cache_bytes(), storage::MAX_CACHE_BYTES); + assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3); + assert!(!leftover.exists()); + + // Names differing in case are two entries. + st.set_cache_entry("Tile", b"1", m).unwrap(); + st.set_cache_entry("tile", b"22", m).unwrap(); + assert_eq!((st.cache_entry("Tile", m).unwrap(), st.cache_entry("tile", m).unwrap()), (Some(b"1".to_vec()), Some(b"22".to_vec()))); + assert_eq!(st.cache_bytes(), 3); + + // More items in the cache folder than a write looks at: the write is refused whatever the + // items it looked at hold, and clearing still reaches every entry. + let cache = dir.join("cache"); + for i in 0..crate::store::MAX_CACHE_SCAN { + std::fs::create_dir(cache.join(format!("d{i:05}"))).unwrap(); + } + std::fs::File::create(cache.join("z.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES).unwrap(); + assert_eq!(st.set_cache_entry("more", b"1", m), Err(StorageError::Full)); + assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3); + assert!(!cache.join("z.bin").exists()); + + // An id that differs only in case has storage of its own, which removing this one leaves. + let upper_id = "test.engine.StorageCaps"; + s.attach_plugin_storage(upper_id); + let upper = vectorcraft_plugins::registry::storage(upper_id).unwrap(); + assert_eq!(upper.setting("k0", m).unwrap(), None); + upper.set_setting("k0", b"upper", m).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), Some(big)); + // So do ids whose `_` and `.` could be read as each other's escapes. + let folders: std::collections::BTreeSet<_> = + ["a.b", "a_0b", "a_b", "A.b", "a__b", "a._b", "a_.b"].iter().map(|id| crate::store::plugin_data_dir(&folder.0, id)).collect(); + assert_eq!(folders.len(), 7, "{folders:?}"); + + // Removing the plug-in closes the storage a run still holds, even one from before the plug-in + // was replaced, so nothing is written back. + s.attach_plugin_storage(id); + run(&mut s, "plugin.remove", json!({"id": id})); + assert_eq!(st.set_setting("zoom", b"again", m), Err(StorageError::Unavailable)); + assert_eq!(st.set_cache_entry("tile", b"again", m), Err(StorageError::Unavailable)); + assert!(!dir.exists()); + assert_eq!(upper.setting("k0", m).unwrap(), Some(b"upper".to_vec())); + s.remove_plugin_storage(upper_id).unwrap(); +} + +/// Files larger than the storage reads are not read: a settings file larger than 1 MiB, although +/// each of its values would fit, is moved aside and the settings start empty; a cache entry file +/// larger than an entry may be fails as unavailable. `st` is the storage in `dir`. +fn plugin_storage_skips_oversize_files(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path) { + use vectorcraft_plugins::storage::{self, Meter, StorageError}; + let m = &mut Meter::unlimited(); + let value = vectorcraft_format::base64_encode(&vec![7_u8; storage::MAX_SETTING_BYTES]); + let keys: std::collections::BTreeMap = (0..13).map(|i| (format!("k{i}"), value.clone())).collect(); + let oversize = serde_json::to_vec(&keys).unwrap(); + assert!(oversize.len() > storage::MAX_SETTINGS_BYTES); + std::fs::write(dir.join("settings.json"), &oversize).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), None); + assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists()); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + let huge = dir.join("cache").join("huge.bin"); + std::fs::File::create(&huge).unwrap().set_len(storage::MAX_CACHE_ENTRY as u64 + 1).unwrap(); + assert_eq!(st.cache_entry("huge", m), Err(StorageError::Unavailable)); + std::fs::remove_file(&huge).unwrap(); +} + +/// Writes are atomic: a new file takes the old one's name, so another name for the old file (a hard +/// link) keeps its content. `st` is the storage in `dir` and holds the setting "zoom"; `outside` is +/// a folder outside it. +fn plugin_storage_writes_are_atomic(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path, outside: &std::path::Path) { + let m = &mut vectorcraft_plugins::storage::Meter::unlimited(); + let settings_copy = outside.join("settings-copy.json"); + let before = std::fs::read(dir.join("settings.json")).unwrap(); + std::fs::hard_link(dir.join("settings.json"), &settings_copy).unwrap(); + st.set_setting("zoom", b"world", m).unwrap(); + assert_eq!(std::fs::read(&settings_copy).unwrap(), before); + assert_eq!(st.setting("zoom", m).unwrap(), Some(b"world".to_vec())); + st.set_cache_entry("tile", b"abc", m).unwrap(); + let entry_copy = outside.join("tile-copy.bin"); + let entry = std::fs::read_dir(dir.join("cache")).unwrap().map(|e| e.unwrap().path()).find(|p| p.is_file()).unwrap(); + std::fs::hard_link(&entry, &entry_copy).unwrap(); + st.set_cache_entry("tile", b"xyz", m).unwrap(); + assert_eq!(std::fs::read(&entry_copy).unwrap(), b"abc"); + assert_eq!(st.cache_entry("tile", m).unwrap(), Some(b"xyz".to_vec())); + assert!(st.remove_cache_entry("tile").unwrap()); + std::fs::remove_file(&settings_copy).unwrap(); + std::fs::remove_file(&entry_copy).unwrap(); +} + +/// Each storage call is charged the bytes it reads and writes, before it does: a settings call the +/// whole settings file it reads and the whole file it writes, a cache call the entry it reads or +/// writes. A call that would pass what its meter has left fails and changes nothing. +fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { + use vectorcraft_plugins::storage::{Meter, StorageError}; + let folder = crate::tests_links::Folder::new("plugin-storage-meter"); + let id = "test.engine.storagemeter"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let file = crate::store::plugin_data_dir(&folder.0, id).join("settings.json"); + let size = || std::fs::metadata(&file).unwrap().len(); + st.set_setting("big", &[7; 60_000], &mut Meter::unlimited()).unwrap(); + st.set_setting("small", b"x", &mut Meter::unlimited()).unwrap(); + + // Reading a one-byte setting reads the whole file. + let before = size(); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("small", &mut m).unwrap(), Some(b"x".to_vec())); + assert_eq!(m.used(), before); + // Setting it reads the file and writes it again, and so does deleting one. + let mut m = Meter::unlimited(); + st.set_setting("small", b"yz", &mut m).unwrap(); + assert_eq!(m.used(), before + size()); + let (before, mut m) = (size(), Meter::unlimited()); + assert!(st.delete_setting("small", &mut m).unwrap()); + assert_eq!(m.used(), before + size()); + st.set_setting("small", b"y", &mut Meter::unlimited()).unwrap(); + + // Less left than the file: nothing is read. Enough to read it but not to write it again: + // nothing changes. + let mut m = Meter::new(size() - 1); + assert_eq!(st.setting("small", &mut m), Err(StorageError::Budget)); + assert_eq!(m.used(), 0); + assert_eq!(st.set_setting("small", b"z", &mut Meter::new(size())), Err(StorageError::Budget)); + assert_eq!(st.setting("small", &mut Meter::unlimited()).unwrap(), Some(b"y".to_vec())); + + // A cache write is charged the entry it writes, a read the entry it reads. + let mut m = Meter::unlimited(); + st.set_cache_entry("tile", &[1; 1000], &mut m).unwrap(); + assert_eq!(m.used(), 1000); + let mut m = Meter::unlimited(); + assert_eq!(st.cache_entry("tile", &mut m).unwrap().map(|v| v.len()), Some(1000)); + assert_eq!(m.used(), 1000); + assert_eq!(st.cache_entry("tile", &mut Meter::new(999)), Err(StorageError::Budget)); + assert_eq!(st.set_cache_entry("tile", &[2; 10], &mut Meter::new(9)), Err(StorageError::Budget)); + assert_eq!(st.cache_entry("tile", &mut Meter::unlimited()).unwrap().map(|v| v.len()), Some(1000)); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A module with other bytes than the plug-in installed with its id, or, with none installed, one +/// whose id has settings and a cache in the plug-in store, installs only with `replace: true`, and +/// then keeps them; the same module installs again without it, and `replace` must be true or false. +#[test] +fn another_module_under_an_id_with_settings_needs_replace() { + let folder = crate::tests_links::Folder::new("plugin-storage-replace"); + let id = "test.engine.storingreplace"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + let hash = run(&mut s, "plugin.info", json!({"id": id}))["hash"].clone(); + // The same module again. + install_storing(&mut s, id, WRITES); + + let reads = vectorcraft_format::base64_encode(&storing(id, READS)); + let install = |s: &mut Session, extra: Value| { + let mut p = json!({"dataBase64": reads, "grant": ["settings", "cache"]}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("plugin.install", &p) + }; + let Err(e) = install(&mut s, json!({})) else { panic!("another module took the plug-in's place") }; + assert!(e.to_string().contains(&format!("“Test {id}” is another module than the plug-in with id \"{id}\"")), "{e}"); + assert!(e.to_string().contains("`replace: true`"), "{e}"); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(true)); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["hash"], hash); + for bad in [json!({"replace": "yes"}), json!({"replace": 1})] { + let Err(e) = install(&mut s, bad) else { panic!("a bad `replace` was taken") }; + assert!(e.to_string().contains("`replace` must be true or false"), "{e}"); + } + assert!(install(&mut s, json!({"replace": false})).is_err()); + install(&mut s, json!({"replace": true})).unwrap(); + // It reads what the one it replaced set. + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3)); + + // Not installed, but with settings in the store: another module needs `replace` too. + vectorcraft_plugins::registry::remove(id); + let writes = vectorcraft_format::base64_encode(&storing(id, WRITES)); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": writes}))["needsReplace"], json!(true)); + assert!(s.execute("plugin.install", &json!({"dataBase64": writes, "grant": ["settings", "cache"]})).is_err()); + run(&mut s, "plugin.install", json!({"dataBase64": writes, "grant": ["settings", "cache"], "replace": true})); + run(&mut s, "plugin.remove", json!({"id": id})); + // Once removed, with its settings and cache, it installs as a first install does. + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(false)); + install(&mut s, json!({})).unwrap(); + run(&mut s, "plugin.remove", json!({"id": id})); +} diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 514118cf3..8b1b53d6f 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -658,4 +658,66 @@ proptest! { prop_assert!(total == 0 || done <= total); } } + + /// A plug-in that calls the storage imports with hostile keys (`..`, separators, device names, + /// control characters, too long), values, lengths and pointers in and out of its memory runs to + /// an output, never a panic; every call returns a length or a storage code, and the settings and + /// cache of another plug-in in the same store stay as they were. + #[test] + fn hostile_storage_arguments_never_crash( + calls in prop::collection::vec( + ( + 0..6usize, + prop_oneof![any::(), 0x1ff00..0x20100i32, Just(0x2000)], + prop_oneof![any::(), 0..80i32], + prop_oneof![any::(), 0x1ff00..0x20100i32, Just(0x3000)], + prop_oneof![any::(), 0..5000i32], + ), + 0..16, + ), + key in prop::collection::vec(prop_oneof![Just(b'a'), Just(b'.'), Just(b'/'), Just(b'\\'), Just(b'_'), any::()], 0..80), + ) { + use vectorcraft_plugins::host::STORAGE_BUDGET; + use vectorcraft_plugins::storage::Meter; + const FUNCS: [&str; 6] = ["vc_settings_get", "vc_settings_set", "vc_cache_read", "vc_cache_write", "vc_settings_delete", "vc_cache_clear"]; + let folder = std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id())); + let mut s = Session::new(); + s.attach_plugin_store(folder.clone()); + let storing = |id: &str| format!(r#"{{"id":"{id}","name":"F","kind":"filter","api":2,"permissions":["settings","cache"]}}"#); + let grant = json!(["settings", "cache"]); + let other = wat::parse_str(tpl::returning(&storing("fuzz.storage.other"), r#"{"objects":[]}"#)).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": grant})).unwrap(); + let theirs = vectorcraft_plugins::registry::storage("fuzz.storage.other").unwrap(); + let m = &mut Meter::unlimited(); + theirs.set_setting("mine", b"kept", m).unwrap(); + theirs.set_cache_entry("mine", b"kept", m).unwrap(); + // Each call's result is checked: a length (0 or more) or one of the storage codes. + let check = |call: String| format!("(local.set $r {call}) (if (i32.lt_s (local.get $r) (i32.const {STORAGE_BUDGET})) (then unreachable))"); + let body: String = calls + .iter() + .map(|&(f, kp, kl, vp, vl)| match f { + 0..4 => check(format!("(call ${} (i32.const {kp}) (i32.const {kl}) (i32.const {vp}) (i32.const {vl}))", FUNCS[f])), + _ => check(format!("(call ${} (i32.const {kp}) (i32.const {kl}))", FUNCS[f])), + }) + .collect(); + let text: String = key.iter().map(|b| format!("\\{b:02x}")).collect(); + let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::STORAGE_IMPORTS); + let wasm = wat::parse_str(tpl::with_imports(&storing("fuzz.storage"), &imports, &format!("(local $r i32) {body}"), r#"{"objects":[]}"#)).unwrap(); + let r = catch_quiet(|| { + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant})).map_err(|e| e.to_string())?; + let p = vectorcraft_plugins::registry::get("fuzz.storage").ok_or("not installed")?; + p.run(b"{\"objects\":[]}", b"{}").map_err(|e| e.to_string()) + }); + let out = r.map_err(|msg| TestCaseError::fail(format!("panicked on {calls:?}: {msg}")))?; + prop_assert!(out.is_ok(), "{:?}", out); + prop_assert_eq!(theirs.setting("mine", m).unwrap(), Some(b"kept".to_vec())); + prop_assert_eq!(theirs.cache_entry("mine", m).unwrap(), Some(b"kept".to_vec())); + prop_assert_eq!(theirs.cache_bytes(), 4); + // The host names every file: the store holds the two modules, their grants and their folders. + let names = |dir: &std::path::Path| std::fs::read_dir(dir).unwrap().filter_map(Result::ok).map(|e| e.file_name()).collect::>(); + prop_assert!(names(&folder).len() <= 4, "{:?}", names(&folder)); + prop_assert!(names(&folder.join("storage")).len() <= 2, "{:?}", names(&folder.join("storage"))); + let _ = s.execute("plugin.remove", &json!({"id": "fuzz.storage"})); + let _ = s.execute("plugin.remove", &json!({"id": "fuzz.storage.other"})); + } } diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index f47d8e69b..017df5a80 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -1,20 +1,28 @@ //! What a plug-in may import from the host ([`IMPORTS`]), from plug-in API level 2 on: //! `vc_progress` reports how far a run has got, `vc_cancelled` tells it the user or a host limit -//! asked it to stop, and `vc_log` writes a log line. A module may import them only when its -//! manifest gives `api` 2 and lists the `progress` permission, and the host checks each import when -//! the module is loaded ([`check_imports`], [`check_granted`]): one it doesn't offer, one with -//! another signature and one the manifest doesn't allow are refused by name. +//! asked it to stop, and `vc_log` writes a log line (the `progress` permission); `vc_settings_*` +//! read and write its settings (`settings`) and `vc_cache_*` its cache (`cache`, [`crate::storage`]). +//! A module may import one only when its manifest gives `api` 2 and lists the import's permission, +//! and the host checks each import when the module is loaded ([`check_imports`], [`check_granted`]): +//! one it doesn't offer, one with another signature and one the manifest doesn't allow are refused +//! by name. //! //! Each call's arguments are checked: progress is clamped and passed on only when it changes (with //! a known total, to another tenth of a percent; past [`MAX_PROGRESS_REPORTS`], once per //! [`PROGRESS_STRIDE`] changes), log text must lie inside the module's memory and be UTF-8, and //! each line ([`MAX_LOG_LINE`]) and each run ([`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], -//! [`MAX_LOG_READ`]) are capped. Nothing a module passes can panic the host. +//! [`MAX_LOG_READ`]) are capped. Keys and values must lie inside the module's memory, keys must be +//! [`crate::storage::valid_name`]s, values are capped, and so are each run's storage calls +//! ([`MAX_STORAGE_CALLS`]) and the bytes they move ([`MAX_STORAGE_BYTES`]). Nothing a module passes +//! can panic the host. + +use std::sync::Arc; use wasmi::{Caller, Engine, Extern, ExternType, Linker, Module, StoreLimits, ValType}; use crate::manifest::Manifest; use crate::permissions::{self, PROGRESS_API, Permission}; +use crate::storage::{self, Meter, Storage, StorageError}; use crate::{Error, Result}; /// The module a plug-in imports host functions from (the name a Rust or C toolchain gives @@ -60,16 +68,51 @@ pub const LOG_NOT_UTF8: i32 = -2; /// What `vc_log` returns: the level is not 0 to 3. pub const LOG_BAD_LEVEL: i32 = -3; -/// One host import: its name and its type. +/// What a storage import that writes or removes returns when it did. +pub const STORAGE_DONE: i32 = 0; +/// What a storage import returns: the key or entry has no value. +pub const STORAGE_NOT_FOUND: i32 = -1; +/// What a storage import returns: a key, value or buffer doesn't lie inside the module's memory. +pub const STORAGE_OUTSIDE_MEMORY: i32 = -2; +/// What a storage import returns: the key is not a [`crate::storage::valid_name`]. +pub const STORAGE_BAD_NAME: i32 = -3; +/// What a storage import returns: the write would pass a cap ([`crate::storage`]); nothing changed. +pub const STORAGE_FULL: i32 = -4; +/// What a storage import returns: there is nowhere to keep anything (no plug-in store, as on the +/// web, or a module that isn't installed). +pub const STORAGE_UNAVAILABLE: i32 = -5; +/// What a storage import returns: reading or writing the file failed. +pub const STORAGE_FAILED: i32 = -6; +/// What a storage import returns: the run used up its storage calls or bytes +/// ([`MAX_STORAGE_CALLS`], [`MAX_STORAGE_BYTES`]). +pub const STORAGE_BUDGET: i32 = -7; + +/// Most storage calls one run makes; later ones return [`STORAGE_BUDGET`]. +pub const MAX_STORAGE_CALLS: usize = 10_000; + +/// Most bytes one run's storage calls read and write, all together, counting the whole settings +/// file each settings call reads and writes and each cache entry read or written; a call that would +/// pass it returns [`STORAGE_BUDGET`]. +pub const MAX_STORAGE_BYTES: usize = 1 << 30; + +/// One host import: its name, its type and the permission it needs. pub struct Import { pub name: &'static str, params: &'static [ValType], results: &'static [ValType], /// Its type as WebAssembly text writes it. pub signature: &'static str, + pub permission: Permission, } -/// The host imports of API level 2, in module [`MODULE`]: +const I32: ValType = ValType::I32; + +/// An import that takes `params` i32 arguments and returns an i32. +const fn storage_import(name: &'static str, params: &'static [ValType], signature: &'static str, permission: Permission) -> Import { + Import { name, params, results: &[I32], signature, permission } +} + +/// The host imports of API level 2, in module [`MODULE`]. With `progress`: /// /// - `vc_progress(done: i64, total: i64)`: `done` of `total` steps are finished (`total` 0 or less: /// not known). Negative values count as 0 and `done` is clamped to `total`; @@ -77,17 +120,71 @@ pub struct Import { /// - `vc_log(level: i32, ptr: i32, len: i32) -> i32`: writes the UTF-8 text at `ptr` as a log line /// of `level` (0 error, 1 warning, 2 information, 3 debug) → [`LOG_WRITTEN`], [`LOG_DROPPED`], /// [`LOG_OUTSIDE_MEMORY`], [`LOG_NOT_UTF8`] or [`LOG_BAD_LEVEL`]. -pub const IMPORTS: [Import; 3] = [ - Import { name: "vc_progress", params: &[ValType::I64, ValType::I64], results: &[], signature: "(param i64 i64)" }, - Import { name: "vc_cancelled", params: &[], results: &[ValType::I32], signature: "(result i32)" }, +/// +/// With `settings`, for the key of `key_len` bytes at `key`: +/// +/// - `vc_settings_get(key, key_len, out, out_cap) -> i32`: the value's length, its bytes copied to +/// `out` when they fit in `out_cap` (nothing is copied otherwise); +/// - `vc_settings_set(key, key_len, value, value_len) -> i32`: sets it → [`STORAGE_DONE`]; +/// - `vc_settings_delete(key, key_len) -> i32`: removes it → [`STORAGE_DONE`]. +/// +/// With `cache`, the same for the entry named at `name`: `vc_cache_read`, `vc_cache_write` and +/// `vc_cache_clear(name, name_len)`, which removes the entry, or every entry when `name_len` is 0. +/// +/// A storage call that fails returns [`STORAGE_NOT_FOUND`], [`STORAGE_OUTSIDE_MEMORY`], +/// [`STORAGE_BAD_NAME`], [`STORAGE_FULL`], [`STORAGE_UNAVAILABLE`], [`STORAGE_FAILED`] or +/// [`STORAGE_BUDGET`]. +pub const IMPORTS: [Import; 9] = [ + Import { + name: "vc_progress", + params: &[ValType::I64, ValType::I64], + results: &[], + signature: "(param i64 i64)", + permission: Permission::Progress, + }, + Import { name: "vc_cancelled", params: &[], results: &[I32], signature: "(result i32)", permission: Permission::Progress }, Import { name: "vc_log", - params: &[ValType::I32, ValType::I32, ValType::I32], - results: &[ValType::I32], + params: &[I32, I32, I32], + results: &[I32], signature: "(param i32 i32 i32) (result i32)", + permission: Permission::Progress, }, + storage_import("vc_settings_get", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Settings), + storage_import("vc_settings_set", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Settings), + storage_import("vc_settings_delete", &[I32, I32], "(param i32 i32) (result i32)", Permission::Settings), + storage_import("vc_cache_read", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache), + storage_import("vc_cache_write", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache), + storage_import("vc_cache_clear", &[I32, I32], "(param i32 i32) (result i32)", Permission::Cache), ]; +/// Which of a plug-in's stores a storage import reads or writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Area { + Settings, + Cache, +} + +impl Area { + /// The largest value one key or entry holds. + fn max_value(self) -> usize { + match self { + Area::Settings => storage::MAX_SETTING_BYTES, + Area::Cache => storage::MAX_CACHE_ENTRY, + } + } +} + +/// What a storage import returns for `e`. +fn storage_code(e: &StorageError) -> i32 { + match e { + StorageError::Full => STORAGE_FULL, + StorageError::Unavailable => STORAGE_UNAVAILABLE, + StorageError::Io(_) => STORAGE_FAILED, + StorageError::Budget => STORAGE_BUDGET, + } +} + /// A log line's level. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LogLevel { @@ -189,6 +286,11 @@ pub(crate) struct Run { /// Bytes of log text written (with their escapes) and read. log_bytes: usize, log_read: usize, + /// Where the plug-in keeps its settings and cache (none while the module is being loaded), and + /// how many storage calls the run made and bytes they moved. + storage: Option>, + storage_calls: usize, + storage_bytes: usize, pub(crate) report: Report, } @@ -204,10 +306,111 @@ impl Run { skipped: 0, log_bytes: 0, log_read: 0, + storage: None, + storage_calls: 0, + storage_bytes: 0, report: Report::default(), } } + /// The run, with the plug-in's settings and cache kept in `storage`. + pub(crate) fn with_storage(mut self, storage: Option>) -> Self { + self.storage = storage; + self + } + + /// Makes one storage call with `call`, on the plug-in's storage and a [`Meter`] holding the + /// run's storage bytes left, and counts the call and the bytes it read and wrote → the code for + /// a run that has used up its calls or has no storage, else what `call` returns. + fn metered(&mut self, call: impl FnOnce(&dyn Storage, &mut Meter) -> std::result::Result) -> std::result::Result { + if self.storage_calls >= MAX_STORAGE_CALLS { + return Err(STORAGE_BUDGET); + } + self.storage_calls += 1; + let Some(store) = self.storage.clone() else { return Err(STORAGE_UNAVAILABLE) }; + let mut meter = Meter::new(MAX_STORAGE_BYTES.saturating_sub(self.storage_bytes) as u64); + let done = call(store.as_ref(), &mut meter); + // What the call read before it failed counts too. + self.storage_bytes = self.storage_bytes.saturating_add(usize::try_from(meter.used()).unwrap_or(usize::MAX)); + done.map_err(|e| storage_code(&e)) + } + + /// The key or entry name of `len` bytes at `ptr` in `memory`, checked, or the code a storage + /// import returns for it. + fn storage_name(memory: &[u8], ptr: usize, len: usize) -> std::result::Result { + let bytes = ptr.checked_add(len).and_then(|end| memory.get(ptr..end)).ok_or(STORAGE_OUTSIDE_MEMORY)?; + let name = std::str::from_utf8(bytes).ok().filter(|n| storage::valid_name(n)).ok_or(STORAGE_BAD_NAME)?; + Ok(name.to_string()) + } + + /// `vc_settings_get` and `vc_cache_read`: copies the value of the key at `name` into the + /// `out_cap` bytes at `out` when it fits → its length. + fn storage_read(&mut self, area: Area, memory: &mut [u8], name: (usize, usize), out: (usize, usize)) -> i32 { + let key = match Self::storage_name(memory, name.0, name.1) { + Ok(key) => key, + Err(code) => return code, + }; + if out.1 > 0 && out.0.checked_add(out.1).is_none_or(|end| end > memory.len()) { + return STORAGE_OUTSIDE_MEMORY; + } + // The storage counts what it read whether or not the value fits the buffer. + let got = self.metered(|store, meter| match area { + Area::Settings => store.setting(&key, meter), + Area::Cache => store.cache_entry(&key, meter), + }); + let value = match got { + Ok(Some(value)) => value, + Ok(None) => return STORAGE_NOT_FOUND, + Err(code) => return code, + }; + let Ok(len) = i32::try_from(value.len()) else { return STORAGE_FAILED }; + if value.len() <= out.1 + && let Some(dest) = memory.get_mut(out.0..out.0 + value.len()) + { + dest.copy_from_slice(&value); + } + len + } + + /// `vc_settings_set` and `vc_cache_write`: sets the key at `name` to the `value` bytes. + fn storage_write(&mut self, area: Area, memory: &[u8], name: (usize, usize), value: (usize, usize)) -> i32 { + let key = match Self::storage_name(memory, name.0, name.1) { + Ok(key) => key, + Err(code) => return code, + }; + let Some(bytes) = value.0.checked_add(value.1).and_then(|end| memory.get(value.0..end)) else { return STORAGE_OUTSIDE_MEMORY }; + if bytes.len() > area.max_value() { + return STORAGE_FULL; + } + let written = self.metered(|store, meter| match area { + Area::Settings => store.set_setting(&key, bytes, meter), + Area::Cache => store.set_cache_entry(&key, bytes, meter), + }); + written.map_or_else(|code| code, |()| STORAGE_DONE) + } + + /// `vc_settings_delete` and `vc_cache_clear`: removes the key at `name`, or with `vc_cache_clear` + /// and a `name` of no bytes, every cache entry. + fn storage_remove(&mut self, area: Area, memory: &[u8], name: (usize, usize)) -> i32 { + let key = match (area, name.1) { + (Area::Cache, 0) => None, + _ => match Self::storage_name(memory, name.0, name.1) { + Ok(key) => Some(key), + Err(code) => return code, + }, + }; + let removed = self.metered(|store, meter| match (area, key) { + (Area::Settings, Some(key)) => store.delete_setting(&key, meter), + (Area::Cache, Some(key)) => store.remove_cache_entry(&key), + (_, None) => store.clear_cache().map(|_| true), + }); + match removed { + Ok(true) => STORAGE_DONE, + Ok(false) => STORAGE_NOT_FOUND, + Err(code) => code, + } + } + fn progress(&mut self, done: i64, total: i64) { let total = u64::try_from(total).unwrap_or(0); let done = u64::try_from(done).unwrap_or(0); @@ -286,55 +489,89 @@ pub(crate) struct Host { pub(crate) run: Run, } -/// A linker that offers the host imports when `granted`, and no import otherwise. -pub(crate) fn linker(engine: &Engine, granted: bool) -> Linker { +/// The module's memory and the store's state, for an import that reads or writes the memory. +fn memory_and_host<'a>(caller: &'a mut Caller<'_, Host>) -> Option<(&'a mut [u8], &'a mut Host)> { + let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return None }; + Some(memory.data_and_store_mut(caller)) +} + +/// An i32 argument as the unsigned offset or length it stands for. +fn offset(v: i32) -> usize { + v as u32 as usize +} + +/// A linker that offers the host imports whose permission is in `granted`, and no other import. +pub(crate) fn linker(engine: &Engine, granted: &[Permission]) -> Linker { let mut linker = Linker::::new(engine); - if granted { - // Each name is defined once, so none of these fails. + // Each name is defined once, so none of these fails. + if granted.contains(&Permission::Progress) { let _ = linker.func_wrap(MODULE, "vc_progress", |mut caller: Caller<'_, Host>, done: i64, total: i64| { caller.data_mut().run.progress(done, total); }); let _ = linker.func_wrap(MODULE, "vc_cancelled", |caller: Caller<'_, Host>| -> i32 { caller.data().run.cancelled() }); let _ = linker.func_wrap(MODULE, "vc_log", |mut caller: Caller<'_, Host>, level: i32, ptr: i32, len: i32| -> i32 { - let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return LOG_OUTSIDE_MEMORY }; - let (data, host) = memory.data_and_store_mut(&mut caller); - host.run.log(level, data, ptr as u32 as usize, len as u32 as usize) + let Some((data, host)) = memory_and_host(&mut caller) else { return LOG_OUTSIDE_MEMORY }; + host.run.log(level, data, offset(ptr), offset(len)) + }); + } + for (area, permission, get, set, remove) in [ + (Area::Settings, Permission::Settings, "vc_settings_get", "vc_settings_set", "vc_settings_delete"), + (Area::Cache, Permission::Cache, "vc_cache_read", "vc_cache_write", "vc_cache_clear"), + ] { + if !granted.contains(&permission) { + continue; + } + let _ = linker.func_wrap(MODULE, get, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32, out: i32, out_cap: i32| -> i32 { + let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY }; + host.run.storage_read(area, data, (offset(key), offset(key_len)), (offset(out), offset(out_cap))) + }); + let _ = linker.func_wrap(MODULE, set, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32, value: i32, value_len: i32| -> i32 { + let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY }; + host.run.storage_write(area, data, (offset(key), offset(key_len)), (offset(value), offset(value_len))) + }); + let _ = linker.func_wrap(MODULE, remove, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32| -> i32 { + let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY }; + host.run.storage_remove(area, data, (offset(key), offset(key_len))) }); } linker } -/// Checks that every import of `module` is a host import with the host's signature → their names. -/// Any other import is refused, and the error names it. -pub(crate) fn check_imports(module: &Module) -> Result> { - let mut names = Vec::new(); +/// Checks that every import of `module` is a host import with the host's signature → them. Any +/// other import is refused, and the error names it. +pub(crate) fn check_imports(module: &Module) -> Result> { + let mut found = Vec::new(); for imp in module.imports() { let offered = if imp.module() == MODULE { IMPORTS.iter().find(|i| i.name == imp.name()) } else { None }; let (from, name) = (permissions::display_text(imp.module(), 64), permissions::display_text(imp.name(), 64)); let Some(host) = offered else { return Err(Error::Abi(format!( - "the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network" + "the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network" ))); }; match imp.ty() { - ExternType::Func(f) if f.params() == host.params && f.results() == host.results => names.push(host.name), + ExternType::Func(f) if f.params() == host.params && f.results() == host.results => found.push(host), _ => { let signature = host.signature; return Err(Error::Abi(format!("the module imports `env.{name}` with another type than the host's: it is {signature}"))); } } } - Ok(names) + Ok(found) } -/// Checks that `manifest` allows the host imports `imports`: `api` 2 and the `progress` -/// permission. The error names the first import it doesn't allow. -pub(crate) fn check_granted(manifest: &Manifest, imports: &[&str]) -> Result<()> { - match imports.first() { - Some(name) if manifest.api < PROGRESS_API || !manifest.permissions().contains(&Permission::Progress) => Err(Error::Abi(format!( - "the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress`" - ))), - _ => Ok(()), +/// Checks that `manifest` allows the host imports `imports`: `api` 2 and each import's permission. +/// The error names the first import it doesn't allow. +pub(crate) fn check_granted(manifest: &Manifest, imports: &[&Import]) -> Result<()> { + let granted = manifest.permissions(); + match imports.iter().find(|i| manifest.api < PROGRESS_API || !granted.contains(&i.permission)) { + Some(import) => { + let (name, permission) = (import.name, import.permission.name()); + Err(Error::Abi(format!( + "the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}`" + ))) + } + None => Ok(()), } } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index fd2375be6..4ac06db48 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -3,14 +3,16 @@ //! A plug-in is a WebAssembly module that implements the VectorCraft plug-in ABI (v1, documented //! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with: //! -//! - **no host imports but the three of API level 2** ([`host`]: progress, Cancel and log lines, -//! with the `progress` permission): no WASI, file system, network, clock or randomness. A module -//! that imports anything else is rejected; +//! - **no host imports but those of API level 2** ([`host`]: progress, Cancel and log lines with +//! the `progress` permission, and settings and a cache of its own with the `settings` and `cache` +//! permissions, [`storage`]): no WASI, file system, network, clock or randomness. A module that +//! imports anything else is rejected; //! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is //! also enforced (native builds), so infinite loops end with an error. A filter may ask for a //! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it; //! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits; -//! - a **fresh instance per run**, so no state leaks between calls. +//! - a **fresh instance per run**, so no state leaks between calls; what a plug-in keeps is only +//! what it writes to its settings and cache. //! //! Every failure (malformed module, trap, exhausted budget, bad output) is an [`Error`]; nothing //! a module does can panic or hang the host. @@ -36,6 +38,7 @@ pub mod permissions; pub mod registry; mod runtime; pub mod safe_mode; +pub mod storage; pub mod wat; pub use dialog::ParamUi; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 36b4b78c4..da66ae820 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -308,10 +308,11 @@ impl Manifest { return Err(Error::Manifest(format!("`api` is {}; this host implements plug-in API levels 1 to {API_LEVEL}", self.api))); } if let Some(names) = &self.permissions - && permissions::validate(self.kind, names)?.contains(&Permission::Progress) + && let Some(host) = permissions::validate(self.kind, names)?.into_iter().find(|p| permissions::HOST.contains(p)) && self.api < PROGRESS_API { - return Err(Error::Manifest(format!("permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}"))); + let permission = host.name(); + return Err(Error::Manifest(format!("permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}"))); } self.validate_extensions()?; match (self.budget, self.kind) { @@ -658,6 +659,9 @@ mod tests { assert_eq!(serde_json::to_value(&level2).unwrap()["api"], json!(2)); let plain2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2}"#).unwrap(); assert!(!plain2.permissions().contains(&Permission::Progress), "never implied"); + let storing = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["cache","settings"]}"#).unwrap(); + assert_eq!(storing.permissions(), [Permission::Settings, Permission::Cache]); + assert!(!plain2.permissions().iter().any(|p| permissions::HOST.contains(p)), "never implied"); for (bad, says) in [ (r#"{"id":"a","name":"x","kind":"filter","permissions":["doc.read","net.fetch"]}"#, "unknown permission `net.fetch`"), (r#"{"id":"a","name":"x","kind":"filter","permissions":["ui.panel"]}"#, "reserved"), @@ -665,6 +669,8 @@ mod tests { (r#"{"id":"a","name":"x","kind":"filter","permissions":"doc.read"}"#, "manifest"), (r#"{"id":"a","name":"x","kind":"filter","permissions":["progress"]}"#, "`progress` needs plug-in API level 2"), (r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["progress"]}"#, "`progress` needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","permissions":["settings"]}"#, "`settings` needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["doc.read","cache"]}"#, "`cache` needs plug-in API level 2"), (r#"{"id":"a","name":"x","kind":"filter","api":3}"#, "`api` is 3"), (r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"), (r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"), diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 083c78a04..700c9641e 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -5,8 +5,9 @@ //! `doc.read`, `doc.write`, `state` and `progress` are the names of the plug-in capability model in //! storytold/photocraft#662; the other names are VectorCraft's own. A manifest without //! `permissions` gets the ones its kind implies ([`Permission::implied`]): what every plug-in of -//! that kind could do before manifests listed them. `progress`, which lets a plug-in use the host -//! imports of API level 2 ([`crate::host`]), is never implied: a manifest lists it. +//! that kind could do before manifests listed them. `progress`, `settings` and `cache`, which let a +//! plug-in use the host imports of API level 2 ([`crate::host`]), are never implied: a manifest +//! lists them. use serde::{Deserialize, Serialize}; @@ -16,8 +17,8 @@ use crate::{Error, Kind, Result}; /// this. pub const API_LEVEL: u32 = 2; -/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress` -/// permission they need. +/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress`, +/// `settings` and `cache` permissions they need. pub const PROGRESS_API: u32 = 2; /// Most permissions a manifest may list. @@ -56,11 +57,29 @@ pub enum Permission { /// host imports of API level 2 ([`crate::host::IMPORTS`]). #[serde(rename = "progress")] Progress, + /// Keep settings of its own between runs and sessions ([`crate::storage`]). + #[serde(rename = "settings")] + Settings, + /// Keep a cache of its own, which the user can clear ([`crate::storage`]). + #[serde(rename = "cache")] + Cache, } /// Every permission this host grants, in the order prompts list them. -pub const ALL: [Permission; 6] = - [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite, Permission::Progress]; +pub const ALL: [Permission; 8] = [ + Permission::DocRead, + Permission::DocWrite, + Permission::State, + Permission::EffectRender, + Permission::ExportWrite, + Permission::Progress, + Permission::Settings, + Permission::Cache, +]; + +/// The permissions of the host imports, which any kind may list and none implies; each needs plug-in +/// API level [`PROGRESS_API`]. +pub const HOST: [Permission; 3] = [Permission::Progress, Permission::Settings, Permission::Cache]; impl Permission { /// Its name in a manifest and in `plugin.install`'s `grant`. @@ -72,6 +91,8 @@ impl Permission { Permission::EffectRender => "effect.render", Permission::ExportWrite => "export.write", Permission::Progress => "progress", + Permission::Settings => "settings", + Permission::Cache => "cache", } } @@ -89,17 +110,19 @@ impl Permission { Permission::EffectRender => "Change how the objects its effect is applied to are drawn", Permission::ExportWrite => "Write the document as a file in its own format", Permission::Progress => "Show its progress, see Cancel and write log lines", + Permission::Settings => "Keep settings of its own", + Permission::Cache => "Keep a cache of its own, which you can clear", } } - /// May a plug-in of `kind` list it: one its kind implies, or `progress`, which any kind may - /// list. + /// May a plug-in of `kind` list it: one its kind implies, or one of the host imports' ([`HOST`]), + /// which any kind may list. pub fn applies(self, kind: Kind) -> bool { - self == Permission::Progress || Permission::implied(kind).contains(&self) + HOST.contains(&self) || Permission::implied(kind).contains(&self) } /// The permissions a manifest of `kind` without `permissions` gets. A manifest may also list - /// `progress` ([`Permission::applies`]). + /// the host imports' ([`HOST`], [`Permission::applies`]). pub fn implied(kind: Kind) -> &'static [Permission] { match kind { Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State], @@ -241,10 +264,22 @@ mod tests { assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render", "progress"])).is_ok()); assert!(validate(Kind::Exporter, &list(&["progress", "export.write", "doc.read"])).is_ok()); assert!([Kind::Filter, Kind::Effect, Kind::Exporter].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress))); + // So may `settings` and `cache`, which no kind implies either. + assert_eq!( + validate(Kind::Effect, &list(&["cache", "settings", "doc.read", "effect.render"])).unwrap(), + [Permission::DocRead, Permission::EffectRender, Permission::Settings, Permission::Cache] + ); + assert!(validate(Kind::Exporter, &list(&["settings", "export.write", "doc.read"])).is_ok()); + assert_eq!(validate(Kind::Filter, &list(&["cache"])).unwrap(), [Permission::Cache]); + for kind in [Kind::Filter, Kind::Effect, Kind::Exporter] { + assert!(HOST.iter().all(|p| p.applies(kind) && !Permission::implied(kind).contains(p))); + } for (kind, names, says) in [ (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), (Kind::Filter, &["ui.panel"], "`ui.panel` is reserved"), (Kind::Filter, &["progress", "progress"], "`progress` is listed twice"), + (Kind::Filter, &["cache", "cache"], "`cache` is listed twice"), + (Kind::Filter, &["storage"], "unknown permission `storage`"), (Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), (Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"), (Kind::Effect, &["doc.read"], "needs the permission `effect.render`"), diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index 6399d180f..fdb47dd03 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -10,12 +10,30 @@ use std::sync::{Arc, Mutex, RwLock}; #[cfg(not(target_arch = "wasm32"))] use crate::Error; use crate::permissions::Grant; +use crate::storage::Storage; use crate::{Limits, Plugin, Result}; static PLUGINS: RwLock>> = RwLock::new(BTreeMap::new()); static REVISION: AtomicU64 = AtomicU64::new(0); /// What the user approved, by plug-in id. static GRANTS: RwLock> = RwLock::new(BTreeMap::new()); +/// Where each plug-in keeps its settings and cache, by plug-in id. +static STORAGE: RwLock>> = RwLock::new(BTreeMap::new()); + +/// Keeps the settings and cache of plug-in `id` in `storage` from now on (`None`: nowhere, so its +/// storage calls fail as unavailable). A run of the plug-in reaches only its own id's storage. +pub fn set_storage(id: &str, storage: Option>) { + let mut all = STORAGE.write().unwrap_or_else(|e| e.into_inner()); + match storage { + Some(s) => all.insert(id.to_string(), s), + None => all.remove(id), + }; +} + +/// Where plug-in `id` keeps its settings and cache, if anywhere. +pub fn storage(id: &str) -> Option> { + STORAGE.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() +} /// Records what the user approved for a plug-in, replacing an earlier grant for its id. pub fn grant(g: Grant) { diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 7059aed15..d37a9159d 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -154,8 +154,9 @@ impl Plugin { let hash = crate::permissions::sha256_hex(bytes); let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), hash, source: None, serial: 0 }; let deadline = Deadline::new(p.limits.wall_time_ms); - // The manifest isn't read yet: every host import is linked, and what it reports is dropped. - let mut live = p.instantiate(Run::new("", false, false, deadline), true, &mut Report::default())?; + // The manifest isn't read yet: every host import is linked, what it reports is dropped and + // there is no storage to reach. + let mut live = p.instantiate(Run::new("", false, false, deadline), &crate::permissions::ALL, &mut Report::default())?; let version: TypedFunc<(), i32> = live.func("vc_abi_version")?; let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?; if v != ABI_VERSION { @@ -215,9 +216,9 @@ impl Plugin { self.serial = serial; } - /// A new instance for `run`, with the host imports linked when `granted`. When the module's - /// start function fails, what it reported through the host imports goes to `report`. - fn instantiate(&self, run: Run, granted: bool, report: &mut Report) -> Result { + /// A new instance for `run`, with the host imports of the permissions `granted` linked. When the + /// module's start function fails, what it reported through the host imports goes to `report`. + fn instantiate(&self, run: Run, granted: &[Permission], report: &mut Report) -> Result { let limits = StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build(); let mut store = Store::new(&self.engine, Host { limits, run }); @@ -287,7 +288,8 @@ impl Plugin { } /// [`Plugin::run_limited`], and what the run reported through the host imports: its last - /// progress report and its log lines, which also go to the app's log under its id. Inside a + /// progress report and its log lines, which also go to the app's log under its id. Its storage + /// imports reach the storage of its id ([`crate::registry::storage`]). Inside a /// [`vectorcraft_doc::cancel::scope`] its progress goes to the scope's sink (a live effect's /// doesn't) and `vc_cancelled` sees the scope's Cancel. pub fn run_reporting(&self, input: &[u8], params: &[u8], limits: &Limits) -> (Result>, Report) { @@ -306,8 +308,8 @@ impl Plugin { total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?; let deadline = Deadline::new(limits.wall_time_ms); let m = &self.manifest; - let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline); - let mut live = self.instantiate(run, m.permissions().contains(&Permission::Progress), report)?; + let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline).with_storage(crate::registry::storage(&m.id)); + let mut live = self.instantiate(run, &m.permissions(), report)?; let out = self.run_live(&mut live, input, params, limits, &deadline); *report = std::mem::take(&mut live.store.data_mut().run.report); out diff --git a/crates/plugins/src/storage.rs b/crates/plugins/src/storage.rs new file mode 100644 index 000000000..f3ef2e899 --- /dev/null +++ b/crates/plugins/src/storage.rs @@ -0,0 +1,151 @@ +//! What a plug-in keeps between runs, from plug-in API level 2 on: **settings**, a few named values +//! (the `settings` permission), and a **cache** of named entries the user can clear (the `cache` +//! permission). A plug-in reads and writes them through the host imports ([`crate::host::IMPORTS`]), +//! naming each by a key ([`valid_name`]), never by a path; the host keeps them per plug-in id +//! ([`Storage`], given with [`crate::registry::set_storage`]), so one plug-in can't reach another's. +//! +//! The caps are VectorCraft's own values: [`MAX_SETTINGS`] keys of at most [`MAX_SETTING_BYTES`] +//! each in a settings file of at most [`MAX_SETTINGS_BYTES`], and cache entries of at most +//! [`MAX_CACHE_ENTRY`] bytes, [`MAX_CACHE_BYTES`] and [`MAX_CACHE_ENTRIES`] in all. A write that +//! would pass one fails with [`StorageError::Full`] and changes nothing. + +/// Longest key or cache entry name, in bytes. +pub const MAX_NAME: usize = 64; + +/// Most settings a plug-in keeps. +pub const MAX_SETTINGS: usize = 256; + +/// Largest value of one setting, in bytes. +pub const MAX_SETTING_BYTES: usize = 64 << 10; + +/// Largest settings file, in bytes: the keys and the values in base64, as JSON. +pub const MAX_SETTINGS_BYTES: usize = 1 << 20; + +/// Largest cache entry, in bytes. +pub const MAX_CACHE_ENTRY: usize = 64 << 20; + +/// Most bytes a plug-in's cache holds, all entries together. +pub const MAX_CACHE_BYTES: u64 = 512 << 20; + +/// Most entries a plug-in's cache holds. +pub const MAX_CACHE_ENTRIES: usize = 4096; + +/// Why a storage call failed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StorageError { + /// The write would pass a cap; nothing was changed. + Full, + /// There is nowhere to keep anything: no plug-in store is attached, as in the web build. + Unavailable, + /// Reading or writing the file failed, with the system's reason. + Io(String), + /// The call would read or write more bytes than its [`Meter`] has left; nothing was changed. + Budget, +} + +/// The bytes a storage call may still read and write, and those it has. The storage charges each +/// file it reads or writes, at its size, before it does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Meter { + left: u64, + used: u64, +} + +impl Meter { + /// A meter that lets a call read and write `left` bytes. + pub fn new(left: u64) -> Self { + Meter { left, used: 0 } + } + + /// A meter with no limit, for callers that don't count. + pub fn unlimited() -> Self { + Meter::new(u64::MAX) + } + + /// Counts `bytes` about to be read or written → [`StorageError::Budget`], counting nothing, + /// when that is more than is left. + pub fn take(&mut self, bytes: u64) -> Result<(), StorageError> { + if bytes > self.left { + return Err(StorageError::Budget); + } + self.left -= bytes; + self.used = self.used.saturating_add(bytes); + Ok(()) + } + + /// The bytes counted so far. + pub fn used(&self) -> u64 { + self.used + } +} + +impl std::fmt::Display for StorageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StorageError::Full => write!(f, "the plug-in's storage is full"), + StorageError::Unavailable => write!(f, "no plug-in storage is available"), + StorageError::Io(why) => write!(f, "{why}"), + StorageError::Budget => write!(f, "the run has used up its storage bytes"), + } + } +} + +/// Where one plug-in's settings and cache are kept. Every key and name the host passes is a +/// [`valid_name`], and every value is within its cap ([`MAX_SETTING_BYTES`], [`MAX_CACHE_ENTRY`]); +/// the storage enforces the totals. The calls that read or write a file charge `meter` the bytes +/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file it +/// reads and the whole file it writes, a cache call the entry it reads or writes. +pub trait Storage: Send + Sync { + /// The value of setting `key`, if it has one. + fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError>; + /// Sets `key` to `value`. + fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError>; + /// Removes setting `key` → whether it had a value. + fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result; + /// The cache entry `name`, if there is one. + fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError>; + /// Writes the cache entry `name`. + fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError>; + /// Removes the cache entry `name` → whether there was one. + fn remove_cache_entry(&self, name: &str) -> Result; + /// Removes every cache entry → the bytes they held. + fn clear_cache(&self) -> Result; + /// The bytes the cache holds now. + fn cache_bytes(&self) -> u64; +} + +/// Is `name` a key or cache entry name a plug-in may use: 1 to [`MAX_NAME`] characters from +/// `A-Z a-z 0-9 _ -`, and not a name Windows keeps for a device (`CON`, `PRN`, `AUX`, `NUL`, +/// `COM0` to `COM9`, `LPT0` to `LPT9`, in any case)? Keys differ in case: `a` and `A` are two. +pub fn valid_name(name: &str) -> bool { + (1..=MAX_NAME).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) && !device_name(name) +} + +/// Is `name` one of the device names Windows keeps? +fn device_name(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + match upper.as_bytes() { + [a, b, c] => matches!(&[*a, *b, *c], b"CON" | b"PRN" | b"AUX" | b"NUL"), + [a, b, c, d] => matches!(&[*a, *b, *c], b"COM" | b"LPT") && d.is_ascii_digit(), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_are_short_plain_and_never_a_device() { + for ok in ["a", "Z", "zoom-level", "tile_12", &"x".repeat(MAX_NAME), "CONSOLE", "COM10", "LPTX", "con1"] { + assert!(valid_name(ok), "{ok}"); + } + for bad in ["", "..", ".", "a/b", "a\\b", "a.b", "a b", "a\u{0}", "\u{7}", "é", &"x".repeat(MAX_NAME + 1), "CON", "con", "Nul", "aux", "prn"] + { + assert!(!valid_name(bad), "{bad:?}"); + } + for n in 0..10 { + assert!(!valid_name(&format!("COM{n}")) && !valid_name(&format!("lpt{n}"))); + } + } +} diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index af041414a..f6bd289a6 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -59,6 +59,16 @@ pub const HOST_IMPORTS: &str = r#"(import "env" "vc_progress" (func $vc_progress (import "env" "vc_cancelled" (func $vc_cancelled (result i32))) (import "env" "vc_log" (func $vc_log (param i32 i32 i32) (result i32)))"#; +/// The storage imports of plug-in API level 2 ([`crate::host::IMPORTS`]), as `$vc_settings_get`, +/// `$vc_settings_set`, `$vc_settings_delete`, `$vc_cache_read`, `$vc_cache_write` and +/// `$vc_cache_clear`; a manifest that uses them lists `settings` and `cache`. +pub const STORAGE_IMPORTS: &str = r#"(import "env" "vc_settings_get" (func $vc_settings_get (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_settings_set" (func $vc_settings_set (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_settings_delete" (func $vc_settings_delete (param i32 i32) (result i32))) + (import "env" "vc_cache_read" (func $vc_cache_read (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_cache_write" (func $vc_cache_write (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_cache_clear" (func $vc_cache_clear (param i32 i32) (result i32)))"#; + /// A plug-in of API level 2 (its manifest gives `api` 2 and lists `progress`) that imports the /// host functions ([`HOST_IMPORTS`]) and whose `vc_run` runs `first`, which may call them, then /// returns `output`. diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index f5b78d224..bb32906ea 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -6,6 +6,7 @@ use std::time::{Duration, Instant}; use serde_json::{Value, json}; +use vectorcraft_plugins::storage::{Meter, Storage, StorageError}; use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, Report, heavy_terms, registry, wat as tpl}; /// The example Desaturate plug-in, built from `crates/plugins/example`. @@ -356,8 +357,8 @@ fn load_err(bytes: &[u8]) -> String { Plugin::load(bytes, Limits::default()).expect_err("the module must be refused").to_string() } -/// A module may import only the host's three functions, with the host's types, and only when its -/// manifest gives `api` 2 and lists `progress`; the error names the import it refuses. +/// A module may import only the host's functions, with the host's types, and the progress ones only +/// when its manifest gives `api` 2 and lists `progress`; the error names the import it refuses. #[test] fn host_imports_are_checked_when_a_module_is_loaded() { let with = |manifest: &str, import: &str| build(&tpl::with_imports(manifest, import, "", INPUT)); @@ -502,6 +503,206 @@ fn what_a_start_function_reports_before_it_fails_is_kept() { assert_eq!(report.progress, Some((1, 4))); } +/// Settings and a cache kept in memory, for the storage imports. Each call charges its meter the +/// bytes of the value it reads or writes. +#[derive(Default)] +struct Kept { + settings: std::sync::Mutex>>, + cache: std::sync::Mutex>>, +} + +/// The value `key` has in `kept`, once `meter` is charged its bytes. +fn charged( + kept: &std::sync::Mutex>>, + key: &str, + meter: &mut Meter, +) -> Result>, StorageError> { + let value = kept.lock().unwrap().get(key).cloned(); + meter.take(value.as_ref().map_or(0, |v| v.len() as u64))?; + Ok(value) +} + +impl Storage for Kept { + fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError> { + charged(&self.settings, key, meter) + } + fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + meter.take(value.len() as u64)?; + self.settings.lock().unwrap().insert(key.into(), value.to_vec()); + Ok(()) + } + fn delete_setting(&self, key: &str, _: &mut Meter) -> Result { + Ok(self.settings.lock().unwrap().remove(key).is_some()) + } + fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError> { + charged(&self.cache, name, meter) + } + fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + meter.take(value.len() as u64)?; + self.cache.lock().unwrap().insert(name.into(), value.to_vec()); + Ok(()) + } + fn remove_cache_entry(&self, name: &str) -> Result { + Ok(self.cache.lock().unwrap().remove(name).is_some()) + } + fn clear_cache(&self) -> Result { + let mut cache = self.cache.lock().unwrap(); + let freed = cache.values().map(|v| v.len() as u64).sum(); + cache.clear(); + Ok(freed) + } + fn cache_bytes(&self) -> u64 { + self.cache.lock().unwrap().values().map(|v| v.len() as u64).sum() + } +} + +/// A filter `id` of API level 2 that lists `settings` and `cache`. +fn storing(id: &str) -> String { + format!(r#"{{"id":"{id}","name":"Storing","version":"1","kind":"filter","api":2,"permissions":["settings","cache"]}}"#) +} + +/// Instructions that trap unless `call` returns `want`. +fn expect(call: &str, want: i32) -> String { + format!("(if (i32.ne {call} (i32.const {want})) (then unreachable))") +} + +/// The module fields of a storage test: the storage imports, keys at 0x2000 ("zoom", "..", "a/b", +/// "CON", a tab, 65 "x"), the value "hello" at 0x2100; 0x3000 is free for an output buffer. +fn storage_data() -> String { + format!(r#"{} (data (i32.const 0x2000) "zoom..a/bCON\09{}") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS, "x".repeat(65)) +} + +/// Each storage import needs its own permission: a module that imports one its manifest doesn't +/// list is refused, and the error names the import and the permission. +#[test] +fn storage_imports_need_their_own_permission() { + let with = |manifest: &str, import: &str| build(&tpl::with_imports(manifest, import, "", INPUT)); + let get = r#"(import "env" "vc_settings_get" (func (param i32 i32 i32 i32) (result i32)))"#; + let clear = r#"(import "env" "vc_cache_clear" (func (param i32 i32) (result i32)))"#; + let e = load_err(&with(LEVEL2, get)); + assert!( + e.contains("imports `env.vc_settings_get`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `settings`"), + "{e}" + ); + let settings_only = r#"{"id":"test.settings","name":"S","kind":"filter","api":2,"permissions":["settings"]}"#; + assert!(Plugin::load(&with(settings_only, get), Limits::default()).is_ok()); + let e = load_err(&with(settings_only, clear)); + assert!( + e.contains("imports `env.vc_cache_clear`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `cache`"), + "{e}" + ); + let wrong = load_err(&with(&storing("test.wrong"), r#"(import "env" "vc_cache_clear" (func (param i32) (result i32)))"#)); + assert!(wrong.contains("imports `env.vc_cache_clear` with another type than the host's: it is (param i32 i32) (result i32)"), "{wrong}"); + assert!(Plugin::load(&build(&tpl::with_imports(&storing("test.all"), tpl::STORAGE_IMPORTS, "", INPUT)), Limits::default()).is_ok()); +} + +/// A plug-in sets, reads and removes its settings and cache entries through the storage imports, +/// by key; a value too large for its buffer returns its length and copies nothing; a key or buffer +/// outside its memory, a key that isn't a plain name and a value over its cap are errors it sees. +#[test] +fn storage_imports_read_and_write_the_plugins_own_storage() { + use vectorcraft_plugins::host::{STORAGE_BAD_NAME, STORAGE_DONE, STORAGE_FULL, STORAGE_NOT_FOUND, STORAGE_OUTSIDE_MEMORY}; + let id = "test.storage.own"; + let kept = std::sync::Arc::new(Kept::default()); + registry::set_storage(id, Some(kept.clone())); + let zoom = "(i32.const 0x2000) (i32.const 4)"; + let first = [ + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), STORAGE_NOT_FOUND), + expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const 5))"), STORAGE_DONE), + // Too small a buffer: the length, and nothing copied. + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 4))"), 5), + expect("(i32.load8_u (i32.const 0x3000))", 0), + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), 5), + expect("(i32.load (i32.const 0x3000))", i32::from_le_bytes(*b"hell")), + expect(&format!("(call $vc_cache_write {zoom} (i32.const 0x2100) (i32.const 0))"), STORAGE_DONE), + expect(&format!("(call $vc_cache_read {zoom} (i32.const 0x3000) (i32.const 0))"), 0), + expect(&format!("(call $vc_cache_write {zoom} (i32.const 0x2100) (i32.const 3))"), STORAGE_DONE), + // Hostile keys: `..`, `a/b`, `CON`, a tab, 65 bytes, no bytes. + expect("(call $vc_settings_set (i32.const 0x2004) (i32.const 2) (i32.const 0x2100) (i32.const 5))", STORAGE_BAD_NAME), + expect("(call $vc_settings_set (i32.const 0x2006) (i32.const 3) (i32.const 0x2100) (i32.const 5))", STORAGE_BAD_NAME), + expect("(call $vc_cache_write (i32.const 0x2009) (i32.const 3) (i32.const 0x2100) (i32.const 5))", STORAGE_BAD_NAME), + expect("(call $vc_cache_read (i32.const 0x200c) (i32.const 1) (i32.const 0x3000) (i32.const 16))", STORAGE_BAD_NAME), + expect("(call $vc_settings_get (i32.const 0x200d) (i32.const 65) (i32.const 0x3000) (i32.const 16))", STORAGE_BAD_NAME), + expect("(call $vc_settings_delete (i32.const 0x2000) (i32.const 0))", STORAGE_BAD_NAME), + // Outside memory: the key, the value, the buffer. + expect("(call $vc_settings_get (i32.const -1) (i32.const 4) (i32.const 0x3000) (i32.const 16))", STORAGE_OUTSIDE_MEMORY), + expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const -1))"), STORAGE_OUTSIDE_MEMORY), + expect(&format!("(call $vc_cache_read {zoom} (i32.mul (memory.size) (i32.const 65536)) (i32.const 1))"), STORAGE_OUTSIDE_MEMORY), + // A setting over its cap changes nothing. + expect( + &format!("(call $vc_settings_set {zoom} (i32.const 0) (i32.const {}))", vectorcraft_plugins::storage::MAX_SETTING_BYTES + 1), + STORAGE_FULL, + ), + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), 5), + ] + .concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing(id), &storage_data(), &first, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes(), "every call returned what it should"); + assert_eq!(kept.settings.lock().unwrap().get("zoom"), Some(&b"hello".to_vec())); + assert_eq!(kept.cache.lock().unwrap().get("zoom"), Some(&b"hel".to_vec())); + // Removing: one setting, then a missing one, then the whole cache, then a missing entry. + let removing = [ + expect("(call $vc_settings_delete (i32.const 0x2000) (i32.const 4))", STORAGE_DONE), + expect("(call $vc_settings_delete (i32.const 0x2000) (i32.const 4))", STORAGE_NOT_FOUND), + expect("(call $vc_cache_clear (i32.const 0) (i32.const 0))", STORAGE_DONE), + expect("(call $vc_cache_clear (i32.const 0x2000) (i32.const 4))", STORAGE_NOT_FOUND), + ] + .concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing(id), &storage_data(), &removing, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes(), "every call returned what it should"); + assert!(kept.settings.lock().unwrap().is_empty() && kept.cache.lock().unwrap().is_empty()); + registry::set_storage(id, None); +} + +/// A read counts the value's bytes toward the run's storage bytes even when its buffer is too small +/// and nothing is copied, so reading a large entry into no buffer again and again ends in +/// `STORAGE_BUDGET`. +#[test] +fn a_read_into_too_small_a_buffer_still_counts_its_bytes() { + use vectorcraft_plugins::host::{MAX_STORAGE_BYTES, STORAGE_BUDGET}; + use vectorcraft_plugins::storage::MAX_CACHE_ENTRY; + let id = "test.storage.budget"; + let kept = std::sync::Arc::new(Kept::default()); + kept.cache.lock().unwrap().insert("zoom".into(), vec![0; MAX_CACHE_ENTRY]); + registry::set_storage(id, Some(kept.clone())); + let read = "(call $vc_cache_read (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 0))"; + let fits = MAX_STORAGE_BYTES / MAX_CACHE_ENTRY; + let reading = [vec![expect(read, MAX_CACHE_ENTRY as i32); fits], vec![expect(read, STORAGE_BUDGET)]].concat().concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing(id), &storage_data(), &reading, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes(), "every call returned what it should"); + registry::set_storage(id, None); +} + +/// A plug-in reaches only the storage of its own id, and a plug-in with none (not installed with a +/// plug-in store, as on the web) gets `STORAGE_UNAVAILABLE`. +#[test] +fn a_plugin_reaches_only_its_own_storage() { + use vectorcraft_plugins::host::{STORAGE_DONE, STORAGE_NOT_FOUND, STORAGE_UNAVAILABLE}; + let (mine, theirs) = (std::sync::Arc::new(Kept::default()), std::sync::Arc::new(Kept::default())); + registry::set_storage("test.storage.mine", Some(mine.clone())); + registry::set_storage("test.storage.theirs", Some(theirs.clone())); + theirs.settings.lock().unwrap().insert("zoom".into(), b"theirs".to_vec()); + theirs.cache.lock().unwrap().insert("zoom".into(), b"theirs".to_vec()); + let zoom = "(i32.const 0x2000) (i32.const 4)"; + let looking = [ + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), STORAGE_NOT_FOUND), + expect(&format!("(call $vc_cache_read {zoom} (i32.const 0x3000) (i32.const 16))"), STORAGE_NOT_FOUND), + expect("(call $vc_cache_clear (i32.const 0) (i32.const 0))", STORAGE_DONE), + expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const 5))"), STORAGE_DONE), + ] + .concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing("test.storage.mine"), &storage_data(), &looking, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes()); + assert_eq!(theirs.settings.lock().unwrap().get("zoom"), Some(&b"theirs".to_vec())); + assert_eq!(theirs.cache_bytes(), 6); + assert_eq!(mine.settings.lock().unwrap().get("zoom"), Some(&b"hello".to_vec())); + let nowhere = expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const 5))"), STORAGE_UNAVAILABLE); + let p = Plugin::load(&build(&tpl::with_imports(&storing("test.storage.nowhere"), &storage_data(), &nowhere, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes()); + registry::set_storage("test.storage.mine", None); + registry::set_storage("test.storage.theirs", None); +} + /// A heavy filter installs once the user agrees, which loading a folder doesn't ask: it is skipped /// and reported. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 6184b8bb0..8eed280d6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5731,6 +5731,8 @@ axis Y Axis Y ос Install “{name}”, which asks for these permissions? Да се инсталира ли „{name}“, който иска тези права? ID: {id} ID: {id} Version: {version} Версия: {version} + Install “{name}” in place of the plug-in with the same ID? Да се инсталира ли „{name}“ на мястото на приставката със същия ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Този файл не е приставката, инсталирана преди с този ID. Той получава настройките и кеша, пазени за този ID. File SHA-256: {hash} SHA-256 на файла: {hash} Read the objects it is given Четене на обектите, които получава Change, add and delete objects in the document Промяна, добавяне и изтриване на обекти в документа @@ -5738,6 +5740,9 @@ axis Y Axis Y ос Change how the objects its effect is applied to are drawn Промяна на начина, по който се рисуват обектите, към които е приложен ефектът му Write the document as a file in its own format Записване на документа като файл в собствен формат Show its progress, see Cancel and write log lines Показване на напредъка, разпознаване на отказ и записване на редове в дневника + Keep settings of its own Съхраняване на собствени настройки + Keep a cache of its own, which you can clear Съхраняване на собствен кеш, който можете да изчистите + Clear Plug-in Cache Изчистване на кеша на добавката # Actions panel: action sets (#3712). New Set… Нов набор… New Set Нов набор diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 18f1100db..f533bd62c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5754,6 +5754,9 @@ axis Y Axis Y osa Install “{name}”, which asks for these permissions? Instalirati „{name}”, koji traži ove dozvole? ID: {id} ID: {id} Version: {version} Verzija: {version} + Install “{name}” in place of the plug-in with the same ID? Instalirati „{name}” umjesto dodatka s istim ID-om? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ova datoteka nije dodatak ranije instaliran s ovim ID-om. Dobija postavke i predmemoriju sačuvane za taj ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” je drugi modul od dodatka s ID-om {_1} i dobio bi postavke i predmemoriju sačuvane za njega: instalirajte ga s `replace: true` kada korisnik pristane File SHA-256: {hash} SHA-256 datoteke: {hash} Read the objects it is given Čitati objekte koje dobije Change, add and delete objects in the document Mijenjati, dodavati i brisati objekte u dokumentu @@ -5761,10 +5764,13 @@ axis Y Axis Y osa Change how the objects its effect is applied to are drawn Mijenjati kako se crtaju objekti na koje je primijenjen njegov efekt Write the document as a file in its own format Zapisati dokument kao datoteku u vlastitom formatu Show its progress, see Cancel and write log lines Prikazati napredak, prepoznati otkazivanje i zapisivati redove dnevnika -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `progress` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API} + Keep settings of its own Čuvati vlastite postavke + Keep a cache of its own, which you can clear Čuvati vlastitu predmemoriju koju možete očistiti + Clear Plug-in Cache Očisti predmemoriju dodatka +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `{permission}` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modul uvozi `env.{name}` s drugim tipom od domaćinovog: domaćinov tip je {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul uvozi `env.{name}`, koji dodatak smije koristiti samo na nivou API-ja dodataka {PROGRESS_API} (`api` {PROGRESS_API}) i s dozvolom `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul uvozi `{from}.{name}`, koji domaćin ne nudi: dodatak smije uvoziti samo `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivo API-ja dodataka 2), a nikakav WASI, datoteke ni mrežu +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modul uvozi `env.{name}`, koji dodatak smije koristiti samo na nivou API-ja dodataka {PROGRESS_API} (`api` {PROGRESS_API}) i s dozvolom `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modul uvozi `{from}.{name}`, koji domaćin ne nudi: dodatak smije uvoziti samo funkcije `env.vc_*` koje navodi docs/plugins.md (nivo API-ja dodataka 2), a nikakav WASI, datoteke ni mrežu @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; ovaj domaćin podržava nivoe API-ja dodataka od 1 do {API_LEVEL} @msg `grant` must be a list of permission names `grant` mora biti lista naziva dozvola @msg `grant` names an unknown permission `{shown}` `grant` navodi nepoznatu dozvolu `{shown}` diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 55b04195c..f30503c5e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5877,6 +5877,9 @@ digits Malayalam Malaiàlam Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos? ID: {id} ID: {id} Version: {version} Versió: {version} + Install “{name}” in place of the plug-in with the same ID? Instal·la «{name}» en lloc del complement amb el mateix ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Aquest fitxer no és el complement instal·lat abans amb aquest ID. Rep la configuració i la memòria cau desades per a aquest ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» és un mòdul diferent del complement amb l’ID {_1} i rebria la configuració i la memòria cau desades per a aquest: instal·la’l amb `replace: true` quan l’usuari hi estigui d’acord File SHA-256: {hash} SHA-256 del fitxer: {hash} Read the objects it is given Llegir els objectes que rep Change, add and delete objects in the document Canviar, afegir i suprimir objectes del document @@ -5884,10 +5887,13 @@ digits Malayalam Malaiàlam Change how the objects its effect is applied to are drawn Canviar com es dibuixen els objectes als quals s’aplica el seu efecte Write the document as a file in its own format Escriure el document com un fitxer en un format propi Show its progress, see Cancel and write log lines Mostrar el progrés, detectar la cancel·lació i escriure línies de registre -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `progress` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API} + Keep settings of its own Desar una configuració pròpia + Keep a cache of its own, which you can clear Desar una memòria cau pròpia, que podeu buidar + Clear Plug-in Cache Buida la memòria cau del complement +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `{permission}` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} el mòdul importa `env.{name}` amb un tipus diferent del de l’amfitrió: el de l’amfitrió és {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el mòdul importa `env.{name}`, que un complement només pot fer servir al nivell d’API de complements {PROGRESS_API} (`api` {PROGRESS_API}) i amb el permís `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el mòdul importa `{from}.{name}`, que l’amfitrió no ofereix: un complement només pot importar `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivell d’API de complements 2), i cap WASI, fitxer ni xarxa +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` el mòdul importa `env.{name}`, que un complement només pot fer servir al nivell d’API de complements {PROGRESS_API} (`api` {PROGRESS_API}) i amb el permís `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network el mòdul importa `{from}.{name}`, que l’amfitrió no ofereix: un complement només pot importar les funcions `env.vc_*` que enumera docs/plugins.md (nivell d’API de complements 2), i cap WASI, fitxer ni xarxa @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` és {_1}; aquest amfitrió admet els nivells d’API de complements de l’1 al {API_LEVEL} @msg `grant` must be a list of permission names `grant` ha de ser una llista de noms de permisos @msg `grant` names an unknown permission `{shown}` `grant` esmenta un permís desconegut `{shown}` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 291eb020c..327664b70 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5863,6 +5863,9 @@ axis Y Axis osa Y Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění? ID: {id} ID: {id} Version: {version} Verze: {version} + Install “{name}” in place of the plug-in with the same ID? Nainstalovat „{name}“ místo zásuvného modulu se stejným ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tento soubor není zásuvný modul dříve nainstalovaný s tímto ID. Získá nastavení a mezipaměť uchovávané pro toto ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ je jiný modul než zásuvný modul s ID {_1} a získal by nastavení a mezipaměť uchovávané pro něj: nainstalujte ho s `replace: true`, jakmile uživatel souhlasí File SHA-256: {hash} SHA-256 souboru: {hash} Read the objects it is given Číst objekty, které dostane Change, add and delete objects in the document Měnit, přidávat a mazat objekty v dokumentu @@ -5870,10 +5873,13 @@ axis Y Axis osa Y Change how the objects its effect is applied to are drawn Měnit, jak se kreslí objekty, na které je použit jeho efekt Write the document as a file in its own format Zapsat dokument jako soubor ve vlastním formátu Show its progress, see Cancel and write log lines Zobrazit průběh, rozpoznat zrušení a zapisovat řádky protokolu -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `progress` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API} + Keep settings of its own Uchovávat vlastní nastavení + Keep a cache of its own, which you can clear Uchovávat vlastní mezipaměť, kterou můžete vymazat + Clear Plug-in Cache Vymazat mezipaměť zásuvného modulu +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `{permission}` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modul importuje `env.{name}` s jiným typem, než má hostitel: typ hostitele je {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul importuje `env.{name}`, který zásuvný modul smí používat jen na úrovni API zásuvných modulů {PROGRESS_API} (`api` {PROGRESS_API}) a s oprávněním `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul importuje `{from}.{name}`, který hostitel nenabízí: zásuvný modul smí importovat jen `env.vc_progress`, `env.vc_cancelled` a `env.vc_log` (úroveň API zásuvných modulů 2), a žádné WASI, soubory ani síť +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modul importuje `env.{name}`, který zásuvný modul smí používat jen na úrovni API zásuvných modulů {PROGRESS_API} (`api` {PROGRESS_API}) a s oprávněním `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modul importuje `{from}.{name}`, který hostitel nenabízí: zásuvný modul smí importovat jen funkce `env.vc_*`, které uvádí docs/plugins.md (úroveň API zásuvných modulů 2), a žádné WASI, soubory ani síť @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; tento hostitel podporuje úrovně API zásuvných modulů 1 až {API_LEVEL} @msg `grant` must be a list of permission names `grant` musí být seznam názvů oprávnění @msg `grant` names an unknown permission `{shown}` `grant` uvádí neznámé oprávnění `{shown}` diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 494242444..ddcf0cee0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5746,6 +5746,8 @@ axis Y Axis Y-akse Install “{name}”, which asks for these permissions? Installér »{name}«, som beder om disse rettigheder? ID: {id} ID: {id} Version: {version} Version: {version} + Install “{name}” in place of the plug-in with the same ID? Installér »{name}« i stedet for det plug-in, der har samme id? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Denne fil er ikke det plug-in, der før var installeret med dette id. Den får de indstillinger og den cache, der er gemt for dette id. File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Læse de objekter, den får Change, add and delete objects in the document Ændre, tilføje og slette objekter i dokumentet @@ -5753,6 +5755,9 @@ axis Y Axis Y-akse Change how the objects its effect is applied to are drawn Ændre, hvordan de objekter, dens effekt er anvendt på, tegnes Write the document as a file in its own format Skrive dokumentet som en fil i sit eget format Show its progress, see Cancel and write log lines Vise sin fremdrift, se en annullering og skrive logrækker + Keep settings of its own Gemme sine egne indstillinger + Keep a cache of its own, which you can clear Gemme sin egen cache, som du kan rydde + Clear Plug-in Cache Ryd plug-in-cache # Actions panel: action sets (#3712). New Set… Nyt sæt… New Set Nyt sæt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 66064b144..609d04cd2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5445,6 +5445,9 @@ axis Y Axis Y-Achse Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt? ID: {id} ID: {id} Version: {version} Version: {version} + Install “{name}” in place of the plug-in with the same ID? „{name}“ anstelle des Plug-ins mit derselben ID installieren? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Diese Datei ist nicht das Plug-in, das vorher mit dieser ID installiert war. Sie erhält die Einstellungen und den Cache, die für diese ID gespeichert sind. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ ist ein anderes Modul als das Plug-in mit der ID {_1} und würde die dafür gespeicherten Einstellungen und den Cache erhalten: Installieren Sie es mit `replace: true`, sobald der Benutzer zustimmt File SHA-256: {hash} Datei-SHA-256: {hash} Read the objects it is given Die Objekte lesen, die es erhält Change, add and delete objects in the document Objekte im Dokument ändern, hinzufügen und löschen @@ -5452,10 +5455,13 @@ axis Y Axis Y-Achse Change how the objects its effect is applied to are drawn Ändern, wie die Objekte gezeichnet werden, auf die sein Effekt angewendet ist Write the document as a file in its own format Das Dokument als Datei in einem eigenen Format schreiben Show its progress, see Cancel and write log lines Den Fortschritt anzeigen, ein Abbrechen erkennen und Protokollzeilen schreiben -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `progress` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an + Keep settings of its own Eigene Einstellungen speichern + Keep a cache of its own, which you can clear Einen eigenen Cache anlegen, den Sie leeren können + Clear Plug-in Cache Plug-in-Cache leeren +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `{permission}` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an @msg the module imports `env.{name}` with another type than the host's: it is {signature} das Modul importiert `env.{name}` mit einem anderen Typ als dem des Hosts: Der Typ des Hosts ist {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` das Modul importiert `env.{name}`, das ein Plug-in nur auf der Plug-in-API-Stufe {PROGRESS_API} (`api` {PROGRESS_API}) und mit der Berechtigung `progress` verwenden darf -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network das Modul importiert `{from}.{name}`, das der Host nicht anbietet: Ein Plug-in darf nur `env.vc_progress`, `env.vc_cancelled` und `env.vc_log` importieren (Plug-in-API-Stufe 2), aber kein WASI, keine Dateien und kein Netzwerk +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` das Modul importiert `env.{name}`, das ein Plug-in nur auf der Plug-in-API-Stufe {PROGRESS_API} (`api` {PROGRESS_API}) und mit der Berechtigung `{permission}` verwenden darf +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network das Modul importiert `{from}.{name}`, das der Host nicht anbietet: Ein Plug-in darf nur die `env.vc_*`-Funktionen importieren, die docs/plugins.md aufführt (Plug-in-API-Stufe 2), aber kein WASI, keine Dateien und kein Netzwerk @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ist {_1}; dieser Host unterstützt die Plug-in-API-Stufen 1 bis {API_LEVEL} @msg `grant` must be a list of permission names `grant` muss eine Liste von Berechtigungsnamen sein @msg `grant` names an unknown permission `{shown}` `grant` nennt eine unbekannte Berechtigung `{shown}` diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 282f72eb5..5c701ea4a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5814,6 +5814,9 @@ axis Y Axis Άξονας Y Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα; ID: {id} Αναγνωριστικό: {id} Version: {version} Έκδοση: {version} + Install “{name}” in place of the plug-in with the same ID? Να εγκατασταθεί το «{name}» στη θέση του πρόσθετου με το ίδιο αναγνωριστικό; + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Αυτό το αρχείο δεν είναι το πρόσθετο που ήταν εγκατεστημένο πριν με αυτό το αναγνωριστικό. Παίρνει τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό το αναγνωριστικό. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees το «{name}» είναι άλλη λειτουργική μονάδα από το πρόσθετο με αναγνωριστικό {_1} και θα έπαιρνε τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό: εγκαταστήστε το με `replace: true` μόλις συμφωνήσει ο χρήστης File SHA-256: {hash} SHA-256 αρχείου: {hash} Read the objects it is given Ανάγνωση των αντικειμένων που του δίνονται Change, add and delete objects in the document Αλλαγή, προσθήκη και διαγραφή αντικειμένων στο έγγραφο @@ -5821,10 +5824,13 @@ axis Y Axis Άξονας Y Change how the objects its effect is applied to are drawn Αλλαγή του τρόπου σχεδίασης των αντικειμένων στα οποία εφαρμόζεται το εφέ του Write the document as a file in its own format Εγγραφή του εγγράφου ως αρχείου σε δική του μορφή Show its progress, see Cancel and write log lines Εμφάνιση της προόδου, αναγνώριση της ακύρωσης και εγγραφή γραμμών καταγραφής -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `progress` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API} + Keep settings of its own Διατήρηση δικών του ρυθμίσεων + Keep a cache of its own, which you can clear Διατήρηση δικής του κρυφής μνήμης, την οποία μπορείτε να εκκαθαρίσετε + Clear Plug-in Cache Εκκαθάριση κρυφής μνήμης προσθέτου +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `{permission}` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} η λειτουργική μονάδα εισάγει το `env.{name}` με άλλον τύπο από αυτόν του κεντρικού υπολογιστή: ο τύπος του είναι {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` η λειτουργική μονάδα εισάγει το `env.{name}`, το οποίο ένα πρόσθετο μπορεί να χρησιμοποιεί μόνο στο επίπεδο API προσθέτων {PROGRESS_API} (`api` {PROGRESS_API}) και με το δικαίωμα `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network η λειτουργική μονάδα εισάγει το `{from}.{name}`, το οποίο δεν προσφέρει ο κεντρικός υπολογιστής: ένα πρόσθετο μπορεί να εισάγει μόνο τα `env.vc_progress`, `env.vc_cancelled` και `env.vc_log` (επίπεδο API προσθέτων 2) και κανένα WASI, αρχεία ή δίκτυο +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` η λειτουργική μονάδα εισάγει το `env.{name}`, το οποίο ένα πρόσθετο μπορεί να χρησιμοποιεί μόνο στο επίπεδο API προσθέτων {PROGRESS_API} (`api` {PROGRESS_API}) και με το δικαίωμα `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network η λειτουργική μονάδα εισάγει το `{from}.{name}`, το οποίο δεν προσφέρει ο κεντρικός υπολογιστής: ένα πρόσθετο μπορεί να εισάγει μόνο τις συναρτήσεις `env.vc_*` που απαριθμεί το docs/plugins.md (επίπεδο API προσθέτων 2) και κανένα WASI, αρχεία ή δίκτυο @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} το `api` είναι {_1}· αυτός ο κεντρικός υπολογιστής υποστηρίζει τα επίπεδα API προσθέτων 1 έως {API_LEVEL} @msg `grant` must be a list of permission names το `grant` πρέπει να είναι λίστα ονομάτων δικαιωμάτων @msg `grant` names an unknown permission `{shown}` το `grant` αναφέρει άγνωστο δικαίωμα `{shown}` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c120598a6..7ba760adb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5804,6 +5804,9 @@ axis Y Axis Eje Y Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos? ID: {id} ID: {id} Version: {version} Versión: {version} + Install “{name}” in place of the plug-in with the same ID? ¿Instalar “{name}” en lugar del plugin con el mismo ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este archivo no es el plugin instalado antes con este ID. Recibe la configuración y la caché guardadas para ese ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” es un módulo distinto del plugin con ID {_1} y recibiría la configuración y la caché guardadas para él: instálalo con `replace: true` cuando el usuario lo acepte File SHA-256: {hash} SHA-256 del archivo: {hash} Read the objects it is given Leer los objetos que recibe Change, add and delete objects in the document Cambiar, añadir y eliminar objetos del documento @@ -5811,10 +5814,13 @@ axis Y Axis Eje Y Change how the objects its effect is applied to are drawn Cambiar cómo se dibujan los objetos a los que se aplica su efecto Write the document as a file in its own format Escribir el documento como un archivo en su propio formato Show its progress, see Cancel and write log lines Mostrar su progreso, detectar la cancelación y escribir líneas de registro -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `progress` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API} + Keep settings of its own Guardar una configuración propia + Keep a cache of its own, which you can clear Guardar una caché propia, que usted puede vaciar + Clear Plug-in Cache Vaciar caché del plugin +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `{permission}` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} el módulo importa `env.{name}` con un tipo distinto del del host: el del host es {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el módulo importa `env.{name}`, que un plugin solo puede usar en el nivel de API de plugins {PROGRESS_API} (`api` {PROGRESS_API}) y con el permiso `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el módulo importa `{from}.{name}`, que el host no ofrece: un plugin solo puede importar `env.vc_progress`, `env.vc_cancelled` y `env.vc_log` (nivel de API de plugins 2), y ningún WASI, archivo ni red +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` el módulo importa `env.{name}`, que un plugin solo puede usar en el nivel de API de plugins {PROGRESS_API} (`api` {PROGRESS_API}) y con el permiso `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network el módulo importa `{from}.{name}`, que el host no ofrece: un plugin solo puede importar las funciones `env.vc_*` que enumera docs/plugins.md (nivel de API de plugins 2), y ningún WASI, archivo ni red @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` es {_1}; este host admite los niveles de API de plugins de 1 a {API_LEVEL} @msg `grant` must be a list of permission names `grant` debe ser una lista de nombres de permisos @msg `grant` names an unknown permission `{shown}` `grant` nombra un permiso desconocido `{shown}` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 677ab5ddb..8fab4136a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5893,6 +5893,9 @@ axis Y Axis Y-akseli Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia? ID: {id} Tunnus: {id} Version: {version} Versio: {version} + Install “{name}” in place of the plug-in with the same ID? Asennetaanko ”{name}” samaa tunnusta käyttävän laajennuksen tilalle? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tämä tiedosto ei ole laajennus, joka oli aiemmin asennettu tällä tunnuksella. Se saa tälle tunnukselle säilytetyt asetukset ja välimuistin. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” on eri moduuli kuin laajennus, jonka tunnus on {_1}, ja se saisi sille säilytetyt asetukset ja välimuistin: asenna se asetuksella `replace: true`, kun käyttäjä hyväksyy File SHA-256: {hash} Tiedoston SHA-256: {hash} Read the objects it is given Lukea sille annetut objektit Change, add and delete objects in the document Muuttaa, lisätä ja poistaa asiakirjan objekteja @@ -5900,10 +5903,13 @@ axis Y Axis Y-akseli Change how the objects its effect is applied to are drawn Muuttaa sitä, miten objektit, joihin sen tehostetta on käytetty, piirretään Write the document as a file in its own format Kirjoittaa asiakirjan tiedostoksi omassa muodossaan Show its progress, see Cancel and write log lines Näyttää edistymisensä, havaita peruutuksen ja kirjoittaa lokirivejä -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `progress` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API} + Keep settings of its own Säilyttää omat asetuksensa + Keep a cache of its own, which you can clear Säilyttää oman välimuistin, jonka voit tyhjentää + Clear Plug-in Cache Tyhjennä laajennuksen välimuisti +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `{permission}` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} moduuli tuo kohteen `env.{name}` eri tyyppisenä kuin isäntä: isännän tyyppi on {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduuli tuo kohteen `env.{name}`, jota laajennus saa käyttää vain laajennus-API:n tasolla {PROGRESS_API} (`api` {PROGRESS_API}) ja käyttöoikeudella `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduuli tuo kohteen `{from}.{name}`, jota isäntä ei tarjoa: laajennus saa tuoda vain kohteet `env.vc_progress`, `env.vc_cancelled` ja `env.vc_log` (laajennus-API:n taso 2) eikä mitään WASIa, tiedostoja tai verkkoa +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` moduuli tuo kohteen `env.{name}`, jota laajennus saa käyttää vain laajennus-API:n tasolla {PROGRESS_API} (`api` {PROGRESS_API}) ja käyttöoikeudella `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network moduuli tuo kohteen `{from}.{name}`, jota isäntä ei tarjoa: laajennus saa tuoda vain docs/plugins.md-tiedostossa luetellut `env.vc_*`-funktiot (laajennus-API:n taso 2) eikä mitään WASIa, tiedostoja tai verkkoa @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` on {_1}; tämä isäntä tukee laajennus-API:n tasoja 1 ja {API_LEVEL} välillä @msg `grant` must be a list of permission names `grant` on oltava luettelo käyttöoikeuksien nimistä @msg `grant` names an unknown permission `{shown}` `grant` nimeää tuntemattoman käyttöoikeuden `{shown}` diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6b6f306cf..039fe3fca 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5858,6 +5858,9 @@ axis Y Axis Axe Y Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? ID: {id} ID : {id} Version: {version} Version : {version} + Install “{name}” in place of the plug-in with the same ID? Installer « {name} » à la place du module externe qui a le même ID ? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ce fichier n’est pas le module externe installé auparavant avec cet ID. Il reçoit les réglages et le cache conservés pour cet ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees « {name} » est un autre module que le module externe d’ID {_1} et recevrait les réglages et le cache conservés pour celui-ci : installez-le avec `replace: true` une fois que l’utilisateur l’accepte File SHA-256: {hash} SHA-256 du fichier : {hash} Read the objects it is given Lire les objets qui lui sont transmis Change, add and delete objects in the document Modifier, ajouter et supprimer des objets du document @@ -5865,10 +5868,13 @@ axis Y Axis Axe Y Change how the objects its effect is applied to are drawn Modifier le rendu des objets auxquels son effet est appliqué Write the document as a file in its own format Écrire le document dans un fichier à son propre format Show its progress, see Cancel and write log lines Afficher sa progression, détecter l’annulation et écrire des lignes de journal -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `progress` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API} + Keep settings of its own Conserver ses propres réglages + Keep a cache of its own, which you can clear Conserver son propre cache, que vous pouvez vider + Clear Plug-in Cache Vider le cache du module externe +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `{permission}` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} le module importe `env.{name}` avec un autre type que celui de l’hôte : celui de l’hôte est {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` le module importe `env.{name}`, qu’un module externe ne peut utiliser qu’au niveau d’API de module externe {PROGRESS_API} (`api` {PROGRESS_API}) et avec l’autorisation `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network le module importe `{from}.{name}`, que l’hôte ne propose pas : un module externe ne peut importer que `env.vc_progress`, `env.vc_cancelled` et `env.vc_log` (niveau d’API de module externe 2), et aucun WASI, fichier ni réseau +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` le module importe `env.{name}`, qu’un module externe ne peut utiliser qu’au niveau d’API de module externe {PROGRESS_API} (`api` {PROGRESS_API}) et avec l’autorisation `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network le module importe `{from}.{name}`, que l’hôte ne propose pas : un module externe ne peut importer que les fonctions `env.vc_*` que liste docs/plugins.md (niveau d’API de module externe 2), et aucun WASI, fichier ni réseau @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL} @msg `grant` must be a list of permission names `grant` doit être une liste de noms d’autorisations @msg `grant` names an unknown permission `{shown}` `grant` nomme une autorisation inconnue `{shown}` diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index c5e079470..8c03e0c67 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5230,6 +5230,9 @@ Install “{name}”, which asks for these permissions? "{name}" स्थापित करें, जो ये अनुमतियां मांगता है? ID: {id} ID: {id} Version: {version} संस्करण: {version} + Install “{name}” in place of the plug-in with the same ID? समान ID वाले प्लग-इन की जगह “{name}” इंस्टॉल करें? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. यह फ़ाइल इस ID के साथ पहले इंस्टॉल किया गया प्लग-इन नहीं है। इसे उस ID के लिए रखी गई सेटिंग्स और कैश मिलते हैं। +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” आईडी {_1} वाले प्लग-इन से अलग मॉड्यूल है और उसके लिए रखी गई सेटिंग्स और कैश पा लेगा: उपयोगकर्ता की सहमति मिलने पर इसे `replace: true` से इंस्टॉल करें File SHA-256: {hash} फ़ाइल SHA-256: {hash} Read the objects it is given उसे दिए गए ऑब्जेक्ट्स पढ़ना Change, add and delete objects in the document डॉक्यूमेंट में ऑब्जेक्ट्स बदलना, जोड़ना और हटाना @@ -5237,10 +5240,13 @@ Change how the objects its effect is applied to are drawn जिन ऑब्जेक्ट्स पर उसका इफ़ेक्ट लगा है, उनके बनाए जाने का तरीका बदलना Write the document as a file in its own format डॉक्यूमेंट को अपने फ़ॉर्मेट की फ़ाइल के रूप में लिखना Show its progress, see Cancel and write log lines अपनी प्रगति दिखाना, रद्द करने का अनुरोध देखना और लॉग पंक्तियाँ लिखना -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `progress` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें + Keep settings of its own अपनी सेटिंग्स रखना + Keep a cache of its own, which you can clear अपना कैश रखना, जिसे आप साफ़ कर सकते हैं + Clear Plug-in Cache प्लग-इन कैश साफ़ करें +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `{permission}` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें @msg the module imports `env.{name}` with another type than the host's: it is {signature} मॉड्यूल `env.{name}` को होस्ट से अलग प्रकार के साथ आयात करता है: होस्ट का प्रकार {signature} है -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` मॉड्यूल `env.{name}` आयात करता है, जिसे प्लग-इन केवल प्लग-इन API स्तर {PROGRESS_API} (`api` {PROGRESS_API}) पर और अनुमति `progress` के साथ उपयोग कर सकता है -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network मॉड्यूल `{from}.{name}` आयात करता है, जिसे होस्ट उपलब्ध नहीं कराता: प्लग-इन केवल `env.vc_progress`, `env.vc_cancelled` और `env.vc_log` (प्लग-इन API स्तर 2) आयात कर सकता है, और कोई WASI, फ़ाइल या नेटवर्क नहीं +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` मॉड्यूल `env.{name}` आयात करता है, जिसे प्लग-इन केवल प्लग-इन API स्तर {PROGRESS_API} (`api` {PROGRESS_API}) पर और अनुमति `{permission}` के साथ उपयोग कर सकता है +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network मॉड्यूल `{from}.{name}` आयात करता है, जिसे होस्ट उपलब्ध नहीं कराता: प्लग-इन केवल वे `env.vc_*` फ़ंक्शन आयात कर सकता है जो docs/plugins.md में सूचीबद्ध हैं (प्लग-इन API स्तर 2), और कोई WASI, फ़ाइल या नेटवर्क नहीं @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` {_1} है; यह होस्ट प्लग-इन API स्तर 1 से {API_LEVEL} तक का समर्थन करता है @msg `grant` must be a list of permission names `grant` अनुमति नामों की सूची होनी चाहिए @msg `grant` names an unknown permission `{shown}` `grant` एक अज्ञात अनुमति `{shown}` बताता है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 262530084..eb0bf1e83 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5846,6 +5846,9 @@ axis Y Axis Y tengely Install “{name}”, which asks for these permissions? Telepíted a(z) „{name}” bővítményt, amely ezeket az engedélyeket kéri? ID: {id} Azonosító: {id} Version: {version} Verzió: {version} + Install “{name}” in place of the plug-in with the same ID? Telepíted a(z) „{name}” bővítményt az azonos azonosítójú bővítmény helyére? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ez a fájl nem az a bővítmény, amely korábban ezzel az azonosítóval volt telepítve. Megkapja az ehhez az azonosítóhoz tárolt beállításokat és gyorsítótárat. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees a(z) „{name}” más modul, mint a(z) {_1} azonosítójú bővítmény, és megkapná az ahhoz tárolt beállításokat és gyorsítótárat: telepítsd a `replace: true` megadásával, miután a felhasználó beleegyezett File SHA-256: {hash} A fájl SHA-256 értéke: {hash} Read the objects it is given Olvashatja a kapott objektumokat Change, add and delete objects in the document Módosíthat, hozzáadhat és törölhet objektumokat a dokumentumban @@ -5853,10 +5856,13 @@ axis Y Axis Y tengely Change how the objects its effect is applied to are drawn Módosíthatja, hogyan rajzolódnak ki az objektumok, amelyekre az effektusa alkalmazva van Write the document as a file in its own format Saját formátumú fájlba írhatja a dokumentumot Show its progress, see Cancel and write log lines Megjelenítheti az előrehaladását, észlelheti a megszakítást és naplósorokat írhat -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a `progress` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket + Keep settings of its own Saját beállításokat tárolhat + Keep a cache of its own, which you can clear Saját gyorsítótárat tárolhat, amelyet Ön kiüríthet + Clear Plug-in Cache Bővítmény gyorsítótárának kiürítése +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a(z) `{permission}` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket @msg the module imports `env.{name}` with another type than the host's: it is {signature} a modul az `env.{name}` elemet a gazdaprogramétól eltérő típussal importálja: a gazdaprogram típusa {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` a modul importálja az `env.{name}` elemet, amelyet bővítmény csak {PROGRESS_API}. szintű bővítmény-API-val (`api` {PROGRESS_API}) és a `progress` engedéllyel használhat -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network a modul importálja a(z) `{from}.{name}` elemet, amelyet a gazdaprogram nem kínál: bővítmény csak az `env.vc_progress`, `env.vc_cancelled` és `env.vc_log` elemet importálhatja (2. szintű bővítmény-API), WASI-t, fájlokat és hálózatot nem +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` a modul importálja az `env.{name}` elemet, amelyet bővítmény csak {PROGRESS_API}. szintű bővítmény-API-val (`api` {PROGRESS_API}) és a(z) `{permission}` engedéllyel használhat +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network a modul importálja a(z) `{from}.{name}` elemet, amelyet a gazdaprogram nem kínál: bővítmény csak a docs/plugins.md által felsorolt `env.vc_*` függvényeket importálhatja (2. szintű bővítmény-API), WASI-t, fájlokat és hálózatot nem @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} az `api` értéke {_1}; ez a gazdaprogram az 1 és {API_LEVEL} közötti bővítmény-API-szinteket támogatja @msg `grant` must be a list of permission names a `grant` értékének engedélynevek listájának kell lennie @msg `grant` names an unknown permission `{shown}` a `grant` ismeretlen engedélyt nevez meg: `{shown}` diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a176f60e3..a27a83850 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5746,6 +5746,8 @@ axis Y Axis Sumbu Y Install “{name}”, which asks for these permissions? Instal “{name}”, yang meminta izin berikut? ID: {id} ID: {id} Version: {version} Versi: {version} + Install “{name}” in place of the plug-in with the same ID? Instal “{name}” sebagai pengganti plug-in dengan ID yang sama? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. File ini bukan plug-in yang sebelumnya diinstal dengan ID ini. File ini mendapatkan pengaturan dan cache yang disimpan untuk ID tersebut. File SHA-256: {hash} SHA-256 file: {hash} Read the objects it is given Membaca objek yang diberikan kepadanya Change, add and delete objects in the document Mengubah, menambah, dan menghapus objek dalam dokumen @@ -5753,6 +5755,9 @@ axis Y Axis Sumbu Y Change how the objects its effect is applied to are drawn Mengubah cara objek yang diberi efeknya digambar Write the document as a file in its own format Menulis dokumen sebagai file dalam formatnya sendiri Show its progress, see Cancel and write log lines Menampilkan kemajuannya, mengenali pembatalan, dan menulis baris log + Keep settings of its own Menyimpan pengaturannya sendiri + Keep a cache of its own, which you can clear Menyimpan cache-nya sendiri, yang dapat Anda kosongkan + Clear Plug-in Cache Kosongkan cache plug-in # Actions panel: action sets (#3712). New Set… Set baru… New Set Set baru diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cfcbad2dc..62c8d5855 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5479,6 +5479,9 @@ axis Y Axis Asse Y Install “{name}”, which asks for these permissions? Installare “{name}”, che chiede queste autorizzazioni? ID: {id} ID: {id} Version: {version} Versione: {version} + Install “{name}” in place of the plug-in with the same ID? Installare “{name}” al posto del plug-in con lo stesso ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Questo file non è il plug-in installato prima con questo ID. Riceve le impostazioni e la cache conservate per quell’ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” è un modulo diverso dal plug-in con ID {_1} e riceverebbe le impostazioni e la cache conservate per esso: installalo con `replace: true` quando l’utente acconsente File SHA-256: {hash} SHA-256 del file: {hash} Read the objects it is given Leggere gli oggetti che riceve Change, add and delete objects in the document Modificare, aggiungere ed eliminare oggetti nel documento @@ -5486,10 +5489,13 @@ axis Y Axis Asse Y Change how the objects its effect is applied to are drawn Cambiare il modo in cui vengono disegnati gli oggetti a cui è applicato il suo effetto Write the document as a file in its own format Scrivere il documento come file in un formato proprio Show its progress, see Cancel and write log lines Mostrare il proprio avanzamento, rilevare l’annullamento e scrivere righe di registro -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `progress` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API} + Keep settings of its own Conservare impostazioni proprie + Keep a cache of its own, which you can clear Conservare una cache propria, che è possibile svuotare + Clear Plug-in Cache Svuota cache del plug-in +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `{permission}` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} il modulo importa `env.{name}` con un tipo diverso da quello dell’host: quello dell’host è {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` il modulo importa `env.{name}`, che un plug-in può usare solo al livello API dei plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e con l’autorizzazione `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network il modulo importa `{from}.{name}`, che l’host non offre: un plug-in può importare solo `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (livello API dei plug-in 2), e nessun WASI, file o rete +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` il modulo importa `env.{name}`, che un plug-in può usare solo al livello API dei plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e con l’autorizzazione `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network il modulo importa `{from}.{name}`, che l’host non offre: un plug-in può importare solo le funzioni `env.vc_*` elencate in docs/plugins.md (livello API dei plug-in 2), e nessun WASI, file o rete @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` è {_1}; questo host supporta i livelli API dei plug-in da 1 a {API_LEVEL} @msg `grant` must be a list of permission names `grant` deve essere un elenco di nomi di autorizzazioni @msg `grant` names an unknown permission `{shown}` `grant` indica un’autorizzazione sconosciuta `{shown}` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5c6eff65b..8ea8706c5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5544,6 +5544,9 @@ axis Y Axis Y 軸 Install “{name}”, which asks for these permissions? これらの権限を求める「{name}」をインストールしますか? ID: {id} ID: {id} Version: {version} バージョン: {version} + Install “{name}” in place of the plug-in with the same ID? 同じ ID のプラグインの代わりに「{name}」をインストールしますか? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. このファイルは、以前にこの ID でインストールされていたプラグインではありません。この ID 用に保持されている設定とキャッシュを引き継ぎます。 +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」は ID {_1} のプラグインとは別のモジュールで、そのプラグイン用に保持されている設定とキャッシュを引き継ぐことになります。ユーザーが同意したら `replace: true` を指定してインストールしてください File SHA-256: {hash} ファイルの SHA-256: {hash} Read the objects it is given 渡されたオブジェクトの読み取り Change, add and delete objects in the document ドキュメント内のオブジェクトの変更、追加、削除 @@ -5551,10 +5554,13 @@ axis Y Axis Y 軸 Change how the objects its effect is applied to are drawn 効果が適用されたオブジェクトの描画方法の変更 Write the document as a file in its own format ドキュメントを独自形式のファイルとして書き出し Show its progress, see Cancel and write log lines 進行状況の表示、キャンセルの検知、ログ行の書き込み -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `progress` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください + Keep settings of its own 独自の設定の保持 + Keep a cache of its own, which you can clear 消去できる独自のキャッシュの保持 + Clear Plug-in Cache プラグインのキャッシュを消去 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `{permission}` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください @msg the module imports `env.{name}` with another type than the host's: it is {signature} モジュールが `env.{name}` をホストと異なる型でインポートしています。ホストの型は {signature} です -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` モジュールが `env.{name}` をインポートしていますが、プラグインがこれを使用できるのはプラグイン API レベル {PROGRESS_API} (`api` {PROGRESS_API}) で権限 `progress` がある場合だけです -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network モジュールがホストの提供しない `{from}.{name}` をインポートしています。プラグインがインポートできるのは `env.vc_progress`、`env.vc_cancelled`、`env.vc_log` (プラグイン API レベル 2) だけで、WASI、ファイル、ネットワークは使用できません +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` モジュールが `env.{name}` をインポートしていますが、プラグインがこれを使用できるのはプラグイン API レベル {PROGRESS_API} (`api` {PROGRESS_API}) で権限 `{permission}` がある場合だけです +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network モジュールがホストの提供しない `{from}.{name}` をインポートしています。プラグインがインポートできるのは docs/plugins.md に記載された `env.vc_*` 関数 (プラグイン API レベル 2) だけで、WASI、ファイル、ネットワークは使用できません @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` は {_1} です。このホストが対応するプラグイン API レベルは 1 から {API_LEVEL} です @msg `grant` must be a list of permission names `grant` は権限名のリストにしてください @msg `grant` names an unknown permission `{shown}` `grant` に不明な権限 `{shown}` が含まれています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 9a388da1f..bb12f0353 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5787,6 +5787,9 @@ axis Y Axis Y ღერძი Install “{name}”, which asks for these permissions? დავაყენოთ „{name}“, რომელიც ამ ნებართვებს ითხოვს? ID: {id} ID: {id} Version: {version} ვერსია: {version} + Install “{name}” in place of the plug-in with the same ID? დავაყენოთ „{name}“ იმავე ID-ის მქონე პლაგინის ნაცვლად? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. ეს ფაილი არ არის ამ ID-ით ადრე დაყენებული პლაგინი. ის მიიღებს ამ ID-სთვის შენახულ პარამეტრებს და ქეშს. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ სხვა მოდულია, ვიდრე პლაგინი ID-ით {_1}, და მიიღებდა მისთვის შენახულ პარამეტრებს და ქეშს: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `replace: true` პარამეტრით File SHA-256: {hash} ფაილის SHA-256: {hash} Read the objects it is given მიღებული ობიექტების წაკითხვა Change, add and delete objects in the document დოკუმენტში ობიექტების შეცვლა, დამატება და წაშლა @@ -5794,10 +5797,13 @@ axis Y Axis Y ღერძი Change how the objects its effect is applied to are drawn იმის შეცვლა, თუ როგორ იხატება ობიექტები, რომლებზეც მისი ეფექტია გამოყენებული Write the document as a file in its own format დოკუმენტის ჩაწერა ფაილად საკუთარ ფორმატში Show its progress, see Cancel and write log lines პროგრესის ჩვენება, გაუქმების შემჩნევა და ჟურნალის სტრიქონების ჩაწერა -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `progress` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} + Keep settings of its own საკუთარი პარამეტრების შენახვა + Keep a cache of its own, which you can clear საკუთარი ქეშის შენახვა, რომლის გასუფთავებაც შეგიძლიათ + Clear Plug-in Cache პლაგინის ქეშის გასუფთავება +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `{permission}` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} მოდულს შემოაქვს `env.{name}` ჰოსტისგან განსხვავებული ტიპით: ჰოსტის ტიპია {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` მოდულს შემოაქვს `env.{name}`, რომლის გამოყენებაც პლაგინს მხოლოდ პლაგინის API-ის დონეზე {PROGRESS_API} (`api` {PROGRESS_API}) და ნებართვით `progress` შეუძლია -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network მოდულს შემოაქვს `{from}.{name}`, რომელსაც ჰოსტი არ გვთავაზობს: პლაგინს შეუძლია შემოიტანოს მხოლოდ `env.vc_progress`, `env.vc_cancelled` და `env.vc_log` (პლაგინის API-ის დონე 2) და არა WASI, ფაილები ან ქსელი +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` მოდულს შემოაქვს `env.{name}`, რომლის გამოყენებაც პლაგინს მხოლოდ პლაგინის API-ის დონეზე {PROGRESS_API} (`api` {PROGRESS_API}) და ნებართვით `{permission}` შეუძლია +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network მოდულს შემოაქვს `{from}.{name}`, რომელსაც ჰოსტი არ გვთავაზობს: პლაგინს შეუძლია შემოიტანოს მხოლოდ docs/plugins.md-ში ჩამოთვლილი `env.vc_*` ფუნქციები (პლაგინის API-ის დონე 2) და არა WASI, ფაილები ან ქსელი @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` არის {_1}; ეს ჰოსტი პლაგინის API-ის დონეებს 1-დან {API_LEVEL}-მდე უჭერს მხარს @msg `grant` must be a list of permission names `grant` ნებართვების სახელების სია უნდა იყოს @msg `grant` names an unknown permission `{shown}` `grant` უცნობ ნებართვას ასახელებს: `{shown}` diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2bcd02f1f..bed786266 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5440,6 +5440,9 @@ axis Y Axis Y축 Install “{name}”, which asks for these permissions? 이 권한을 요청하는 “{name}”을(를) 설치하시겠습니까? ID: {id} ID: {id} Version: {version} 버전: {version} + Install “{name}” in place of the plug-in with the same ID? 같은 ID의 플러그인 대신 “{name}”을(를) 설치하시겠습니까? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 이 파일은 이전에 이 ID로 설치된 플러그인이 아닙니다. 이 ID에 보관된 설정과 캐시를 받습니다. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”은(는) ID {_1}의 플러그인과 다른 모듈이며 그 플러그인에 보관된 설정과 캐시를 받게 됩니다. 사용자가 동의하면 `replace: true`로 설치하세요 File SHA-256: {hash} 파일 SHA-256: {hash} Read the objects it is given 전달받은 개체 읽기 Change, add and delete objects in the document 문서의 개체 변경, 추가 및 삭제 @@ -5447,10 +5450,13 @@ axis Y Axis Y축 Change how the objects its effect is applied to are drawn 효과가 적용된 개체가 그려지는 방식 변경 Write the document as a file in its own format 문서를 자체 형식의 파일로 쓰기 Show its progress, see Cancel and write log lines 진행률 표시, 취소 감지 및 로그 줄 쓰기 -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오 + Keep settings of its own 자체 설정 보관 + Keep a cache of its own, which you can clear 지울 수 있는 자체 캐시 보관 + Clear Plug-in Cache 플러그인 캐시 지우기 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `{permission}` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오 @msg the module imports `env.{name}` with another type than the host's: it is {signature} 모듈이 `env.{name}`을(를) 호스트와 다른 형식으로 가져옵니다. 호스트의 형식은 {signature}입니다 -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 모듈이 `env.{name}`을(를) 가져오지만, 플러그인은 이를 플러그인 API 수준 {PROGRESS_API}(`api` {PROGRESS_API})에서 `progress` 권한이 있을 때만 사용할 수 있습니다 -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 모듈이 호스트가 제공하지 않는 `{from}.{name}`을(를) 가져옵니다. 플러그인은 `env.vc_progress`, `env.vc_cancelled`, `env.vc_log`(플러그인 API 수준 2)만 가져올 수 있으며 WASI, 파일, 네트워크는 사용할 수 없습니다 +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` 모듈이 `env.{name}`을(를) 가져오지만, 플러그인은 이를 플러그인 API 수준 {PROGRESS_API}(`api` {PROGRESS_API})에서 `{permission}` 권한이 있을 때만 사용할 수 있습니다 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network 모듈이 호스트가 제공하지 않는 `{from}.{name}`을(를) 가져옵니다. 플러그인은 docs/plugins.md에 나열된 `env.vc_*` 함수(플러그인 API 수준 2)만 가져올 수 있으며 WASI, 파일, 네트워크는 사용할 수 없습니다 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api`가 {_1}입니다. 이 호스트는 플러그인 API 수준 1부터 {API_LEVEL}까지 지원합니다 @msg `grant` must be a list of permission names `grant`는 권한 이름 목록이어야 합니다 @msg `grant` names an unknown permission `{shown}` `grant`에 알 수 없는 권한 `{shown}`이(가) 있습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 58a7a5ab8..bb8b5a0b0 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5792,6 +5792,8 @@ axis Y Axis Y-as Install “{name}”, which asks for these permissions? „{name}” installeren, dat om deze machtigingen vraagt? ID: {id} ID: {id} Version: {version} Versie: {version} + Install “{name}” in place of the plug-in with the same ID? „{name}” installeren in plaats van de plug-in met dezelfde ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Dit bestand is niet de plug-in die eerder met deze ID was geïnstalleerd. Het krijgt de instellingen en cache die voor die ID bewaard zijn. File SHA-256: {hash} SHA-256 van het bestand: {hash} Read the objects it is given De objecten lezen die het krijgt Change, add and delete objects in the document Objecten in het document wijzigen, toevoegen en verwijderen @@ -5799,6 +5801,9 @@ axis Y Axis Y-as Change how the objects its effect is applied to are drawn Wijzigen hoe de objecten waarop het effect is toegepast worden getekend Write the document as a file in its own format Het document als bestand in een eigen indeling schrijven Show its progress, see Cancel and write log lines De voortgang tonen, een annulering zien en logregels schrijven + Keep settings of its own Eigen instellingen bewaren + Keep a cache of its own, which you can clear Een eigen cache bewaren, die u kunt legen + Clear Plug-in Cache Plug-incache legen # Actions panel: action sets (#3712). New Set… Nieuwe set… New Set Nieuwe set diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8f150913d..48bf41947 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5716,6 +5716,9 @@ axis Y Axis Oś Y Install “{name}”, which asks for these permissions? Zainstalować „{name}”, wtyczkę, która prosi o te uprawnienia? ID: {id} ID: {id} Version: {version} Wersja: {version} + Install “{name}” in place of the plug-in with the same ID? Zainstalować „{name}” zamiast wtyczki o tym samym ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ten plik nie jest wtyczką zainstalowaną wcześniej z tym ID. Otrzyma ustawienia i pamięć podręczną przechowywane dla tego ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” to inny moduł niż wtyczka o ID {_1} i otrzymałby przechowywane dla niej ustawienia i pamięć podręczną: zainstaluj go z `replace: true`, gdy użytkownik się zgodzi File SHA-256: {hash} SHA-256 pliku: {hash} Read the objects it is given Odczytywać obiekty, które otrzymuje Change, add and delete objects in the document Zmieniać, dodawać i usuwać obiekty w dokumencie @@ -5723,10 +5726,13 @@ axis Y Axis Oś Y Change how the objects its effect is applied to are drawn Zmieniać sposób rysowania obiektów, do których zastosowano jej efekt Write the document as a file in its own format Zapisywać dokument jako plik we własnym formacie Show its progress, see Cancel and write log lines Pokazywać swój postęp, wykrywać anulowanie i zapisywać wiersze dziennika -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `progress` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API} + Keep settings of its own Przechowywać własne ustawienia + Keep a cache of its own, which you can clear Przechowywać własną pamięć podręczną, którą możesz wyczyścić + Clear Plug-in Cache Wyczyść pamięć podręczną wtyczki +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `{permission}` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} moduł importuje `env.{name}` z innym typem niż typ hosta: typ hosta to {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduł importuje `env.{name}`, którego wtyczka może używać tylko na poziomie API wtyczek {PROGRESS_API} (`api` {PROGRESS_API}) i z uprawnieniem `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduł importuje `{from}.{name}`, którego host nie udostępnia: wtyczka może importować tylko `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (poziom API wtyczek 2), bez WASI, plików i sieci +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` moduł importuje `env.{name}`, którego wtyczka może używać tylko na poziomie API wtyczek {PROGRESS_API} (`api` {PROGRESS_API}) i z uprawnieniem `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network moduł importuje `{from}.{name}`, którego host nie udostępnia: wtyczka może importować tylko funkcje `env.vc_*` wymienione w docs/plugins.md (poziom API wtyczek 2), bez WASI, plików i sieci @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ma wartość {_1}; ten host obsługuje poziomy API wtyczek od 1 do {API_LEVEL} @msg `grant` must be a list of permission names `grant` musi być listą nazw uprawnień @msg `grant` names an unknown permission `{shown}` `grant` wymienia nieznane uprawnienie `{shown}` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e528e0062..62e251ef5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5778,6 +5778,9 @@ axis Y Axis Eixo Y Install “{name}”, which asks for these permissions? Instalar “{name}”, que pede estas permissões? ID: {id} ID: {id} Version: {version} Versão: {version} + Install “{name}” in place of the plug-in with the same ID? Instalar “{name}” no lugar do plug-in com o mesmo ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este arquivo não é o plug-in instalado antes com este ID. Ele recebe as configurações e o cache guardados para esse ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” é um módulo diferente do plug-in com ID {_1} e receberia as configurações e o cache guardados para ele: instale-o com `replace: true` depois que o usuário concordar File SHA-256: {hash} SHA-256 do arquivo: {hash} Read the objects it is given Ler os objetos que recebe Change, add and delete objects in the document Alterar, adicionar e excluir objetos no documento @@ -5785,10 +5788,13 @@ axis Y Axis Eixo Y Change how the objects its effect is applied to are drawn Alterar como são desenhados os objetos aos quais seu efeito é aplicado Write the document as a file in its own format Gravar o documento como um arquivo em formato próprio Show its progress, see Cancel and write log lines Mostrar seu progresso, detectar o cancelamento e gravar linhas de log -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `progress` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API} + Keep settings of its own Guardar configurações próprias + Keep a cache of its own, which you can clear Guardar um cache próprio, que você pode limpar + Clear Plug-in Cache Limpar cache do plug-in +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `{permission}` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} o módulo importa `env.{name}` com um tipo diferente do tipo do host: o do host é {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` o módulo importa `env.{name}`, que um plug-in só pode usar no nível de API de plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e com a permissão `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network o módulo importa `{from}.{name}`, que o host não oferece: um plug-in só pode importar `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (nível de API de plug-in 2), e nenhum WASI, arquivo ou rede +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` o módulo importa `env.{name}`, que um plug-in só pode usar no nível de API de plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e com a permissão `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network o módulo importa `{from}.{name}`, que o host não oferece: um plug-in só pode importar as funções `env.vc_*` listadas em docs/plugins.md (nível de API de plug-in 2), e nenhum WASI, arquivo ou rede @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` é {_1}; este host aceita os níveis de API de plug-in de 1 a {API_LEVEL} @msg `grant` must be a list of permission names `grant` deve ser uma lista de nomes de permissões @msg `grant` names an unknown permission `{shown}` `grant` cita uma permissão desconhecida `{shown}` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 95e55ddfc..c505319d9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5474,6 +5474,9 @@ axis Y Axis Ось Y Install “{name}”, which asks for these permissions? Установить «{name}», которому нужны эти разрешения? ID: {id} ID: {id} Version: {version} Версия: {version} + Install “{name}” in place of the plug-in with the same ID? Установить «{name}» вместо модуля с тем же ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Этот файл не является модулем, установленным ранее с этим ID. Он получит настройки и кэш, сохранённые для этого ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» отличается от модуля с ID {_1} и получил бы сохранённые для него настройки и кэш: установите его с `replace: true`, когда пользователь согласится File SHA-256: {hash} SHA-256 файла: {hash} Read the objects it is given Читать переданные ему объекты Change, add and delete objects in the document Изменять, добавлять и удалять объекты в документе @@ -5481,10 +5484,13 @@ axis Y Axis Ось Y Change how the objects its effect is applied to are drawn Изменять то, как рисуются объекты, к которым применён его эффект Write the document as a file in its own format Записывать документ в файл собственного формата Show its progress, see Cancel and write log lines Показывать свой ход выполнения, замечать отмену и записывать строки журнала -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `progress` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API} + Keep settings of its own Хранить собственные настройки + Keep a cache of its own, which you can clear Хранить собственный кэш, который можно очистить + Clear Plug-in Cache Очистить кэш модуля +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `{permission}` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль импортирует `env.{name}` с другим типом, чем у хоста: у хоста тип {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль импортирует `env.{name}`, который модуль может использовать только на уровне API модулей {PROGRESS_API} (`api` {PROGRESS_API}) и с разрешением `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль импортирует `{from}.{name}`, который хост не предоставляет: модуль может импортировать только `env.vc_progress`, `env.vc_cancelled` и `env.vc_log` (уровень API модулей 2), без WASI, файлов и сети +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` модуль импортирует `env.{name}`, который модуль может использовать только на уровне API модулей {PROGRESS_API} (`api` {PROGRESS_API}) и с разрешением `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network модуль импортирует `{from}.{name}`, который хост не предоставляет: модуль может импортировать только функции `env.vc_*`, перечисленные в docs/plugins.md (уровень API модулей 2), без WASI, файлов и сети @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` равно {_1}; этот хост поддерживает уровни API модулей от 1 до {API_LEVEL} @msg `grant` must be a list of permission names `grant` должен быть списком имён разрешений @msg `grant` names an unknown permission `{shown}` `grant` называет неизвестное разрешение `{shown}` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 29d428cbc..67c495f17 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5886,6 +5886,9 @@ axis Y Axis Y-axel Install “{name}”, which asks for these permissions? Installera ”{name}”, som begär dessa behörigheter? ID: {id} ID: {id} Version: {version} Version: {version} + Install “{name}” in place of the plug-in with the same ID? Installera ”{name}” i stället för det plugin som har samma id? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Den här filen är inte det plugin som tidigare installerades med detta id. Den får de inställningar och den cache som sparats för detta id. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” är en annan modul än pluginet med id {_1} och skulle få de inställningar och den cache som sparats för det: installera det med `replace: true` när användaren har godkänt File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Läsa objekten den får Change, add and delete objects in the document Ändra, lägga till och ta bort objekt i dokumentet @@ -5893,10 +5896,13 @@ axis Y Axis Y-axel Change how the objects its effect is applied to are drawn Ändra hur objekten som dess effekt används på ritas Write the document as a file in its own format Skriva dokumentet som en fil i sitt eget format Show its progress, see Cancel and write log lines Visa sitt förlopp, se när det avbryts och skriva loggrader -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `progress` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API} + Keep settings of its own Spara egna inställningar + Keep a cache of its own, which you can clear Spara en egen cache, som du kan tömma + Clear Plug-in Cache Töm pluginets cache +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `{permission}` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} modulen importerar `env.{name}` med en annan typ än värdens: värdens typ är {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modulen importerar `env.{name}`, som ett plugin bara får använda på plugin-API-nivå {PROGRESS_API} (`api` {PROGRESS_API}) och med behörigheten `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modulen importerar `{from}.{name}`, som värden inte erbjuder: ett plugin får bara importera `env.vc_progress`, `env.vc_cancelled` och `env.vc_log` (plugin-API-nivå 2), och ingen WASI, inga filer eller nätverk +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modulen importerar `env.{name}`, som ett plugin bara får använda på plugin-API-nivå {PROGRESS_API} (`api` {PROGRESS_API}) och med behörigheten `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modulen importerar `{from}.{name}`, som värden inte erbjuder: ett plugin får bara importera de `env.vc_*`-funktioner som docs/plugins.md listar (plugin-API-nivå 2), och ingen WASI, inga filer eller nätverk @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` är {_1}; den här värden stöder plugin-API-nivå 1 till {API_LEVEL} @msg `grant` must be a list of permission names `grant` måste vara en lista med behörighetsnamn @msg `grant` names an unknown permission `{shown}` `grant` anger en okänd behörighet `{shown}` diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e9020bf69..70ff00f3d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5820,6 +5820,9 @@ axis Y Axis Y ekseni Install “{name}”, which asks for these permissions? Bu izinleri isteyen “{name}” yüklensin mi? ID: {id} Kimlik: {id} Version: {version} Sürüm: {version} + Install “{name}” in place of the plug-in with the same ID? Aynı kimliğe sahip eklentinin yerine “{name}” yüklensin mi? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Bu dosya, daha önce bu kimlikle yüklenen eklenti değil. O kimlik için saklanan ayarları ve önbelleği alır. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”, {_1} kimlikli eklentiden farklı bir modül ve onun için saklanan ayarları ve önbelleği alacak: kullanıcı onayladıktan sonra `replace: true` ile yükleyin File SHA-256: {hash} Dosya SHA-256: {hash} Read the objects it is given Kendisine verilen nesneleri okuma Change, add and delete objects in the document Belgedeki nesneleri değiştirme, ekleme ve silme @@ -5827,10 +5830,13 @@ axis Y Axis Y ekseni Change how the objects its effect is applied to are drawn Efektinin uygulandığı nesnelerin nasıl çizildiğini değiştirme Write the document as a file in its own format Belgeyi kendi biçiminde bir dosya olarak yazma Show its progress, see Cancel and write log lines İlerlemesini gösterme, iptali algılama ve günlük satırları yazma -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin + Keep settings of its own Kendi ayarlarını saklama + Keep a cache of its own, which you can clear Temizleyebileceğiniz kendi önbelleğini saklama + Clear Plug-in Cache Eklenti Önbelleğini Temizle +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `{permission}` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin @msg the module imports `env.{name}` with another type than the host's: it is {signature} modül `env.{name}` öğesini ana bilgisayarınkinden farklı bir türle içe aktarıyor: ana bilgisayarın türü {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modül `env.{name}` öğesini içe aktarıyor; bir eklenti bunu yalnızca eklenti API düzeyi {PROGRESS_API} (`api` {PROGRESS_API}) ile ve `progress` izniyle kullanabilir -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modül, ana bilgisayarın sunmadığı `{from}.{name}` öğesini içe aktarıyor: bir eklenti yalnızca `env.vc_progress`, `env.vc_cancelled` ve `env.vc_log` öğelerini (eklenti API düzeyi 2) içe aktarabilir; WASI, dosya veya ağ kullanamaz +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` modül `env.{name}` öğesini içe aktarıyor; bir eklenti bunu yalnızca eklenti API düzeyi {PROGRESS_API} (`api` {PROGRESS_API}) ile ve `{permission}` izniyle kullanabilir +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network modül, ana bilgisayarın sunmadığı `{from}.{name}` öğesini içe aktarıyor: bir eklenti yalnızca docs/plugins.md dosyasında listelenen `env.vc_*` işlevlerini (eklenti API düzeyi 2) içe aktarabilir; WASI, dosya veya ağ kullanamaz @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` değeri {_1}; bu ana bilgisayar 1 ile {API_LEVEL} arasındaki eklenti API düzeylerini destekler @msg `grant` must be a list of permission names `grant` izin adlarından oluşan bir liste olmalıdır @msg `grant` names an unknown permission `{shown}` `grant` bilinmeyen bir izin adlandırıyor: `{shown}` diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 661c6baf4..c91061845 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5459,6 +5459,9 @@ axis Y Axis Вісь Y Install “{name}”, which asks for these permissions? Встановити «{name}», якому потрібні ці дозволи? ID: {id} ID: {id} Version: {version} Версія: {version} + Install “{name}” in place of the plug-in with the same ID? Встановити «{name}» замість модуля з тим самим ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Цей файл не є модулем, установленим раніше з цим ID. Він отримає налаштування й кеш, збережені для цього ID. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» відрізняється від модуля з ID {_1} і отримав би збережені для нього налаштування й кеш: встановіть його з `replace: true`, коли користувач погодиться File SHA-256: {hash} SHA-256 файлу: {hash} Read the objects it is given Читати передані йому об’єкти Change, add and delete objects in the document Змінювати, додавати й видаляти об’єкти в документі @@ -5466,10 +5469,13 @@ axis Y Axis Вісь Y Change how the objects its effect is applied to are drawn Змінювати те, як малюються об’єкти, до яких застосовано його ефект Write the document as a file in its own format Записувати документ у файл власного формату Show its progress, see Cancel and write log lines Показувати свій поступ, помічати скасування і записувати рядки журналу -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `progress` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API} + Keep settings of its own Зберігати власні налаштування + Keep a cache of its own, which you can clear Зберігати власний кеш, який можна очистити + Clear Plug-in Cache Очистити кеш модуля +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `{permission}` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль імпортує `env.{name}` з іншим типом, ніж у хоста: у хоста тип {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль імпортує `env.{name}`, який модуль може використовувати лише на рівні API модулів {PROGRESS_API} (`api` {PROGRESS_API}) і з дозволом `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль імпортує `{from}.{name}`, який хост не надає: модуль може імпортувати лише `env.vc_progress`, `env.vc_cancelled` і `env.vc_log` (рівень API модулів 2), без WASI, файлів і мережі +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` модуль імпортує `env.{name}`, який модуль може використовувати лише на рівні API модулів {PROGRESS_API} (`api` {PROGRESS_API}) і з дозволом `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network модуль імпортує `{from}.{name}`, який хост не надає: модуль може імпортувати лише функції `env.vc_*`, перелічені в docs/plugins.md (рівень API модулів 2), без WASI, файлів і мережі @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` дорівнює {_1}; цей хост підтримує рівні API модулів від 1 до {API_LEVEL} @msg `grant` must be a list of permission names `grant` має бути списком назв дозволів @msg `grant` names an unknown permission `{shown}` `grant` називає невідомий дозвіл `{shown}` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index dd79005c1..3c97189d6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5460,6 +5460,9 @@ axis Y Axis Trục Y Install “{name}”, which asks for these permissions? Cài đặt “{name}”, plug-in yêu cầu các quyền này? ID: {id} ID: {id} Version: {version} Phiên bản: {version} + Install “{name}” in place of the plug-in with the same ID? Cài đặt “{name}” thay cho plug-in có cùng ID? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tệp này không phải là plug-in đã cài đặt trước đây với ID này. Tệp sẽ nhận cài đặt và bộ nhớ đệm được lưu cho ID đó. +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” là mô-đun khác với plug-in có ID {_1} và sẽ nhận cài đặt và bộ nhớ đệm được lưu cho plug-in đó: hãy cài đặt với `replace: true` khi người dùng đồng ý File SHA-256: {hash} SHA-256 của tệp: {hash} Read the objects it is given Đọc các đối tượng được trao cho nó Change, add and delete objects in the document Thay đổi, thêm và xóa đối tượng trong tài liệu @@ -5467,10 +5470,13 @@ axis Y Axis Trục Y Change how the objects its effect is applied to are drawn Thay đổi cách vẽ các đối tượng được áp dụng hiệu ứng của nó Write the document as a file in its own format Ghi tài liệu thành tệp theo định dạng riêng Show its progress, see Cancel and write log lines Hiển thị tiến độ, nhận biết yêu cầu hủy và ghi các dòng nhật ký -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `progress` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API} + Keep settings of its own Lưu cài đặt riêng + Keep a cache of its own, which you can clear Lưu bộ nhớ đệm riêng mà bạn có thể xóa + Clear Plug-in Cache Xóa bộ nhớ đệm của plug-in +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `{permission}` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} mô-đun nhập `env.{name}` với kiểu khác với kiểu của máy chủ: kiểu của máy chủ là {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` mô-đun nhập `env.{name}`, thứ mà plug-in chỉ được dùng ở API plug-in cấp {PROGRESS_API} (`api` {PROGRESS_API}) và với quyền `progress` -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network mô-đun nhập `{from}.{name}`, thứ mà máy chủ không cung cấp: plug-in chỉ được nhập `env.vc_progress`, `env.vc_cancelled` và `env.vc_log` (API plug-in cấp 2), không có WASI, tệp hay mạng +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` mô-đun nhập `env.{name}`, thứ mà plug-in chỉ được dùng ở API plug-in cấp {PROGRESS_API} (`api` {PROGRESS_API}) và với quyền `{permission}` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network mô-đun nhập `{from}.{name}`, thứ mà máy chủ không cung cấp: plug-in chỉ được nhập các hàm `env.vc_*` mà docs/plugins.md liệt kê (API plug-in cấp 2), không có WASI, tệp hay mạng @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` là {_1}; máy chủ này hỗ trợ các mức API plug-in từ 1 đến {API_LEVEL} @msg `grant` must be a list of permission names `grant` phải là danh sách tên quyền @msg `grant` names an unknown permission `{shown}` `grant` nêu một quyền không xác định `{shown}` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fc8fa22f5..3ac228eee 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5701,6 +5701,9 @@ axis Y Axis Y 轴 Install “{name}”, which asks for these permissions? 要安装需要这些权限的“{name}”吗? ID: {id} ID:{id} Version: {version} 版本:{version} + Install “{name}” in place of the plug-in with the same ID? 要安装“{name}”来替换具有相同 ID 的插件吗? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此文件不是之前以此 ID 安装的插件。它将获得为该 ID 保存的设置和缓存。 +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”与 ID 为 {_1} 的插件是不同的模块,并会获得为其保存的设置和缓存:请在用户同意后使用 `replace: true` 安装 File SHA-256: {hash} 文件 SHA-256:{hash} Read the objects it is given 读取交给它的对象 Change, add and delete objects in the document 更改、添加和删除文档中的对象 @@ -5708,10 +5711,13 @@ axis Y Axis Y 轴 Change how the objects its effect is applied to are drawn 更改应用了其效果的对象的绘制方式 Write the document as a file in its own format 将文档写为其自有格式的文件 Show its progress, see Cancel and write log lines 显示进度、检测取消请求并写入日志行 -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `progress` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API} + Keep settings of its own 保存自己的设置 + Keep a cache of its own, which you can clear 保存自己的缓存,您可以清除它 + Clear Plug-in Cache 清除增效工具缓存 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `{permission}` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} 模块以与宿主不同的类型导入 `env.{name}`:宿主的类型为 {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模块导入了 `env.{name}`,增效工具只有在增效工具 API 级别 {PROGRESS_API}(`api` {PROGRESS_API})且具有权限 `progress` 时才能使用它 -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模块导入了宿主不提供的 `{from}.{name}`:增效工具只能导入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效工具 API 级别 2),不能使用 WASI、文件或网络 +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` 模块导入了 `env.{name}`,增效工具只有在增效工具 API 级别 {PROGRESS_API}(`api` {PROGRESS_API})且具有权限 `{permission}` 时才能使用它 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network 模块导入了宿主不提供的 `{from}.{name}`:增效工具只能导入 docs/plugins.md 列出的 `env.vc_*` 函数(增效工具 API 级别 2),不能使用 WASI、文件或网络 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 为 {_1};此主机支持的增效工具 API 级别为 1 到 {API_LEVEL} @msg `grant` must be a list of permission names `grant` 必须是权限名称的列表 @msg `grant` names an unknown permission `{shown}` `grant` 包含未知权限 `{shown}` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 03e07a814..3a18628cb 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5497,6 +5497,9 @@ axis Y Axis Y 軸 Install “{name}”, which asks for these permissions? 要安裝需要這些權限的「{name}」嗎? ID: {id} ID:{id} Version: {version} 版本:{version} + Install “{name}” in place of the plug-in with the same ID? 要安裝「{name}」來取代具有相同 ID 的增效模組嗎? + This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此檔案不是先前以此 ID 安裝的增效模組。它會取得為該 ID 保存的設定和快取。 +@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」與 ID 為 {_1} 的增效模組是不同的模組,並會取得為其保存的設定和快取:請在使用者同意後以 `replace: true` 安裝 File SHA-256: {hash} 檔案 SHA-256:{hash} Read the objects it is given 讀取交給它的物件 Change, add and delete objects in the document 變更、新增和刪除文件中的物件 @@ -5504,10 +5507,13 @@ axis Y Axis Y 軸 Change how the objects its effect is applied to are drawn 變更套用其效果的物件的繪製方式 Write the document as a file in its own format 將文件寫成其自有格式的檔案 Show its progress, see Cancel and write log lines 顯示進度、偵測取消要求並寫入記錄行 -@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `progress` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API} + Keep settings of its own 保存自己的設定 + Keep a cache of its own, which you can clear 保存自己的快取,您可以清除它 + Clear Plug-in Cache 清除增效模組快取 +@msg permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `{permission}` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API} @msg the module imports `env.{name}` with another type than the host's: it is {signature} 模組以與主機不同的類型匯入 `env.{name}`:主機的類型為 {signature} -@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模組匯入了 `env.{name}`,增效模組只有在增效模組 API 層級 {PROGRESS_API}(`api` {PROGRESS_API})且具有權限 `progress` 時才能使用它 -@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模組匯入了主機未提供的 `{from}.{name}`:增效模組只能匯入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效模組 API 層級 2),不能使用 WASI、檔案或網路 +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}` 模組匯入了 `env.{name}`,增效模組只有在增效模組 API 層級 {PROGRESS_API}(`api` {PROGRESS_API})且具有權限 `{permission}` 時才能使用它 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network 模組匯入了主機未提供的 `{from}.{name}`:增效模組只能匯入 docs/plugins.md 列出的 `env.vc_*` 函式(增效模組 API 層級 2),不能使用 WASI、檔案或網路 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 為 {_1};此主機支援的增效模組 API 層級為 1 到 {API_LEVEL} @msg `grant` must be a list of permission names `grant` 必須是權限名稱的清單 @msg `grant` names an unknown permission `{shown}` `grant` 包含未知權限 `{shown}` diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 4f507fe30..0d2ab4df7 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -175,6 +175,8 @@ fn permission_words(name: &str) -> String { Some(permissions::Permission::EffectRender) => tl!("Change how the objects its effect is applied to are drawn"), Some(permissions::Permission::ExportWrite) => tl!("Write the document as a file in its own format"), Some(permissions::Permission::Progress) => tl!("Show its progress, see Cancel and write log lines"), + Some(permissions::Permission::Settings) => tl!("Keep settings of its own"), + Some(permissions::Permission::Cache) => tl!("Keep a cache of its own, which you can clear"), None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD), }; words.to_string() @@ -183,15 +185,18 @@ fn permission_words(name: &str) -> String { /// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` /// with `name`) → `plugin.install`'s result. A plug-in that lists permissions the user hasn't /// granted it yet (`grant`) asks them first, showing its name, id, version, file hash and what each -/// permission lets it do, and so does a filter that asks for a heavy budget the user hasn't allowed -/// yet (`allowHeavy`); OK installs it if the module still has the hash the prompt showed, Cancel -/// installs nothing → `{pending}` while it asks. Every +/// permission lets it do, so does a filter that asks for a heavy budget the user hasn't allowed +/// yet (`allowHeavy`), and so does a module that would take the place of another one with its id +/// and get its settings and cache (`replace`); OK installs it if the module still has the hash the +/// prompt showed, Cancel installs nothing → `{pending}` while it asks. Every /// text from the manifest is shown with its hidden characters escaped and its length capped /// ([`permissions::display_text`]). pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { let heavy_allowed = module.get("allowHeavy").and_then(Value::as_bool) == Some(true); let granted = module.get("grant").is_some_and(|g| !g.is_null()); - if !heavy_allowed || !granted { + // `replace: false` asks for no replacing, so only a module without `replace` asks. + let replace_given = module.get("replace").is_some_and(|r| !r.is_null()); + if !heavy_allowed || !granted || !replace_given { let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); // What OK installs: the same module, with what the user has agreed to so far, pinned to @@ -234,6 +239,19 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result Vec { /// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash /// and each permission in plain words, with hidden characters from the manifest escaped. Cancel -/// installs nothing, OK installs it; installing it again with the same permissions doesn't ask, and -/// one that asks for another does. +/// installs nothing, OK installs it; another module with its id and the same permissions asks only +/// whether to take the installed one's place, and one that asks for another permission asks again. #[test] fn installing_a_plugin_that_lists_permissions_asks_first() { let mut app = app(); @@ -165,9 +165,24 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert!(app.ui.dialog.is_none()); assert!(vectorcraft_plugins::registry::get(id).is_some()); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(hash)); - // Again with the same permissions: installed without asking. + // Another module with the same permissions asks for none of them again, but asks before it + // takes the installed one's place, showing its id and file hash. let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.str("message"), "Install “Tidy 2” in place of the plug-in with the same ID?"); + let again_hash = vectorcraft_plugins::permissions::sha256_hex(&again); + assert_eq!( + d.str("detail"), + format!( + "ID: {id}\nFile SHA-256: {again_hash}\n\nThis file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID." + ) + ); + dialogs::cancel(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed"); app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none(), "the same permissions don't ask again"); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); // One that asks for another permission asks again. diff --git a/docs/mcp.md b/docs/mcp.md index 112dfa9bc..86fe106f7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6368,13 +6368,25 @@ runs it where it is called, so the call may take minutes; `notifications/cancell A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns -an error naming the permissions needed. The grant is kept with the module's hash; a module -with the same id that lists no new permission installs without `grant`, and `plugin.remove` forgets the grant. A +an error naming the permissions needed. The grant is kept with the module's hash; without a `Plug-ins` folder, a +module with the same id that lists no new permission installs without `grant`. A grant kept in a `Plug-ins` folder +covers only the module with that hash, so another module under the id needs `grant` again. `plugin.remove` forgets the +grant. A filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log -under its id. A module that imports anything else, or imports these without `api` 2 and `progress`, is refused by -`plugin.install` and `plugin.inspect` with an error naming the import. +under its id. One that lists `settings` or `cache` keeps settings or a cache of its own in the `Plug-ins` folder +(VectorCraft's own caps: 256 settings and 1 MiB, 512 MiB of cache): `plugin.list` and `plugin.info` give the cache's +size as `cacheBytes`, `plugin.clearCache {id?}` empties the cache of one plug-in or of all of them and returns +`{cleared: [id], bytesFreed}`, replacing a plug-in keeps both, and `plugin.remove` deletes both (also for an id that +isn't installed but still has them). A module with other bytes than the plug-in installed with its id, or, with none +installed, one whose id has settings or a cache in the +`Plug-ins` folder (`needsReplace: true` in its `plugin.inspect` entry), installs only with `replace: true`: ask the +user first, since it takes that plug-in's place and gets its settings and cache. Without it, `plugin.install` returns +an error naming the plug-in. The same module installs again without `replace`, and `replace: false` refuses a module +whose id is installed. A module that imports +anything else, or imports one of these without `api` 2 and the import's permission, is refused by `plugin.install` and +`plugin.inspect` with an error naming the import and the permission. `plugin.install` rejects a `replace` or `allowHeavy` value that is not a boolean, a `grant` that is not a list of permission names, and a `hash` that is not the module's SHA-256. `prefs.set` returns an error and changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty @@ -6415,6 +6427,8 @@ Disable and Continue can't save the plug-in it turns off, this launch skips it a {"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}} {"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}} {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}} +{"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/tiles-2.wasm","replace":true}}} +{"name":"run_command","arguments":{"command":"plugin.clearCache","params":{"id":"org.example.tiles"}}} ``` ## Colour adjustments diff --git a/docs/plugins.md b/docs/plugins.md index 66866efcd..6ebf84e68 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -23,12 +23,13 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) | | `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, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | 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). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | -| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). | -| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | 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). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | +| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`) and `needsReplace` (true when `plugin.install` needs `replace: true`). | +| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder with its [settings and cache](#settings-and-cache). | +| `plugin.clearCache` | `{"id"?}` | Deletes every entry in the [cache](#settings-and-cache) of plug-in `id`, or of every installed plug-in; settings stay. Returns `{"cleared": [id], "bytesFreed"}`. | | `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). | Live effects are applied like any effect: `effect.apply {"effect": "plugin.", "params": {…}}`, edited with @@ -67,7 +68,7 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Limit | Default | On violation | |---|---|---| -| Host imports | none at API level 1; at level 2 only the three [host imports](#host-imports-api-level-2): no WASI, file system, network, clock or randomness | module rejected at install, naming the import | +| Host imports | none at API level 1; at level 2 only the [host imports](#host-imports-api-level-2): no WASI, file system, network, clock or randomness | module rejected at install, naming the import | | Module size | 32 MiB | rejected | | Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | | Output | 64 MiB of JSON | `Limit("output size")` | @@ -116,14 +117,17 @@ capability model proposed for PhotoCraft plug-ins | `effect.render` | effect | Change how the objects its effect is applied to are drawn. | | `export.write` | exporter | Write the document as a file in its own format. | | `progress` | all | Show its progress, see Cancel and write log lines through the [host imports](#host-imports-api-level-2). Needs `api` 2. | +| `settings` | all | Keep settings of its own between runs and sessions ([settings and cache](#settings-and-cache)). Needs `api` 2. | +| `cache` | all | Keep a cache of its own, which the user can clear ([settings and cache](#settings-and-cache)). Needs `api` 2. | - A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and `export.write`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. A filter whose list is empty may do none of these, and also installs without a prompt. - An effect or an exporter that lists permissions lists both of its implied ones. A filter may list any of its three. - Any kind may add `progress`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply - to the kind, more than 16 names, `progress` with an `api` below 2, or one of the names kept for a later plug-in API + Any kind may add `progress`, `settings` and `cache`, which no kind implies. An unknown name, a name listed twice, a + name that doesn't apply to the kind, more than 16 names, `progress`, `settings` or `cache` with an `api` below 2, or + one of the names kept for a later plug-in API (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. - **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of the file and each permission in plain words, and install it once the user clicks OK, if the file still has the @@ -132,14 +136,22 @@ capability model proposed for PhotoCraft plug-ins `plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. +- **Replacing asks first.** When the file is another module than the plug-in installed with its id, or, with none + installed, its id has settings and a cache in the `Plug-ins` folder, Install Plug-in… and File › Open show its id + and SHA-256 and ask whether to install it in place of that plug-in, which gives it the settings and cache kept for + the id. `plugin.install` refuses such a module, naming it, unless `replace` is `true`, which an agent passes once + the user agreed (`plugin.inspect`'s `needsReplace` tells it beforehand). The Additional Plug-ins Folder and + `plugin.reload` do not ask. - The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the - `Plug-ins` folder. Installing a module with the same id that lists no new permission needs no new approval; one that - lists more asks again. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a + `Plug-ins` folder. Without a `Plug-ins` folder, installing a module with the same id that lists no new permission + needs no new approval; one that lists more asks again. A grant kept in a `Plug-ins` folder covers only the module + with the recorded hash: another module under the id needs `grant` again, even when it lists the same permissions, + and does not get the id's settings and cache until then. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, - a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload` + a stored plug-in whose permissions are not granted to that module is skipped. The Additional Plug-ins Folder and `plugin.reload` install only plug-ins whose permissions were granted, and report the others. - The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the @@ -148,7 +160,8 @@ capability model proposed for PhotoCraft plug-ins imports](#host-imports-api-level-2). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`. - Parameter declarations are unchanged by permissions (`params`, below). -Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed +Every run gets a **fresh instance**, so no state survives between calls except what a plug-in writes to its +[settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). @@ -182,9 +195,10 @@ module's memory. ### Host imports (API level 2) -A plug-in whose manifest gives `"api": 2` and lists the `progress` [permission](#permissions) may import these +A plug-in whose manifest gives `"api": 2` and lists the [permission](#permissions) an import needs may import these functions from module `env` (the module a Rust or C toolchain gives `extern` functions by default). `vc_abi_version` -still returns `1`. +still returns `1`. `vc_progress`, `vc_cancelled` and `vc_log` need `progress`; the `vc_settings_*` imports need +`settings` and the `vc_cache_*` imports need `cache` ([settings and cache](#settings-and-cache)). | Import | Signature | Meaning | |---|---|---| @@ -193,8 +207,8 @@ still returns `1`. | `vc_log` | `(level, ptr, len) -> i32` | Writes the UTF-8 text at `ptr` as a log line of `level` (0 error, 1 warning, 2 information, 3 debug). Returns `0` when written, `1` when the run's log budget is spent and the line was dropped, `-1` when the text doesn't lie inside the module's memory, `-2` when it is not UTF-8, `-3` for another level. | - The host checks the imports when the module is installed: an import it doesn't offer, one with another signature, - and one the manifest doesn't allow (an `api` below 2, or no `progress` permission) refuse the module, and the error - names the import. + and one the manifest doesn't allow (an `api` below 2, or without the import's permission) refuse the module, and the + error names the import and the permission. - A filter's or an exporter's progress is shown as a share: a heavy filter's progress window and the export's progress window draw a bar with a percentage, and Cancel there makes `vc_cancelled` return `1`. A plug-in that checks it can stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that @@ -208,6 +222,53 @@ still returns `1`. text, and the host reads at most 1 MiB of log text in a run; past any of these, one "log truncated" line is written and the rest are dropped. +### Settings and cache + +A plug-in with the `settings` permission keeps named values between runs and sessions, and one with `cache` keeps +named entries that the user can clear (#2191). It names each value by a key and never by a path. Each import takes +the key as `(key, key_len)`, the UTF-8 bytes at `key` in its memory: + +| Import | Signature | Meaning | +|---|---|---| +| `vc_settings_get` | `(key, key_len, out, out_cap) -> i32` | The setting's length in bytes. When it fits in the `out_cap` bytes at `out`, its bytes are copied there; otherwise nothing is copied. | +| `vc_settings_set` | `(key, key_len, value, value_len) -> i32` | Sets the setting to the `value_len` bytes at `value`. Returns `0`. | +| `vc_settings_delete` | `(key, key_len) -> i32` | Removes the setting. Returns `0`. | +| `vc_cache_read` | `(key, key_len, out, out_cap) -> i32` | As `vc_settings_get`, for the cache entry. | +| `vc_cache_write` | `(key, key_len, value, value_len) -> i32` | As `vc_settings_set`, for the cache entry. | +| `vc_cache_clear` | `(key, key_len) -> i32` | Removes the cache entry; with `key_len` 0, every entry. Returns `0`. | + +A call that fails returns `-1` when the key has no value, `-2` when a key, value or buffer doesn't lie inside the +module's memory, `-3` when the key is not a valid key, `-4` when the write would pass a cap (nothing changes), `-5` +when there is nowhere to keep anything, `-6` when reading or writing the file failed, and `-7` when the run has used +its 10,000 storage calls or its 1 GiB of storage reads and writes. Each call counts the bytes it reads and writes: a +settings call the whole settings file it reads, and a set or delete also the whole file it writes; a cache call the +entry it reads or writes. A read counts them even when the value doesn't fit and nothing is copied. A call that would +pass the 1 GiB returns `-7` before it reads or writes anything. + +- A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, + `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, in any case). Keys differ in case: `tile` and `Tile` are two. +- These caps are VectorCraft's own values: at most 256 settings of at most 64 KiB each, in a settings file of at most + 1 MiB; cache entries of at most 64 MiB each, at most 4,096 entries and 512 MiB per plug-in. The cache has no + automatic eviction: a write past a cap fails with `-4`, and the plug-in or the user clears entries. A cache write + also fails with `-4` when the `cache` folder holds more than 8,192 items; clearing the cache removes every file in + it. +- The desktop app and `vectorcraft-cli` keep them in a folder named from the id under `storage/` in the `Plug-ins` + folder (ids that differ only in case get two folders): `settings.json` and a + `cache` folder with one file per entry, named by the host. A settings file that is too large or doesn't parse is + moved to `settings.unreadable.json`, replacing an earlier one there, and the settings start empty. A link in that + folder is refused (`-5`). Writes go to a temporary file that then replaces the old one; a temporary file a failed + write leaves in the `cache` folder does not count toward the caps, and clearing the cache removes it. +- A plug-in reaches only the storage of its own id. Replacing it with another module of the same id + (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a run of the plug-in still going gets `-5` from every storage call after that. + `plugin.remove` first renames the folder (adding `.removed` to its name). When that fails, `plugin.remove` fails + and the plug-in stays installed with its storage as it was. Files that can't be deleted after the rename stay in the + `.removed` folder, where no plug-in reaches them, and the next removal of a plug-in with that id deletes them. + `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept for that id, when there is + any. + `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and + `plugin.info` give the cache's size as `cacheBytes`. +- Without a `Plug-ins` folder (the web build, and embedders that attach none) the storage calls return `-5`. + ### Manifest ```json diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c83136333..070814f77 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1536,6 +1536,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. +- **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.