diff --git a/Cargo.lock b/Cargo.lock index 6f92cb310..79778322f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6415,6 +6415,7 @@ version = "0.21.0" dependencies = [ "serde", "serde_json", + "sha2", "thiserror 2.0.21", "vectorcraft-color", "vectorcraft-doc", diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 8d1bb0408..565eded7f 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -13,6 +13,12 @@ //! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`), //! which loading a folder doesn't ask, so folders skip it; the app runs it in the background with //! Cancel ([`FilterRun`]). +//! +//! A plug-in whose manifest lists permissions installs once the user grants them +//! (`plugin.install {grant}`, recorded with the module's hash and kept in the plug-in store), and +//! a filter does only what they allow: without `doc.read` it is given no objects, without +//! `doc.write` an output that changes the document is refused, and without `state` it neither +//! reads nor writes the data it keeps with the document. use std::sync::{Arc, Mutex}; @@ -22,6 +28,8 @@ use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registr use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; +use vectorcraft_plugins::permissions::{self, Grant, Permission}; + /// What a plug-in error looks like to the user. A run the caller cancelled changed nothing. fn err(e: vectorcraft_plugins::Error) -> EngineError { match e { @@ -37,6 +45,7 @@ pub fn describe(p: &Plugin) -> Value { "id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description, "params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), "size": p.size(), "source": p.source(), + "permissions": permissions::names(&m.permissions()), "hash": p.hash(), }); match m.kind { Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)), @@ -122,15 +131,17 @@ impl FilterRun { } // Bad parameters fail before anything runs. let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); + let may = m.permissions(); let st = s.doc()?; let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); - let inputs = objects::filter_targets(&st.doc, &selected); + // Without `doc.read` a filter is given no objects: it can only add new ones. + let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; let parent = st.insertion_parent(); let bounds = st.doc.bounds_of(&inputs, false); let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first()); let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]); // What the plug-in keeps with the document, as it last wrote it. - let data = st.doc.plugin_data(&m.id).cloned().unwrap_or(Value::Null); + let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null); params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); let input = objects::encode_input(&st.doc, &inputs); let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; @@ -175,13 +186,23 @@ impl FilterRun { } /// Applies the plug-in's `output` to the active document as one undo step: its objects, and what - /// it keeps with the document from now on. + /// it keeps with the document from now on. An output its permissions don't allow is refused, + /// and the document stays as it was. fn finish(self, s: &mut Session, output: &[u8]) -> Result { let m = self.plugin.manifest(); + let may = m.permissions(); let out = objects::decode_output(output).map_err(err)?; + if out.document_data.is_some() && !may.contains(&Permission::State) { + return Err(not_granted(&m.name, Permission::State)); + } + let may_write = may.contains(&Permission::DocWrite); let (inputs, parent) = (self.inputs, self.parent); let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| { + let before = (!may_write).then(|| d.layers.clone()); let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?; + if before.is_some_and(|b| b != d.layers) { + return Err(not_granted(&m.name, Permission::DocWrite)); + } if let Some(data) = out.document_data { d.set_plugin_data(&m.id, data); } @@ -192,6 +213,12 @@ impl FilterRun { } } +/// The error of a filter `name` whose output needs `permission`, which it wasn't granted. +fn not_granted(name: &str, permission: Permission) -> EngineError { + let (name, p) = (permissions::display_text(name, permissions::MAX_SHOWN_NAME), permission.name()); + err(vectorcraft_plugins::Error::Output(format!("“{name}” wasn't granted `{p}`, which its output needs: nothing was changed"))) +} + /// `plugin.run`: runs filter `id` on `ids` (else the selected paths and compound paths) with its /// parameters and `_context` (what it keeps with the document included), and applies its output as /// one undo step ([`objects::apply_output`]; effect records as [`admit_effects`] admits them), with @@ -283,13 +310,37 @@ fn load_given(c: &str, p: &Value) -> Result<(Plugin, Vec)> { } } +/// The permissions `grant` lists in command `c`'s params: `None` when it is left out (or null). +/// Anything but a list of permission names is an error. +fn grant_param(c: &str, p: &Value) -> Result>> { + let Some(v) = p.get("grant").filter(|v| !v.is_null()) else { + return Ok(None); + }; + let names = v.as_array().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?; + let mut out = Vec::with_capacity(names.len()); + for n in names { + let name = n.as_str().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?; + let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD); + out.push(Permission::parse(name).ok_or_else(|| bad(c, format!("`grant` names an unknown permission `{shown}`")))?); + } + Ok(Some(out)) +} + /// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless -/// `allowHeavy` says the user agreed, keeps it for the next start and installs it → its -/// `plugin.list` entry. +/// `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. 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 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. + if let Some(h) = p.get("hash").filter(|v| !v.is_null()) + && h.as_str() != Some(plugin.hash()) + { + 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() { return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); } @@ -304,14 +355,33 @@ 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. + 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))) { + let name = permissions::display_text(&m.name, permissions::MAX_SHOWN_NAME); + let asked = permissions::names(&wanted).join(", "); + return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees"))); + } + if m.asks_permissions() { + let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; + registry::grant(g.clone()); + s.store_grant(&m.id, Some(g)); + } // Installed plug-ins stay installed after a restart (#2541). s.store_plugin(plugin.id(), &bytes); Ok(describe(®istry::install(plugin))) } -/// `plugin.inspect`: what installing the module would install, without installing it. -fn inspect(_: &mut Session, p: &Value) -> Result { - load_given("plugin.inspect", p).map(|(plugin, _)| describe(&plugin)) +/// `plugin.inspect`: what installing the module would install, without installing it, and whether +/// that needs the user to grant its permissions first (`needsApproval`). +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 + }) } /// The plug-in in the `.wasm` file `path`, and its module. @@ -328,35 +398,40 @@ fn load_path(_: &str) -> Result<(Plugin, Vec)> { Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into())) } -/// The plug-in folder last loaded from the preferences, and what happened. -static FOLDER: Mutex> = Mutex::new(None); +/// The plug-in folder last loaded, the plug-in store whose grants it was loaded with, and what +/// happened. +static FOLDER: Mutex, Value)>> = Mutex::new(None); -/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and has changed since -/// the last load. Runs whenever preferences are applied (startup, `prefs.set`). Native only. +/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and it, or the +/// session's plug-in store, has changed since the last load. Runs whenever preferences are applied +/// (startup, `prefs.set`). Native only. pub(crate) fn sync_prefs(s: &Session) { let folder = s.prefs.plugins_folder.trim(); if folder.is_empty() { return; } let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner()); - if last.as_ref().is_some_and(|(f, _)| f == folder) { + if last.as_ref().is_some_and(|(f, store, _)| f == folder && *store == s.plugin_dir) { return; } - *last = Some((folder.to_string(), load_folder(folder))); + *last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder))); } +/// Loads the plug-ins in `folder`, those that list permissions only when `s` has them granted +/// ([`Session::plugin_approved`], with the store's grants read once). #[cfg(not(target_arch = "wasm32"))] -fn load_folder(folder: &str) -> Value { +fn load_folder(s: &Session, folder: &str) -> Value { if let Err(e) = crate::file_access::check_read(folder) { return json!({"folder": folder, "error": e}); } // A file in it may be a link to one elsewhere. - match registry::load_folder_where(std::path::Path::new(folder), |f| match f.to_str() { + let allow = |f: &std::path::Path| match f.to_str() { Some(f) => crate::file_access::check_read(f), // A lossy name would check another file than the one loaded. None if crate::file_access::current().is_none() => Ok(()), None => Err(format!("automation path rejected: {} isn't valid text", f.display())), - }) { + }; + match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { Ok(r) => { json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::>()}) } @@ -365,7 +440,7 @@ fn load_folder(folder: &str) -> Value { } #[cfg(target_arch = "wasm32")] -fn load_folder(folder: &str) -> Value { +fn load_folder(_: &Session, folder: &str) -> Value { json!({"folder": folder, "error": "plug-in folders are not available on the web"}) } @@ -378,16 +453,16 @@ fn reload(s: &mut Session, p: &Value) -> Result { if folder.is_empty() { return Err(bad(C, "no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder")); } - let report = load_folder(&folder); + let report = load_folder(s, &folder); if let Some(e) = report.get("error").and_then(Value::as_str) { return Err(EngineError::Other(e.to_string())); } - *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone())); + *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, s.plugin_dir.clone(), report.clone())); Ok(report) } fn list(_: &mut Session, _: &Value) -> Result { - let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone()); + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::>(), "folder": folder})) } @@ -402,6 +477,9 @@ 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`"))?; + // 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()))) } } @@ -415,7 +493,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}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {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), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), @@ -442,7 +520,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)} 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: 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", always, install ), @@ -451,7 +529,7 @@ 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", + "{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)", always, inspect ), @@ -461,7 +539,7 @@ pub fn specs() -> Vec { "Reload Plug-ins", ["Object", "Plug-ins"], None, - "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped) → {folder, loaded: [id], failed: [{file, error}]}", + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}", always, reload ), diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 0e85391fc..71d2baf46 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -14,11 +14,14 @@ //! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named //! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it. //! Attaching the folder installs them again. The Additional Plug-ins Folder preference is not -//! copied: it is loaded from where it is. +//! 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. //! //! 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. +use std::collections::BTreeMap; use std::io::Read; use std::path::{Path, PathBuf}; @@ -41,6 +44,10 @@ const PROFILE_EXT: &str = "icc"; pub const MAX_PLUGIN_STORE: u64 = 512 << 20; /// The extension of a stored plug-in. const PLUGIN_EXT: &str = "wasm"; +/// The file in the plug-in store that holds the permissions the user granted. +const GRANTS_FILE: &str = "grants.json"; +/// The largest grants file read. +const MAX_GRANTS_FILE: u64 = 1 << 20; /// 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 @@ -217,8 +224,10 @@ impl Session { } /// Keep the plug-ins this session installs in `dir`, and install the ones already there (a - /// module that doesn't load is skipped with a log record). + /// module that doesn't load, and one that lists permissions the grants there don't grant it, + /// is skipped with a log record). pub fn attach_plugin_store(&mut self, dir: PathBuf) { + let grants = stored_grants(&dir); let limits = vectorcraft_plugins::Limits::default(); let mut left = MAX_PLUGIN_STORE; for path in list_files(&dir, PLUGIN_EXT) { @@ -231,6 +240,18 @@ impl Session { vectorcraft_plugins::Plugin::load(&bytes, limits.clone()).map_err(|e| e.to_string()) }); match loaded { + // Only this store's grants count: one given elsewhere in the process doesn't let a + // 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())) { + Some(g) => { + vectorcraft_plugins::registry::grant(g.clone()); + 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()), + } + } Ok(plugin) => { vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } @@ -240,6 +261,26 @@ impl Session { self.plugin_dir = Some(dir); } + /// 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`]). + pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool { + self.plugin_approver()(plugin) + } + + /// [`Session::plugin_approved`] for many plug-ins: reads the store's grants once, when it is + /// made, and checks each plug-in against them. + pub(crate) fn plugin_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> { + let grants = self.plugin_dir.as_deref().map(stored_grants); + 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())), + _ => vectorcraft_plugins::registry::approved(plugin), + } + } + } + /// Write the module `bytes` of the plug-in `id` to the store. pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) { if let Some(dir) = &self.plugin_dir { @@ -247,6 +288,25 @@ impl Session { } } + /// Record in the store what the user granted plug-in `id` (`None`: nothing), leaving the store's + /// other grants as they are; with none left, remove the file. + pub(crate) fn store_grant(&self, id: &str, grant: Option) { + let Some(dir) = &self.plugin_dir else { return }; + let mut grants = stored_grants(dir); + let changed = match grant { + Some(g) => grants.insert(id.to_string(), g.clone()).as_ref() != Some(&g), + None => grants.remove(id).is_some(), + }; + if !changed { + return; + } + if grants.is_empty() { + take(dir, GRANTS_FILE); + } else if let Ok(bytes) = serde_json::to_vec_pretty(&grants.into_values().collect::>()) { + put(dir, GRANTS_FILE, &bytes); + } + } + /// Remove the plug-in `id` from the store. pub(crate) fn unstore_plugin(&self, id: &str) { if let Some(dir) = &self.plugin_dir { @@ -255,6 +315,25 @@ impl Session { } } +/// The grants kept in the plug-in store `dir`, by plug-in id: none when there is no grants file, +/// and none, with a log record, when it is too large or doesn't parse. Grants for an invalid id are +/// left out. +fn stored_grants(dir: &Path) -> BTreeMap { + let path = dir.join(GRANTS_FILE); + if !path.is_file() { + return BTreeMap::new(); + } + let grants = read_capped(&path, MAX_GRANTS_FILE) + .and_then(|bytes| serde_json::from_slice::>(&bytes).map_err(|e| e.to_string())); + match grants { + Ok(grants) => grants.into_iter().filter(|g| vectorcraft_plugins::manifest::valid_id(&g.id)).map(|g| (g.id.clone(), g)).collect(), + Err(e) => { + log::warn!("skipping the stored plug-in grants {}: {e}", path.display()); + BTreeMap::new() + } + } +} + /// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a /// built-in preset, at most [`MAX_STORE_FILES`]. fn load_presets(dir: &Path) -> Vec { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 194c56178..36694ef87 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -523,6 +523,230 @@ fn invalid_install_flags_are_refused() { assert!(vectorcraft_plugins::registry::get(light).is_none()); } +/// A test filter `id` (named "Test `id`") whose manifest lists `permissions` (a JSON list) and that +/// returns `output`. +fn asking(id: &str, permissions: &str, output: &Value) -> Vec { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"2.1","kind":"filter","permissions":{permissions}}}"#); + wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap() +} + +/// 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 +/// 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() { + let mut s = session(); + let id = "test.engine.permissions.install"; + let empty = json!({"objects": []}); + let data = |perms: &str| vectorcraft_format::base64_encode(&asking(id, perms, &empty)); + let reads = data(r#"["doc.read"]"#); + let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": reads})); + assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read"]))); + let hash = vectorcraft_plugins::permissions::sha256_hex(&asking(id, r#"["doc.read"]"#, &empty)); + assert_eq!(inspected["hash"], hash); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing"); + // Refused without a grant that holds every permission it lists, naming them. + for (grant, says) in [ + (Value::Null, "asks for the permissions doc.read"), + (json!([]), "asks for the permissions doc.read"), + (json!(["doc.write"]), "asks for the permissions doc.read"), + (json!(["doc.read", "net.fetch"]), "unknown permission `net.fetch`"), + (json!("doc.read"), "`grant` must be a list"), + (json!([1]), "`grant` must be a list"), + ] { + let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": grant})).unwrap_err().to_string(); + assert!(e.contains(says), "grant {grant}: {e}"); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with grant {grant}"); + } + // `hash` pins the module the user was shown. + for wrong in [json!("0".repeat(64)), json!(1)] { + let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": ["doc.read"], "hash": wrong})).unwrap_err().to_string(); + assert!(e.contains("SHA-256 `hash`"), "hash {wrong}: {e}"); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with hash {wrong}"); + } + let installed = run(&mut s, "plugin.install", json!({"dataBase64": reads, "grant": ["doc.read"], "hash": hash})); + assert_eq!(installed["permissions"], json!(["doc.read"])); + let g = vectorcraft_plugins::registry::granted(id).unwrap(); + assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..])); + // Replaced with another module that lists the same permissions: no new grant needed, and the + // 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)})); + 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(); + 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"]})); + 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})); + assert!(vectorcraft_plugins::registry::granted(id).is_none()); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsApproval"], true); + // A plug-in without `permissions` installs as before, with its kind's. + let plain = install(&mut s, DESATURATE); + assert_eq!(plain["permissions"], json!(["doc.read", "doc.write", "state"])); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false); +} + +/// The host enforces what a filter was granted: without `doc.write` an output that changes the +/// document is refused and nothing changes, without `doc.read` it is given no objects, and without +/// `state` it is given no data kept with the document and may not write any. +#[test] +fn a_filter_does_only_what_its_permissions_allow() { + let mut s = session(); + 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})); + }; + // Denied `doc.write`: renaming the object is refused, and the document and history stay. + let reader = "test.engine.permissions.reader"; + grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0, "name": "Changed"}]})), json!(["doc.read"])); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = filter(&mut s, reader).unwrap_err().to_string(); + assert!(e.contains("wasn't granted `doc.write`"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + // Deleting it (leaving it out of the output) and adding objects are changes too. + for out in [json!({"objects": []}), json!({"objects": [{"id": id.0}, {"path": triangle()}]})] { + grant(&mut s, &asking(reader, r#"["doc.read"]"#, &out), json!(["doc.read"])); + assert!(filter(&mut s, reader).is_err(), "{out}"); + assert_eq!(s.doc().unwrap().doc, before, "{out}"); + } + // Returning the object unchanged changes nothing, so it may. + grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0}]})), json!(["doc.read"])); + filter(&mut s, reader).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, before.layers); + // Granted `doc.write` alone: it is given no objects, so its triangle is added beside the + // rectangle rather than replacing it. + let writer = "test.engine.permissions.writer"; + let manifest = format!(r#"{{"id":"{writer}","name":"W","kind":"filter","permissions":["doc.write"]}}"#); + let added = json!({"objects": [{"path": triangle(), "name": "Given nothing"}]}); + let src = tpl::when_given(&manifest, tpl::Given::Input, r#"{"objects":[]}"#, &added.to_string()); + grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.write"])); + let made = filter(&mut s, writer).unwrap(); + let new = NodeId(made["ids"][0].as_u64().unwrap()); + assert_eq!(node(&s, new).name.as_deref(), Some("Given nothing")); + assert!(s.doc().unwrap().doc.node(id).is_some(), "the rectangle it wasn't given stays"); + // Without `state`: kept data is not handed over, and writing some is refused. + run( + &mut s, + "plugin.install", + json!({"dataBase64": vectorcraft_format::base64_encode(&asking("test.engine.permissions.keeper", "null", &json!({"objects": [{"id": id.0}], "documentData": {"pen": 1}})))}), + ); + filter(&mut s, "test.engine.permissions.keeper").unwrap(); + let stateless = "test.engine.permissions.keeper"; + let manifest = format!(r#"{{"id":"{stateless}","name":"K","kind":"filter","permissions":["doc.read","doc.write"]}}"#); + let src = + tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":null"#, &json!({"objects": [{"id": id.0, "name": "No data"}]}).to_string()); + grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.read", "doc.write"])); + filter(&mut s, stateless).unwrap(); + assert_eq!(node(&s, id).name.as_deref(), Some("No data"), "given no kept data"); + let before = s.doc().unwrap().doc.clone(); + grant( + &mut s, + &asking(stateless, r#"["doc.read","doc.write"]"#, &json!({"objects": [{"id": id.0}], "documentData": {"pen": 2}})), + json!(["doc.read", "doc.write"]), + ); + let e = filter(&mut s, stateless).unwrap_err().to_string(); + assert!(e.contains("wasn't granted `state`"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1}))); +} + +/// What the user granted is kept in the plug-in store with the module: the next session installs +/// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is +/// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder +/// until it is granted on its own. +#[test] +fn granted_permissions_are_kept_and_folders_skip_the_rest() { + let folder = crate::tests_links::Folder::new("plugin-store-grants"); + let id = "test.engine.permissions.stored"; + let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + assert!(folder.0.join("grants.json").is_file()); + 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_some(), "installed again from the store, as granted"); + // Only a store's own grants count: the same module in another store without a grant is + // skipped, and a grant given elsewhere is not written to that store. + let other = crate::tests_links::Folder::new("plugin-store-grants-other"); + std::fs::write(other.0.join("copy.wasm"), &wasm).unwrap(); + vectorcraft_plugins::registry::remove(id); + let mut elsewhere = session(); + elsewhere.attach_plugin_store(other.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "granted in another store only"); + let e = elsewhere.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap_err().to_string(); + assert!(e.contains("`grant`"), "a grant kept in another store doesn't count here: {e}"); + let lone = "test.engine.permissions.lone"; + let lone_wasm = asking(lone, r#"["doc.read"]"#, &json!({"objects": []})); + run(&mut elsewhere, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&lone_wasm), "grant": ["doc.read"]})); + let kept = std::fs::read_to_string(other.0.join("grants.json")).unwrap(); + assert!(kept.contains(lone) && !kept.contains(id), "{kept}"); + run(&mut elsewhere, "plugin.remove", json!({"id": lone})); + assert!(!other.0.join("grants.json").exists(), "the last grant removed takes the file with it"); + // Without the grant it is skipped, and the file stays. + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + std::fs::remove_file(folder.0.join("grants.json")).unwrap(); + let mut third = session(); + third.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "nobody granted it"); + assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "the module stays in the store"); + + // The Additional Plug-ins Folder skips it with a note until it is granted. + let dir = crate::tests_links::Folder::new("plugin-folder-grants"); + std::fs::write(dir.0.join("asks.wasm"), &wasm).unwrap(); + let r = run(&mut third, "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.contains("doc.read"), "{why}"); + run(&mut third, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([id])); + run(&mut third, "plugin.remove", json!({"id": id})); + // With a store attached, only that store's grants load it from the folder: a grant given in a + // session without a store doesn't count. + let mut storeless = session(); + run(&mut storeless, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + vectorcraft_plugins::registry::remove(id); + assert!(vectorcraft_plugins::registry::granted(id).is_some(), "granted in the process"); + let empty = crate::tests_links::Folder::new("plugin-store-grants-empty"); + let mut scoped = session(); + scoped.attach_plugin_store(empty.0.clone()); + let r = run(&mut scoped, "plugin.reload", json!({"path": dir.0.display().to_string()})); + vectorcraft_plugins::registry::revoke(id); + assert_eq!(r["loaded"], json!([]), "a grant outside this store doesn't load it"); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval"), "{why}"); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "not installed"); + // The preference's folder is loaded again for a session whose store holds other grants. + let pref_dir = crate::tests_links::Folder::new("plugin-folder-pref-grants"); + std::fs::write(pref_dir.0.join("asks.wasm"), &wasm).unwrap(); + let pref = json!({"key": "pluginsFolder", "value": pref_dir.0.display().to_string()}); + run(&mut scoped, "prefs.set", pref.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "nothing granted in this store"); + let granting = crate::tests_links::Folder::new("plugin-store-grants-pref"); + let mut granted = session(); + granted.attach_plugin_store(granting.0.clone()); + run(&mut granted, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + run(&mut granted, "prefs.set", pref); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "loaded under this store's grant"); + run(&mut granted, "plugin.remove", json!({"id": id})); +} + /// The user agreed once: a heavy filter kept in the plug-in store is installed again at the next /// start without asking, with its budget. #[test] diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index ac982a20e..1f29627e0 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -268,4 +268,56 @@ proptest! { } renders(&s, "after mutated modules")?; } + + /// Manifests with hostile `permissions`, `api` and names (unknown, reserved and repeated + /// permissions, junk of every type, control and bidirectional override characters) parse to + /// an error or to a manifest whose permissions fit its kind, and installing one with a junk + /// `grant` ends as an error or an install, never a panic. A prompt never shows a hidden + /// character from the name. + #[test] + fn hostile_manifest_permissions_never_crash( + kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + names in prop::collection::vec( + prop_oneof![ + prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "progress", "ui.panel", ""]), + Just("doc.read\u{202E}"), + Just("\u{0007}"), + ], + 0..6, + ), + permissions in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), + api in prop_oneof![3 => Just(json!(1)), 1 => arb_junk()], + name in prop_oneof![3 => "[a-z\u{202E}\u{2066}\u{200F} ]{1,64}", 1 => "[a-z\u{202E}\u{2066}\u{0007}\u{200F} ]{1,80}"], + grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]))], + ) { + let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api}); + if kind == "exporter" { + m["extensions"] = json!(["fz"]); + } + m["permissions"] = match permissions { + Some(Value::Null) | None => json!(names), + Some(junk) => junk, + }; + let text = m.to_string(); + let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); + let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; + if let Ok(man) = &parsed { + let implied = vectorcraft_plugins::permissions::Permission::implied(man.kind); + prop_assert!(man.permissions().iter().all(|p| implied.contains(p)), "{}", text); + let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown); + prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + } + let mut s = rich_session(); + let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&module); + let r = catch_quiet(|| { + let _ = s.execute("plugin.inspect", &json!({"dataBase64": data})); + s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant})).is_ok() + }); + let installed = r.map_err(|msg| TestCaseError::fail(format!("panicked installing {text}: {msg}")))?; + prop_assert!(!installed || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text); + vectorcraft_plugins::registry::remove("fuzz.permissions"); + vectorcraft_plugins::registry::revoke("fuzz.permissions"); + } } diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 1fb4bd77a..af9212c90 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -14,6 +14,8 @@ vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# The SHA-256 of a module, which the install prompt shows and a grant records. +sha2 = { version = "0.10", default-features = false } thiserror = { workspace = true } wasmi = { workspace = true } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 061ef506c..9e6f9e934 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -21,7 +21,8 @@ //! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole //! document in a file format of its own. The process-wide [`registry`] holds the installed //! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show -//! them well: labels, units, groups, help and conditions ([`dialog`]). +//! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is +//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. #![forbid(unsafe_code)] pub mod dialog; @@ -29,6 +30,7 @@ pub mod effect; pub mod export; pub mod manifest; pub mod objects; +pub mod permissions; pub mod registry; mod runtime; pub mod wat; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 18ffeb335..75a76cdbb 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::dialog::ParamUi; +use crate::permissions::{self, API_LEVEL, Permission}; use crate::{Error, Result}; /// Longest manifest the host reads. @@ -154,6 +155,12 @@ fn text_max() -> usize { fn list_max() -> usize { 16 } +fn first_api() -> u32 { + 1 +} +fn is_first_api(api: &u32) -> bool { + *api == 1 +} /// One declared parameter: what it holds, and how dialogs show it. In the manifest both are keys of /// the same object: `{"type": "number", "min": 0, "max": 10, "default": 1, "unit": "mm"}`. @@ -236,6 +243,13 @@ pub struct Manifest { pub description: String, #[serde(default)] pub author: String, + /// The plug-in API level it was built for, 1 to [`API_LEVEL`] (1 when left out). + #[serde(default = "first_api", skip_serializing_if = "is_first_api")] + pub api: u32, + /// The permissions it asks for ([`crate::permissions`]), which the user approves when it is + /// installed. Left out, it gets the ones its kind implies ([`Permission::implied`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option>, /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] pub params: Vec<(String, Param)>, @@ -269,6 +283,8 @@ impl Manifest { kind: Kind::Filter, description: String::new(), author: String::new(), + api: 1, + permissions: None, params: Vec::new(), extensions: Vec::new(), budget: Budget::Light, @@ -288,6 +304,12 @@ impl Manifest { if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 { return Err(Error::Manifest("`version`, `description` or `author` is too long".into())); } + if !(1..=API_LEVEL).contains(&self.api) { + 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)?; + } self.validate_extensions()?; match (self.budget, self.kind) { (Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {} @@ -363,6 +385,19 @@ impl Manifest { self.params.iter().map(|(k, p)| (k.clone(), p.spec.default_value())).collect() } + /// What the plug-in may do: the permissions it lists, else the ones its kind implies. + pub fn permissions(&self) -> Vec { + match &self.permissions { + Some(names) => permissions::validate(self.kind, names).unwrap_or_default(), + None => Permission::implied(self.kind).to_vec(), + } + } + + /// Does installing it need the user's approval: does it list permissions? + pub fn asks_permissions(&self) -> bool { + self.permissions.as_ref().is_some_and(|p| !p.is_empty()) + } + /// Validates user `params` against the schema: missing keys take their defaults, numbers are /// clamped into range (whole numbers rounded), unknown keys are dropped; a value of the wrong /// type is an error. @@ -595,6 +630,39 @@ mod tests { } } + /// A manifest lists the permissions it asks for, checked against its kind; without the key it + /// gets the ones its kind implies, as plug-ins did before manifests listed them, and needs no + /// approval. `api` is 1 when left out, and a later level is refused. + #[test] + fn permissions_and_api_level() { + let listed = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["doc.write","doc.read"]}"#).unwrap(); + assert_eq!(listed.permissions(), [Permission::DocRead, Permission::DocWrite]); + assert!(listed.asks_permissions()); + assert_eq!(serde_json::to_value(&listed).unwrap()["permissions"], json!(["doc.write", "doc.read"])); + assert!(serde_json::to_value(&listed).unwrap().get("api").is_none(), "level 1 isn't written back"); + let plain = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap(); + assert_eq!((plain.api, plain.asks_permissions()), (1, false)); + assert_eq!(plain.permissions(), [Permission::DocRead, Permission::DocWrite, Permission::State]); + assert!(serde_json::to_value(&plain).unwrap().get("permissions").is_none()); + let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect"}"#).unwrap(); + assert_eq!(effect.permissions(), [Permission::DocRead, Permission::EffectRender]); + let exporter = Manifest::parse(br#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"]}"#).unwrap(); + assert_eq!(exporter.permissions(), [Permission::DocRead, Permission::ExportWrite]); + assert!(!Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","permissions":[]}"#).unwrap().asks_permissions()); + 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"), + (r#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read"]}"#, "effect.render"), + (r#"{"id":"a","name":"x","kind":"filter","permissions":"doc.read"}"#, "manifest"), + (r#"{"id":"a","name":"x","kind":"filter","api":2}"#, "`api` is 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"), + (r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + } + #[test] fn strings_and_number_lists_resolve_like_the_other_types() { let m = Manifest::parse( diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs new file mode 100644 index 000000000..1d6b0664e --- /dev/null +++ b/crates/plugins/src/permissions.rs @@ -0,0 +1,266 @@ +//! What a plug-in may do: the permissions its manifest lists (`permissions`), which the user +//! approves when it is installed ([`Grant`], kept in [`crate::registry`]), and which the host +//! enforces on every run. +//! +//! `doc.read`, `doc.write`, `state` and the reserved `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. + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Kind, Result}; + +/// The plug-in API level this host implements. A manifest's `api` (1 when left out) may be at most +/// this. +pub const API_LEVEL: u32 = 1; + +/// Most permissions a manifest may list. +pub const MAX_PERMISSIONS: usize = 16; + +/// Longest plug-in name a prompt shows, in characters. +pub const MAX_SHOWN_NAME: usize = 64; + +/// Longest version or id a prompt shows, in characters. +pub const MAX_SHOWN_FIELD: usize = 64; + +/// Names kept for host calls a later plug-in API adds. This host grants none of them, so a +/// manifest that lists one is refused when it is installed. +pub const RESERVED: &[&str] = &["progress", "ui.panel", "ui.tool"]; + +/// One thing a plug-in may do. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Permission { + /// Read the objects the host hands it: a filter's selected paths, the object a live effect is + /// on, the document an exporter writes. + #[serde(rename = "doc.read")] + DocRead, + /// Change, add and delete objects (a filter's output), as one undo step. + #[serde(rename = "doc.write")] + DocWrite, + /// Read and write the data it keeps with the document (a filter's `documentData`). + #[serde(rename = "state")] + State, + /// Change how the objects its live effect is applied to are drawn. + #[serde(rename = "effect.render")] + EffectRender, + /// Write the document as a file in its own format (an exporter). + #[serde(rename = "export.write")] + ExportWrite, +} + +/// Every permission this host grants, in the order prompts list them. +pub const ALL: [Permission; 5] = [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite]; + +impl Permission { + /// Its name in a manifest and in `plugin.install`'s `grant`. + pub fn name(self) -> &'static str { + match self { + Permission::DocRead => "doc.read", + Permission::DocWrite => "doc.write", + Permission::State => "state", + Permission::EffectRender => "effect.render", + Permission::ExportWrite => "export.write", + } + } + + /// The permission named `name`. + pub fn parse(name: &str) -> Option { + ALL.into_iter().find(|p| p.name() == name) + } + + /// What it lets a plug-in do, in plain words (English; the app shows its own translation). + pub fn describe(self) -> &'static str { + match self { + Permission::DocRead => "Read the objects it is given", + Permission::DocWrite => "Change, add and delete objects in the document", + Permission::State => "Keep data of its own with the document", + 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", + } + } + + /// The permissions a plug-in of `kind` may list, which are also the ones a manifest without + /// `permissions` gets. + pub fn implied(kind: Kind) -> &'static [Permission] { + match kind { + Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State], + Kind::Effect => &[Permission::DocRead, Permission::EffectRender], + Kind::Exporter => &[Permission::DocRead, Permission::ExportWrite], + } + } + + /// The permissions a plug-in of `kind` can't work without, which its list must hold: a live + /// effect and an exporter are always handed objects, and only draw or write. A filter may + /// leave any of its permissions out. + fn required(kind: Kind) -> &'static [Permission] { + match kind { + Kind::Filter => &[], + Kind::Effect | Kind::Exporter => Permission::implied(kind), + } + } +} + +/// Checks the `permissions` a manifest of `kind` lists → them, in the order [`ALL`] has. A name +/// that is not a permission, a reserved one, one that doesn't apply to the kind, one listed twice +/// and a list without what the kind needs are errors that name it. +pub(crate) fn validate(kind: Kind, names: &[String]) -> Result> { + if names.len() > MAX_PERMISSIONS { + return Err(Error::Manifest(format!("at most {MAX_PERMISSIONS} `permissions`"))); + } + let mut out: Vec = Vec::with_capacity(names.len()); + for name in names { + let shown = display_text(name, MAX_SHOWN_FIELD); + let Some(p) = Permission::parse(name) else { + if RESERVED.contains(&name.as_str()) { + return Err(Error::Manifest(format!("permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it"))); + } + return Err(Error::Manifest(format!("unknown permission `{shown}`"))); + }; + if !Permission::implied(kind).contains(&p) { + return Err(Error::Manifest(format!("permission `{shown}` doesn't apply to a plug-in of kind `{}`", kind.name()))); + } + if out.contains(&p) { + return Err(Error::Manifest(format!("permission `{shown}` is listed twice"))); + } + out.push(p); + } + if let Some(missing) = Permission::required(kind).iter().find(|p| !out.contains(p)) { + return Err(Error::Manifest(format!("a plug-in of kind `{}` needs the permission `{}`", kind.name(), missing.name()))); + } + out.sort(); + Ok(out) +} + +/// The names of `permissions`, as a manifest and `grant` list them. +pub fn names(permissions: &[Permission]) -> Vec<&'static str> { + permissions.iter().map(|p| p.name()).collect() +} + +/// Is `c` a character that changes the direction text is laid out in, or a line or paragraph +/// separator, which a prompt must not let a manifest string use to disguise itself? +fn is_bidi_or_separator(c: char) -> bool { + matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{2028}' | '\u{2029}') +} + +/// `s` as a prompt shows a string from a manifest: control, bidirectional formatting and line +/// separator characters written as `\u{202E}` escapes, and at most `max` characters, the last of +/// them `…` when it was cut. +pub fn display_text(s: &str, max: usize) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + // Characters written so far, and the byte length of the longest prefix of whole pieces + // shorter than `max` characters, where the ellipsis goes when the text is cut. + let mut count = 0; + let mut cut = 0; + for c in s.chars() { + let start = out.len(); + let n = if c.is_control() || is_bidi_or_separator(c) { + let _ = write!(out, "\\u{{{:04X}}}", c as u32); + out.len() - start + } else { + out.push(c); + 1 + }; + if count + n > max { + out.truncate(cut); + out.push('…'); + return out; + } + count += n; + if count < max { + cut = out.len(); + } + } + out +} + +/// The SHA-256 of `bytes`, in lower-case hexadecimal. +pub fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() +} + +/// What the user approved for a plug-in: its id, the SHA-256 of the module they approved +/// ([`sha256_hex`]) and the permissions it was granted. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Grant { + pub id: String, + pub hash: String, + pub permissions: Vec, +} + +impl Grant { + /// Does it grant every one of `wanted`? + pub fn covers(&self, wanted: &[Permission]) -> bool { + wanted.iter().all(|w| self.permissions.contains(w)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_round_trip_and_serialize_as_names() { + for p in ALL { + assert_eq!(Permission::parse(p.name()), Some(p)); + assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.name())); + } + assert_eq!(Permission::parse("document.read"), None); + assert!(RESERVED.iter().all(|r| Permission::parse(r).is_none()), "reserved names are not granted"); + } + + #[test] + fn lists_are_checked_against_the_kind() { + let list = |names: &[&str]| names.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(validate(Kind::Filter, &list(&["doc.write", "doc.read"])).unwrap(), [Permission::DocRead, Permission::DocWrite]); + assert_eq!(validate(Kind::Filter, &[]).unwrap(), []); + assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok()); + assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok()); + for (kind, names, says) in [ + (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), + (Kind::Filter, &["progress"], "`progress` is reserved"), + (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`"), + (Kind::Exporter, &["export.write"], "needs the permission `doc.read`"), + ] { + let e = validate(kind, &list(names)).unwrap_err().to_string(); + assert!(e.contains(says), "{names:?}: {e}"); + } + let many = list(&["doc.read"; MAX_PERMISSIONS + 1]); + assert!(validate(Kind::Filter, &many).unwrap_err().to_string().contains("at most")); + } + + /// Control, bidirectional override and separator characters show as escapes, so a name can't + /// reverse or hide the text after it; long text is cut with an ellipsis. + #[test] + fn prompt_text_escapes_hidden_characters_and_is_capped() { + assert_eq!(display_text("Tidy\u{202E}fdp.exe", 64), "Tidy\\u{202E}fdp.exe"); + assert_eq!(display_text("a\u{0007}b\nc\u{2066}d\u{200F}", 64), "a\\u{0007}b\\u{000A}c\\u{2066}d\\u{200F}"); + assert_eq!(display_text("plain", 64), "plain"); + assert_eq!(display_text("abcdef", 6), "abcdef"); + assert_eq!(display_text("abcdefg", 6), "abcde…"); + assert_eq!(display_text(&"x".repeat(500), MAX_SHOWN_NAME).chars().count(), MAX_SHOWN_NAME); + assert_eq!(display_text("ab\u{202E}", 6), "ab…", "an escape that doesn't fit is cut whole"); + assert_eq!(display_text("ab\u{0007}", 10), "ab\\u{0007}", "an escape that ends exactly at the cap is kept"); + let long = display_text(&"\u{202E}y".repeat(500_000), MAX_SHOWN_NAME); + assert_eq!(long.chars().count(), MAX_SHOWN_NAME); + assert!(long.starts_with("\\u{202E}y\\u{202E}y") && long.ends_with('…'), "{long}"); + } + + #[test] + fn hashes_are_sha256_in_hex() { + assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + } + + #[test] + fn a_grant_covers_its_permissions_and_no_more() { + let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] }; + assert!(g.covers(&[Permission::DocRead])); + assert!(g.covers(&[])); + assert!(!g.covers(&[Permission::State])); + assert!(!g.covers(&[Permission::DocRead, Permission::State])); + } +} diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index caf63eb54..0302ab6be 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -9,10 +9,41 @@ use std::sync::{Arc, Mutex, RwLock}; #[cfg(not(target_arch = "wasm32"))] use crate::Error; +use crate::permissions::Grant; 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()); + +/// Records what the user approved for a plug-in, replacing an earlier grant for its id. +pub fn grant(g: Grant) { + GRANTS.write().unwrap_or_else(|e| e.into_inner()).insert(g.id.clone(), g); +} + +/// What the user approved for plug-in `id`. +pub fn granted(id: &str) -> Option { + GRANTS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() +} + +/// Every grant, sorted by id. +pub fn grants() -> Vec { + GRANTS.read().unwrap_or_else(|e| e.into_inner()).values().cloned().collect() +} + +/// Forgets what the user approved for `id`; false if nothing was. +pub fn revoke(id: &str) -> bool { + GRANTS.write().unwrap_or_else(|e| e.into_inner()).remove(id).is_some() +} + +/// May `plugin` be installed without asking the user: does it list no permissions, or did the user +/// already grant its id every permission it lists? A module with other bytes but no new permission +/// needs no new approval. +pub fn approved(plugin: &Plugin) -> bool { + let m = plugin.manifest(); + !m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions())) +} /// Installs `plugin`, replacing one with the same id. pub fn install(mut plugin: Plugin) -> Arc { @@ -93,16 +124,21 @@ pub struct FolderReport { /// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is /// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]), -/// which installs only once the user agrees. +/// which installs only once the user agrees, and one that lists permissions the user has not +/// granted its id ([`approved`]). #[cfg(not(target_arch = "wasm32"))] pub fn load_folder(dir: &std::path::Path) -> Result { - load_folder_where(dir, |_| Ok(())) + load_folder_where(dir, |_| Ok(()), approved) } -/// [`load_folder`], loading only the files `allow` accepts; the others are reported as failed -/// with its reason. +/// [`load_folder`], loading only the files `allow` accepts, and of the plug-ins that list +/// permissions only those `approve` accepts; the others are reported as failed with the reason. #[cfg(not(target_arch = "wasm32"))] -pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>) -> Result { +pub fn load_folder_where( + dir: &std::path::Path, + allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>, + approve: impl Fn(&Plugin) -> bool, +) -> Result { let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?; let mut files: Vec = rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect(); @@ -119,6 +155,13 @@ pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path) Ok(p) if p.manifest().budget == crate::Budget::Heavy => { report.failed.push((f.display().to_string(), "asks for a heavy budget, which the user allows when installing it on its own".into())); } + Ok(p) if p.manifest().asks_permissions() && !approve(&p) => { + let asked = crate::permissions::names(&p.manifest().permissions()).join(", "); + report.failed.push(( + f.display().to_string(), + format!("needs approval: it asks for {asked}, which the user grants when installing it on its own"), + )); + } Ok(p) => report.loaded.push(install(p).id().to_string()), Err(e) => report.failed.push((f.display().to_string(), e.to_string())), } diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 3a72b70d7..f5666fb5b 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -85,6 +85,7 @@ pub struct Plugin { module: Module, limits: Limits, size: usize, + hash: String, source: Option, serial: u64, } @@ -174,7 +175,8 @@ impl Plugin { imp.name() ))); } - let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), source: None, serial: 0 }; + 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); let mut live = p.instantiate()?; let version: TypedFunc<(), i32> = live.func("vc_abi_version")?; @@ -214,6 +216,10 @@ impl Plugin { pub fn size(&self) -> usize { self.size } + /// The module's SHA-256, in lower-case hexadecimal. + pub fn hash(&self) -> &str { + &self.hash + } /// Where the module was loaded from, if it came from a file. pub fn source(&self) -> Option<&str> { self.source.as_deref() diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 8ab9cb9b8..f0f7cef6d 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -592,6 +592,8 @@ impl PluginIntField { kind: vectorcraft_plugins::Kind::Filter, description: String::new(), author: String::new(), + api: 1, + permissions: None, params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f6a8debdc..8eaa9a9d5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5672,6 +5672,16 @@ axis Y Axis Y ос The plug-in is computing… Приставката изчислява… Inspect Plug-in Проверка на приставка Cancel Plug-in Run Отказ от изпълнението на приставката +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Да се инсталира ли „{name}“, който иска тези права? + ID: {id} ID: {id} + Version: {version} Версия: {version} + File SHA-256: {hash} SHA-256 на файла: {hash} + Read the objects it is given Четене на обектите, които получава + Change, add and delete objects in the document Промяна, добавяне и изтриване на обекти в документа + Keep data of its own with the document Съхраняване на собствени данни в документа + Change how the objects its effect is applied to are drawn Промяна на начина, по който се рисуват обектите, към които е приложен ефектът му + Write the document as a file in its own format Записване на документа като файл в собствен формат # 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 a8db1ebea..4668b8e29 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5696,6 +5696,28 @@ axis Y Axis Y osa @msg a live effect runs while the document is drawn, so it can't be `heavy` živi efekat radi dok se dokument iscrtava, pa ne može biti `heavy` @msg an exporter always gets an export's budget: leave `budget` out izvoznik uvijek dobija budžet izvoza: izostavite `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” traži veliki budžet (svako pokretanje do {minutes} minuta i {times} puta više uobičajenih instrukcija): instalirajte ga s `allowHeavy: true` kada korisnik pristane +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instalirati „{name}”, koji traži ove dozvole? + ID: {id} ID: {id} + Version: {version} Verzija: {version} + 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 + Keep data of its own with the document Čuvati vlastite podatke uz dokument + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` dodatku vrste `{_1}` potrebna je dozvola `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` najviše {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` dozvola `{shown}` ne odnosi se na dodatak vrste `{_1}` +@msg permission `{shown}` is listed twice dozvola `{shown}` navedena je dvaput +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it dozvola `{shown}` rezervisana je za kasniji API dodataka; ovaj domaćin je ne dodjeljuje +@msg unknown permission `{shown}` nepoznata dozvola `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” traži dozvole {asked}: instalirajte ga s `grant` koji ih navodi kada korisnik pristane +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nije dobio `{p}`, što je potrebno njegovom izlazu: ništa nije promijenjeno +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nema SHA-256 naveden u `hash`: ništa nije instalirano New Set… Novi skup… New Set Novi skup Set Options… Opcije skupa… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3898a677f..9e2fc7837 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5798,6 +5798,28 @@ digits Malayalam Malaiàlam @msg {status} ({e}) {status} ({e}) @msg {tool} isn't in a toolbar group of several tools {tool} no forma part d’un grup de diverses eines a la barra d’eines @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): instal·la’l amb `allowHeavy: true` quan l’usuari hi estigui d’acord +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos? + ID: {id} ID: {id} + Version: {version} Versió: {version} + 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 + Keep data of its own with the document Desar dades pròpies amb el document + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un complement del tipus `{_1}` necessita el permís `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` com a màxim {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permís `{shown}` no s’aplica a un complement del tipus `{_1}` +@msg permission `{shown}` is listed twice el permís `{shown}` apareix dues vegades +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix +@msg unknown permission `{shown}` permís desconegut `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a «{name}» no se li ha concedit `{p}`, que la seva sortida necessita: no s’ha canviat res +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el mòdul no té el SHA-256 que indica `hash`: no s’ha instal·lat res @msg “{name}” is not a file «{name}» no és un fitxer axis Both Tots dos axis Horizontal Horitzontal diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index eff1d2d34..5975f088a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5788,6 +5788,28 @@ axis Y Axis osa Y @msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy` @msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): nainstalujte ho s `allowHeavy: true`, jakmile uživatel souhlasí +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění? + ID: {id} ID: {id} + Version: {version} Verze: {version} + 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 + Keep data of its own with the document Ukládat vlastní data s dokumentem + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` zásuvný modul druhu `{_1}` potřebuje oprávnění `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` nejvýše {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` oprávnění `{shown}` se nevztahuje na zásuvný modul druhu `{_1}` +@msg permission `{shown}` is listed twice oprávnění `{shown}` je uvedeno dvakrát +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje +@msg unknown permission `{shown}` neznámé oprávnění `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ nedostal `{p}`, které jeho výstup potřebuje: nic se nezměnilo +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nemá SHA-256 uvedený v `hash`: nic nebylo nainstalováno # Actions panel: action sets (#3712). New Set… Nová sada… New Set Nová sada diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index abfe581e7..1f4486ba4 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5687,6 +5687,16 @@ axis Y Axis Y-akse The plug-in is computing… Plug-in'en regner… Inspect Plug-in Undersøg plug-in Cancel Plug-in Run Annullér plug-in-kørsel +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installér »{name}«, som beder om disse rettigheder? + ID: {id} ID: {id} + Version: {version} Version: {version} + 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 + Keep data of its own with the document Gemme sine egne data med dokumentet + 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 # 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 4a4142d88..2c0c2717b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5370,6 +5370,28 @@ axis Y Axis Y-Achse @msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein @msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Installieren Sie es mit `allowHeavy: true`, sobald der Benutzer zustimmt +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt? + ID: {id} ID: {id} + Version: {version} Version: {version} + 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 + Keep data of its own with the document Eigene Daten mit dem Dokument speichern + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` ein Plug-in der Art `{_1}` braucht die Berechtigung `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` höchstens {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` die Berechtigung `{shown}` gilt nicht für ein Plug-in der Art `{_1}` +@msg permission `{shown}` is listed twice die Berechtigung `{shown}` ist doppelt aufgeführt +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht +@msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ wurde `{p}` nicht gewährt, das seine Ausgabe braucht: Es wurde nichts geändert +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed das Modul hat nicht den SHA-256-Wert aus `hash`: Es wurde nichts installiert # Actions panel: action sets (#3712). New Set… Neuer Satz… New Set Neuer Satz diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6b2a022d5..79f4b0cc0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5739,6 +5739,28 @@ axis Y Axis Άξονας Y @msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy` @msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): εγκαταστήστε το με `allowHeavy: true` μόλις συμφωνήσει ο χρήστης +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα; + ID: {id} Αναγνωριστικό: {id} + Version: {version} Έκδοση: {version} + File SHA-256: {hash} SHA-256 αρχείου: {hash} + Read the objects it is given Ανάγνωση των αντικειμένων που του δίνονται + Change, add and delete objects in the document Αλλαγή, προσθήκη και διαγραφή αντικειμένων στο έγγραφο + Keep data of its own with the document Αποθήκευση δικών του δεδομένων μαζί με το έγγραφο + Change how the objects its effect is applied to are drawn Αλλαγή του τρόπου σχεδίασης των αντικειμένων στα οποία εφαρμόζεται το εφέ του + Write the document as a file in its own format Εγγραφή του εγγράφου ως αρχείου σε δική του μορφή +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` ένα πρόσθετο τύπου `{_1}` χρειάζεται το δικαίωμα `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` το πολύ {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` το δικαίωμα `{shown}` δεν ισχύει για πρόσθετο τύπου `{_1}` +@msg permission `{shown}` is listed twice το δικαίωμα `{shown}` αναφέρεται δύο φορές +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί +@msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed στο «{name}» δεν παραχωρήθηκε το `{p}`, που χρειάζεται η έξοδός του: δεν άλλαξε τίποτα +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα # Actions panel: action sets (#3712). New Set… Νέο σύνολο… New Set Νέο σύνολο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6ccd50b46..420254d25 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5729,6 +5729,28 @@ axis Y Axis Eje Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): instálalo con `allowHeavy: true` cuando el usuario lo acepte +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos? + ID: {id} ID: {id} + Version: {version} Versión: {version} + 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 + Keep data of its own with the document Guardar datos propios con el documento + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un plugin de tipo `{_1}` necesita el permiso `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` como máximo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permiso `{shown}` no se aplica a un plugin de tipo `{_1}` +@msg permission `{shown}` is listed twice el permiso `{shown}` aparece dos veces +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede +@msg unknown permission `{shown}` permiso desconocido `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” no se le concedió `{p}`, que su resultado necesita: no se cambió nada +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el módulo no tiene el SHA-256 que indica `hash`: no se instaló nada # Actions panel: action sets (#3712). New Set… Nuevo conjunto… New Set Nuevo conjunto diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3cf8607c0..0cbec9246 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5818,6 +5818,28 @@ axis Y Axis Y-akseli @msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy` @msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): asenna se asetuksella `allowHeavy: true`, kun käyttäjä hyväksyy +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia? + ID: {id} Tunnus: {id} + Version: {version} Versio: {version} + 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 + Keep data of its own with the document Tallentaa omia tietojaan asiakirjaan + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` lajin `{_1}` laajennus tarvitsee käyttöoikeuden `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` enintään {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` käyttöoikeus `{shown}` ei koske lajin `{_1}` laajennusta +@msg permission `{shown}` is listed twice käyttöoikeus `{shown}` on lueteltu kahdesti +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä +@msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” ei saanut käyttöoikeutta `{p}`, jota sen tulos tarvitsee: mitään ei muutettu +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduulilla ei ole `hash`-kentän SHA-256-arvoa: mitään ei asennettu # Actions panel: action sets (#3712). New Set… Uusi sarja… New Set Uusi sarja diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 67a7cb29c..f87b0cde3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5783,6 +5783,28 @@ axis Y Axis Axe Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy` @msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? + ID: {id} ID : {id} + Version: {version} Version : {version} + 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 + Keep data of its own with the document Conserver ses propres données avec le document + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un module externe de type `{_1}` a besoin de l’autorisation `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` au plus {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorisation `{shown}` ne s’applique pas à un module externe de type `{_1}` +@msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas +@msg unknown permission `{shown}` autorisation inconnue `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé # Actions panel: action sets (#3712). New Set… Nouveau jeu… New Set Nouveau jeu diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e1af82a3a..a045da15b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5173,6 +5173,28 @@ @id pathfinder.add मिलाएँ @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): उपयोगकर्ता की सहमति मिलने पर इसे `allowHeavy: true` से इंस्टॉल करें @msg "{0}" is not a colour: write it as #rrggbb "{0}" एक रंग नहीं है: इसे #rrggbb के रूप में लिखें +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? "{name}" स्थापित करें, जो ये अनुमतियां मांगता है? + ID: {id} ID: {id} + Version: {version} संस्करण: {version} + File SHA-256: {hash} फ़ाइल SHA-256: {hash} + Read the objects it is given उसे दिए गए ऑब्जेक्ट्स पढ़ना + Change, add and delete objects in the document डॉक्यूमेंट में ऑब्जेक्ट्स बदलना, जोड़ना और हटाना + Keep data of its own with the document अपना डेटा डॉक्यूमेंट के साथ रखना + Change how the objects its effect is applied to are drawn जिन ऑब्जेक्ट्स पर उसका इफ़ेक्ट लगा है, उनके बनाए जाने का तरीका बदलना + Write the document as a file in its own format डॉक्यूमेंट को अपने फ़ॉर्मेट की फ़ाइल के रूप में लिखना +@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}` बताता है +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` प्रकार के प्लग-इन को `{_2}` अनुमति चाहिए +@msg at most {MAX_PERMISSIONS} `permissions` अधिकतम {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` अनुमति `{shown}` `{_1}` प्रकार के प्लग-इन पर लागू नहीं होती +@msg permission `{shown}` is listed twice अनुमति `{shown}` दो बार सूचीबद्ध है +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता +@msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” को `{p}` नहीं दी गई, जो उसके आउटपुट को चाहिए: कुछ नहीं बदला +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ @msg “{name}” is not a file "{name}" एक फ़ाइल नहीं है @msg ({note}) ({more}) ({note}) ({more}) @msg (fonts not copied: {fonts}) (फ़ॉन्ट कॉपी नहीं हुए: {fonts}) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 82f75da72..031eb3791 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5771,6 +5771,28 @@ axis Y Axis Y tengely @msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy` @msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): telepítsd az `allowHeavy: true` megadásával, miután a felhasználó beleegyezett +# Plug-in permissions (Install Plug-in…, plugin.install grant) + 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} + 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 + Keep data of its own with the document Saját adatokat tárolhat a dokumentummal + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` egy `{_1}` fajtájú bővítménynek szüksége van a(z) `{_2}` engedélyre +@msg at most {MAX_PERMISSIONS} `permissions` legfeljebb {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a(z) `{shown}` engedély nem vonatkozik `{_1}` fajtájú bővítményre +@msg permission `{shown}` is listed twice a(z) `{shown}` engedély kétszer szerepel +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg +@msg unknown permission `{shown}` ismeretlen engedély: `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a(z) „{name}” nem kapta meg a(z) `{p}` engedélyt, amelyre a kimenetének szüksége van: semmi sem változott +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed a modul SHA-256 értéke nem az, amit a `hash` megad: semmi sem lett telepítve # Actions panel: action sets (#3712). New Set… Új készlet… New Set Új készlet diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7230b6bfa..872f6b74e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5687,6 +5687,16 @@ axis Y Axis Sumbu Y The plug-in is computing… Plug-in sedang menghitung… Inspect Plug-in Periksa plug-in Cancel Plug-in Run Batalkan jalannya plug-in +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instal “{name}”, yang meminta izin berikut? + ID: {id} ID: {id} + Version: {version} Versi: {version} + 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 + Keep data of its own with the document Menyimpan datanya sendiri bersama dokumen + 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 # 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 723fa4ab0..e5ee7d472 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5404,6 +5404,28 @@ axis Y Axis Asse Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): installalo con `allowHeavy: true` quando l’utente acconsente +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installare “{name}”, che chiede queste autorizzazioni? + ID: {id} ID: {id} + Version: {version} Versione: {version} + 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 + Keep data of its own with the document Conservare dati propri con il documento + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un plug-in di tipo `{_1}` richiede l’autorizzazione `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` al massimo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorizzazione `{shown}` non si applica a un plug-in di tipo `{_1}` +@msg permission `{shown}` is listed twice l’autorizzazione `{shown}` è elencata due volte +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede +@msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” non è stata concessa `{p}`, che serve al suo risultato: non è stato modificato nulla +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed il modulo non ha lo SHA-256 indicato da `hash`: non è stato installato nulla # Actions panel: action sets (#3712). New Set… Nuovo set… New Set Nuovo set diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8bb101269..355da7a35 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5469,6 +5469,28 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません @msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`budget` は省略してください @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。ユーザーが同意したら `allowHeavy: true` を指定してインストールしてください +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? これらの権限を求める「{name}」をインストールしますか? + ID: {id} ID: {id} + Version: {version} バージョン: {version} + File SHA-256: {hash} ファイルの SHA-256: {hash} + Read the objects it is given 渡されたオブジェクトの読み取り + Change, add and delete objects in the document ドキュメント内のオブジェクトの変更、追加、削除 + Keep data of its own with the document 独自のデータをドキュメントと一緒に保存 + Change how the objects its effect is applied to are drawn 効果が適用されたオブジェクトの描画方法の変更 + Write the document as a file in its own format ドキュメントを独自形式のファイルとして書き出し +@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}` が含まれています +@msg a plug-in of kind `{_1}` needs the permission `{_2}` 種類 `{_1}` のプラグインには権限 `{_2}` が必要です +@msg at most {MAX_PERMISSIONS} `permissions` `permissions` は最大 {MAX_PERMISSIONS} 個です +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 権限 `{shown}` は種類 `{_1}` のプラグインには適用されません +@msg permission `{shown}` is listed twice 権限 `{shown}` が 2 回指定されています +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません +@msg unknown permission `{shown}` 不明な権限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」には出力に必要な `{p}` が付与されていません。何も変更されませんでした +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした # Actions panel: action sets (#3712). New Set… 新規セット… New Set 新規セット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 343b415a4..237b5586a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5711,6 +5711,28 @@ axis Y Axis Y ღერძი @msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy` @msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `allowHeavy: true` პარამეტრით +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? დავაყენოთ „{name}“, რომელიც ამ ნებართვებს ითხოვს? + ID: {id} ID: {id} + Version: {version} ვერსია: {version} + File SHA-256: {hash} ფაილის SHA-256: {hash} + Read the objects it is given მიღებული ობიექტების წაკითხვა + Change, add and delete objects in the document დოკუმენტში ობიექტების შეცვლა, დამატება და წაშლა + Keep data of its own with the document საკუთარი მონაცემების დოკუმენტთან ერთად შენახვა + Change how the objects its effect is applied to are drawn იმის შეცვლა, თუ როგორ იხატება ობიექტები, რომლებზეც მისი ეფექტია გამოყენებული + Write the document as a file in its own format დოკუმენტის ჩაწერა ფაილად საკუთარ ფორმატში +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` სახის პლაგინს სჭირდება ნებართვა `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` მაქსიმუმ {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` ნებართვა `{shown}` არ ეხება `{_1}` სახის პლაგინს +@msg permission `{shown}` is listed twice ნებართვა `{shown}` ორჯერაა ჩამოთვლილი +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს +@msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“-ს არ მიეცა `{p}`, რომელიც მის შედეგს სჭირდება: არაფერი შეცვლილა +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა # Actions panel: action sets (#3712). New Set… ახალი ნაკრები… New Set ახალი ნაკრები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f4cc45b82..5ca65c5f3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5365,6 +5365,28 @@ axis Y Axis Y축 @msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다 @msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `budget`은 생략하세요 @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 사용자가 동의하면 `allowHeavy: true`로 설치하세요 +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? 이 권한을 요청하는 “{name}”을(를) 설치하시겠습니까? + ID: {id} ID: {id} + Version: {version} 버전: {version} + File SHA-256: {hash} 파일 SHA-256: {hash} + Read the objects it is given 전달받은 개체 읽기 + Change, add and delete objects in the document 문서의 개체 변경, 추가 및 삭제 + Keep data of its own with the document 자체 데이터를 문서와 함께 보관 + Change how the objects its effect is applied to are drawn 효과가 적용된 개체가 그려지는 방식 변경 + Write the document as a file in its own format 문서를 자체 형식의 파일로 쓰기 +@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}`이(가) 있습니다 +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 종류의 플러그인에는 `{_2}` 권한이 필요합니다 +@msg at most {MAX_PERMISSIONS} `permissions` `permissions`는 최대 {MAX_PERMISSIONS}개입니다 +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` 권한은 `{_1}` 종류의 플러그인에 적용되지 않습니다 +@msg permission `{shown}` is listed twice `{shown}` 권한이 두 번 나열되어 있습니다 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다 +@msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”에 출력에 필요한 `{p}` 권한이 부여되지 않았습니다. 아무것도 변경되지 않았습니다 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 # Actions panel: action sets (#3712). New Set… 새 세트… New Set 새 세트 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2e8d6f34c..a1c721371 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5733,6 +5733,16 @@ axis Y Axis Y-as The plug-in is computing… De plug-in is aan het rekenen… Inspect Plug-in Plug-in inspecteren Cancel Plug-in Run Plug-in-run annuleren +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? „{name}” installeren, dat om deze machtigingen vraagt? + ID: {id} ID: {id} + Version: {version} Versie: {version} + 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 + Keep data of its own with the document Eigen gegevens bij het document bewaren + 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 # 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 c9e1256c3..f9c6ca1ee 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5641,6 +5641,28 @@ axis Y Axis Oś Y @msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy` @msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): zainstaluj wtyczkę z `allowHeavy: true`, gdy użytkownik się zgodzi +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Zainstalować „{name}”, wtyczkę, która prosi o te uprawnienia? + ID: {id} ID: {id} + Version: {version} Wersja: {version} + 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 + Keep data of its own with the document Przechowywać własne dane w dokumencie + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` wtyczka rodzaju `{_1}` potrzebuje uprawnienia `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` najwyżej {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` uprawnienie `{shown}` nie dotyczy wtyczki rodzaju `{_1}` +@msg permission `{shown}` is listed twice uprawnienie `{shown}` występuje dwa razy +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje +@msg unknown permission `{shown}` nieznane uprawnienie `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nie otrzymała `{p}`, którego potrzebuje jej wynik: nic nie zmieniono +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduł nie ma skrótu SHA-256 podanego w `hash`: nic nie zainstalowano # Actions panel: action sets (#3712). New Set… Nowy zestaw… New Set Nowy zestaw diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 062b7d88b..d22b6b4d1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5703,6 +5703,28 @@ axis Y Axis Eixo Y @msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): instale-o com `allowHeavy: true` depois que o usuário concordar +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instalar “{name}”, que pede estas permissões? + ID: {id} ID: {id} + Version: {version} Versão: {version} + 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 + Keep data of its own with the document Guardar dados próprios com o documento + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` um plug-in do tipo `{_1}` precisa da permissão `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` no máximo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a permissão `{shown}` não se aplica a um plug-in do tipo `{_1}` +@msg permission `{shown}` is listed twice a permissão `{shown}` aparece duas vezes +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede +@msg unknown permission `{shown}` permissão desconhecida `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” não recebeu `{p}`, de que sua saída precisa: nada foi alterado +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed o módulo não tem o SHA-256 indicado em `hash`: nada foi instalado # Actions panel: action sets (#3712). New Set… Novo Conjunto… New Set Novo Conjunto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a83d9edf3..695853915 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5399,6 +5399,28 @@ axis Y Axis Ось Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): установите его с `allowHeavy: true`, когда пользователь согласится +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Установить «{name}», которому нужны эти разрешения? + ID: {id} ID: {id} + Version: {version} Версия: {version} + File SHA-256: {hash} SHA-256 файла: {hash} + Read the objects it is given Читать переданные ему объекты + Change, add and delete objects in the document Изменять, добавлять и удалять объекты в документе + Keep data of its own with the document Хранить свои данные в документе + Change how the objects its effect is applied to are drawn Изменять то, как рисуются объекты, к которым применён его эффект + Write the document as a file in its own format Записывать документ в файл собственного формата +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю вида `{_1}` нужно разрешение `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` не более {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` разрешение `{shown}` не относится к модулю вида `{_1}` +@msg permission `{shown}` is listed twice разрешение `{shown}` указано дважды +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт +@msg unknown permission `{shown}` неизвестное разрешение `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не получил разрешение `{p}`, нужное для его результата: ничего не изменено +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено # Actions panel: action sets (#3712). New Set… Новый набор… New Set Новый набор diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 780d23bea..019067d99 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5811,6 +5811,28 @@ axis Y Axis Y-axel @msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy` @msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): installera det med `allowHeavy: true` när användaren har godkänt +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installera ”{name}”, som begär dessa behörigheter? + ID: {id} ID: {id} + Version: {version} Version: {version} + 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 + Keep data of its own with the document Spara egna data med dokumentet + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` ett plugin av typen `{_1}` behöver behörigheten `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` högst {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` behörigheten `{shown}` gäller inte ett plugin av typen `{_1}` +@msg permission `{shown}` is listed twice behörigheten `{shown}` står två gånger +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte +@msg unknown permission `{shown}` okänd behörighet `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulen har inte den SHA-256 som `hash` anger: ingenting installerades # Actions panel: action sets (#3712). New Set… Ny uppsättning… New Set Ny uppsättning diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 55a9cdc73..5c0091946 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5745,6 +5745,28 @@ axis Y Axis Y ekseni @msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz @msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): kullanıcı onayladıktan sonra `allowHeavy: true` ile yükleyin +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Bu izinleri isteyen “{name}” yüklensin mi? + ID: {id} Kimlik: {id} + Version: {version} Sürüm: {version} + 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 + Keep data of its own with the document Kendi verilerini belgeyle birlikte saklama + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` türündeki bir eklentinin `{_2}` iznine ihtiyacı vardır +@msg at most {MAX_PERMISSIONS} `permissions` en fazla {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` izni `{_1}` türündeki bir eklentiye uygulanmaz +@msg permission `{shown}` is listed twice `{shown}` izni iki kez listelenmiş +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez +@msg unknown permission `{shown}` bilinmeyen izin `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” öğesine çıktısının ihtiyaç duyduğu `{p}` izni verilmedi: hiçbir şey değiştirilmedi +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modül `hash` içinde verilen SHA-256 değerine sahip değil: hiçbir şey yüklenmedi # Actions panel: action sets (#3712). New Set… Yeni Set… New Set Yeni Set diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bbfc332f9..ae36d2e9e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5384,6 +5384,28 @@ axis Y Axis Вісь Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): встановіть його з `allowHeavy: true`, коли користувач погодиться +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Встановити «{name}», якому потрібні ці дозволи? + ID: {id} ID: {id} + Version: {version} Версія: {version} + File SHA-256: {hash} SHA-256 файлу: {hash} + Read the objects it is given Читати передані йому об’єкти + Change, add and delete objects in the document Змінювати, додавати й видаляти об’єкти в документі + Keep data of its own with the document Зберігати власні дані в документі + Change how the objects its effect is applied to are drawn Змінювати те, як малюються об’єкти, до яких застосовано його ефект + Write the document as a file in its own format Записувати документ у файл власного формату +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю виду `{_1}` потрібен дозвіл `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` щонайбільше {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` дозвіл `{shown}` не стосується модуля виду `{_1}` +@msg permission `{shown}` is listed twice дозвіл `{shown}` указано двічі +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає +@msg unknown permission `{shown}` невідомий дозвіл `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не отримав дозволу `{p}`, потрібного для його результату: нічого не змінено +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено # Actions panel: action sets (#3712). New Set… Новий набір… New Set Новий набір diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 47915fb17..5a83ec144 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5385,6 +5385,28 @@ axis Y Axis Trục Y @msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy` @msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): hãy cài đặt với `allowHeavy: true` khi người dùng đồng ý +# Plug-in permissions (Install Plug-in…, plugin.install grant) + 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} + 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 + Keep data of its own with the document Lưu dữ liệu riêng cùng tài liệu + 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 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` plug-in loại `{_1}` cần quyền `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` tối đa {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` quyền `{shown}` không áp dụng cho plug-in loại `{_1}` +@msg permission `{shown}` is listed twice quyền `{shown}` được liệt kê hai lần +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó +@msg unknown permission `{shown}` quyền không xác định `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” chưa được cấp `{p}`, quyền mà đầu ra của nó cần: không có gì thay đổi +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed mô-đun không có SHA-256 mà `hash` cho biết: không có gì được cài đặt # Actions panel: action sets (#3712). New Set… Bộ mới… New Set Bộ mới diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e646c956b..de6f8ea37 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5626,6 +5626,28 @@ axis Y Axis Y 轴 @msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):请在用户同意后使用 `allowHeavy: true` 安装 +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? 要安装需要这些权限的“{name}”吗? + ID: {id} ID:{id} + Version: {version} 版本:{version} + File SHA-256: {hash} 文件 SHA-256:{hash} + Read the objects it is given 读取交给它的对象 + Change, add and delete objects in the document 更改、添加和删除文档中的对象 + Keep data of its own with the document 在文档中保存自己的数据 + Change how the objects its effect is applied to are drawn 更改应用了其效果的对象的绘制方式 + Write the document as a file in its own format 将文档写为其自有格式的文件 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 类型的增效工具需要权限 `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 个 `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 权限 `{shown}` 不适用于 `{_1}` 类型的增效工具 +@msg permission `{shown}` is listed twice 权限 `{shown}` 列出了两次 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限 +@msg unknown permission `{shown}` 未知权限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”未获授予其输出所需的 `{p}`:未作任何更改 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 # Actions panel: action sets (#3712). New Set… 新建组… New Set 新建组 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 468ad851a..14b66871f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5422,6 +5422,28 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):請在使用者同意後以 `allowHeavy: true` 安裝 +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? 要安裝需要這些權限的「{name}」嗎? + ID: {id} ID:{id} + Version: {version} 版本:{version} + File SHA-256: {hash} 檔案 SHA-256:{hash} + Read the objects it is given 讀取交給它的物件 + Change, add and delete objects in the document 變更、新增和刪除文件中的物件 + Keep data of its own with the document 在文件中保存自己的資料 + Change how the objects its effect is applied to are drawn 變更套用其效果的物件的繪製方式 + Write the document as a file in its own format 將文件寫成其自有格式的檔案 +@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}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 類型的增效模組需要權限 `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 個 `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 權限 `{shown}` 不適用於 `{_1}` 類型的增效模組 +@msg permission `{shown}` is listed twice 權限 `{shown}` 列出了兩次 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限 +@msg unknown permission `{shown}` 未知權限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」未獲授予其輸出所需的 `{p}`:未做任何變更 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 # Actions panel: action sets (#3712). New Set… 新增組… New Set 新增組 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index d9d2b470a..85ce45c40 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -1,3 +1,5 @@ +use vectorcraft_plugins::permissions; + use super::save::suggested; use super::{Dialog, FilePick, Format, Value, VectorcraftApp, dialogs, fileio, json}; @@ -163,32 +165,77 @@ pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result String { + let words = match permissions::Permission::parse(name) { + Some(permissions::Permission::DocRead) => tl!("Read the objects it is given"), + Some(permissions::Permission::DocWrite) => tl!("Change, add and delete objects in the document"), + Some(permissions::Permission::State) => tl!("Keep data of its own with the document"), + 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"), + None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD), + }; + words.to_string() +} + /// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` -/// with `name`) → `plugin.install`'s result. A filter that asks for a heavy budget the user hasn't -/// allowed yet (`allowHeavy`) asks them first, and OK installs it → `{pending}` while it asks. +/// 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 +/// 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 { - if module.get("allowHeavy").and_then(Value::as_bool) != Some(true) { + 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 { 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()); - let info = app.run("plugin.inspect", source.clone())?; - if info["budget"] == "heavy" { - let name = info["name"].as_str().unwrap_or_default(); + // What OK installs: the same module, with what the user has agreed to so far, pinned to + // the bytes the prompt shows (`hash`). + let mut answered = + Value::Object(["path", "dataBase64", "name", "replace", "grant", "allowHeavy", "hash"].into_iter().filter_map(given).collect()); + let info = app.run("plugin.inspect", source)?; + if answered.get("hash").is_none() { + answered["hash"] = info["hash"].clone(); + } + let shown = |k: &str, max: usize| permissions::display_text(info[k].as_str().unwrap_or_default(), max); + let name = shown("name", permissions::MAX_SHOWN_NAME); + if !granted && info["needsApproval"] == true { + let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)]); + let mut lines = vec![crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))])]; + let version = shown("version", permissions::MAX_SHOWN_FIELD); + if !version.is_empty() { + lines.push(crate::i18n::fmt(tl!("Version: {version}"), &[("version", &version)])); + } + lines.push(crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))])); + lines.push(String::new()); + let asked = info["permissions"].as_array().cloned().unwrap_or_default(); + lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p)))); + let mut again = answered; + again["grant"] = Value::Array(asked); + dialogs::confirm::ask(app, &message, &lines.join("\n"), "ui.installPlugin", again); + return Ok(json!({ "pending": dialogs::confirm::KIND })); + } + if !heavy_allowed && info["budget"] == "heavy" { let (minutes, times) = vectorcraft_plugins::heavy_terms(); - let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", name)]); + let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)]); let detail = crate::i18n::fmt( tl!( "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." ), &[("minutes", &minutes.to_string()), ("times", ×.to_string())], ); - let mut allowed = source; + let mut allowed = answered; allowed["allowHeavy"] = json!(true); dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed); return Ok(json!({ "pending": dialogs::confirm::KIND })); } } let r = app.run("plugin.install", module)?; - app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); + app.status(format!("Installed plug-in {}", permissions::display_text(r["name"].as_str().unwrap_or_default(), permissions::MAX_SHOWN_NAME))); Ok(r) } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 988abad5b..10e829577 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1397,7 +1397,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A filter that asks for a heavy budget asks the user first: OK installs it → plugin.install's result | {pending: \"confirm\"} while it asks", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget), grant?: [permission] (the permissions the user granted it), replace?: bool, hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, and so does a filter that asks for a heavy budget: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing → plugin.install's result | {pending: \"confirm\"} while it asks", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index 3bd693afa..dbb32769a 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -119,3 +119,67 @@ fn plugin_effects_open_the_effect_dialog_with_their_schema() { app.run("effect.dialog", json!({"effect": "plugin.test.ui.fx", "index": 0})).unwrap(); assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("mode"), Some(&Value::from("b"))); } + +/// A filter `id` named `name` whose manifest lists `permissions` (a JSON list), at a version with +/// a bidirectional override, a tab and a bell in it. +fn asking(id: &str, name: &str, permissions: &str) -> Vec { + let manifest = json!({"id": id, "name": name, "version": "3.0\u{202E}\t\u{0007}", "kind": "filter"}).to_string(); + let manifest = format!(r#"{},"permissions":{permissions}}}"#, manifest.trim_end_matches('}')); + wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap() +} + +/// 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. +#[test] +fn installing_a_plugin_that_lists_permissions_asks_first() { + let mut app = app(); + let id = "test.ui.permissions"; + // U+202E would show the text after it reversed, and U+2066 isolates it; a manifest's name may + // hold them, though not control characters, which its version may. + let name = "Tidy\u{202E}fdp.exe\u{2066}"; + let bytes = asking(id, name, r#"["doc.read","doc.write"]"#); + let data = vectorcraft_format::base64_encode(&bytes); + let r = app.run("ui.installPlugin", json!({"dataBase64": data, "name": "tidy.wasm"})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.str("message"), "Install “Tidy\\u{202E}fdp.exe\\u{2066}”, which asks for these permissions?"); + let hash = vectorcraft_plugins::permissions::sha256_hex(&bytes); + assert_eq!(d.fields.get("__params").map(|p| p["hash"].clone()), Some(json!(hash)), "OK installs the module shown"); + let detail = d.str("detail"); + assert_eq!( + detail, + format!( + "ID: {id}\nVersion: 3.0\\u{{202E}}\\u{{0009}}\\u{{0007}}\nFile SHA-256: {hash}\n\n• Read the objects it is given\n• Change, add and delete objects in the document" + ) + ); + assert!(!detail.chars().chain(d.str("message").chars()).any(|c| matches!(c, '\u{202E}' | '\u{2066}' | '\t' | '\u{0007}'))); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "not before the user agrees"); + dialogs::cancel(&mut app); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "Cancel installs nothing"); + // File › Open asks too, and OK installs it with what it lists granted. + io::open_bytes(&mut app, "tidy.wasm", &bytes, None).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND)); + dialogs::confirm(&mut app).unwrap(); + 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. + let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#); + app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).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. + let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + assert!(app.ui.dialog.as_ref().unwrap().str("detail").ends_with("• Keep data of its own with the document")); + dialogs::cancel(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "Cancel keeps the one installed"); + // OK keeps the `replace` it was asked with: `false` leaves the installed one in place. + app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more), "replace": false})).unwrap(); + let _ = dialogs::confirm(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "replace: false is kept"); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1..030d4fe33 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5915,7 +5915,14 @@ that no built-in format has. A failing plug-in returns an error and leaves the d no file). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its `plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run` runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it. -`plugin.install` rejects a `replace` or `allowHeavy` value that is not a boolean. `prefs.set` returns an error and +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 +filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. +`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 value or the folder the preference already holds, and the web build does not check folders. diff --git a/docs/plugins.md b/docs/plugins.md index e1fcce813..96f8ac6e3 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -23,13 +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}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters)) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `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` | 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. | -| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have. | +| `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.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so is a heavy filter (nobody was asked). | +| `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 `effect.setParams`, listed by `effect.list` (menu `Effect › Plug-ins`) and baked by Object › Expand Appearance. @@ -47,7 +47,8 @@ heading and buttons in view. Exporters are formats of **File › Export › Expo Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is picked, and its Export button writes the file. **File › Open** of a `.wasm` file installs a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the -background ([below](#heavy-filters)). +background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed +([below](#permissions)). **Preference folder.** Edit › Preferences › Plug-ins & Scratch Disks › *Additional Plug-ins Folder*: when set, every `*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; @@ -101,6 +102,48 @@ much there is only once the run has ended. - `plugin.run` itself runs a heavy filter where it is called, as it runs any filter: an MCP client waits for it, and its `notifications/cancelled` stops it. +### Permissions + +A manifest may list what the plug-in may do in `permissions`. `doc.read`, `doc.write` and `state` follow the +capability model proposed for PhotoCraft plug-ins +([storytold/photocraft#662](https://github.com/storytold/photocraft/issues/662)); the other names are VectorCraft's own: + +| Permission | Kinds | What it allows | +|---|---|---| +| `doc.read` | all | Read the objects it is given. | +| `doc.write` | filter | Change, add and delete objects in the document. | +| `state` | filter | Keep data of its own with the document (`documentData`). | +| `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. | + +- 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. + An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, or one of the names + kept for a later plug-in API (`progress`, `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 + SHA-256 shown; Cancel installs nothing. The + names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. + `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. +- 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. 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` + 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 + document is refused. A refused output leaves the document as it was. +- `api` is the plug-in API level the manifest is written for: `1` (the default) is the only level this host + implements, and a higher one is refused. +- Parameter declarations are unchanged by permissions (`params`, below). + Every run gets a **fresh instance**, so no state survives between calls. 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 @@ -159,6 +202,8 @@ pointers are offsets into the module's memory. the kind when the plug-in is installed. - `budget` (filters only): `"light"` (the default) or `"heavy"`, for work that takes minutes ([heavy filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget. +- `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names + ([permissions](#permissions)); without it, what its kind implies. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..6ee68a15f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1428,6 +1428,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.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 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.