Plug-ins: manifests list permissions, the user grants them at install, and the host enforces the grant

A manifest may list `permissions` (doc.read, doc.write, state,
effect.render, export.write) and an `api` level. doc.read, doc.write
and state follow the capability model proposed in
storytold/photocraft#662; the other names are VectorCraft's own. An
unknown, reserved, duplicate or wrong-kind name is refused by name. A
manifest without the key gets the permissions 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 each string cut to 64
  characters. OK installs the module only if it still has the SHA-256
  shown; Cancel installs nothing. The heavy-budget prompt and the
  status line show the escaped name too, and OK keeps the `replace` it
  was given.
- `plugin.install` needs `grant` listing every permission, and
  `plugin.inspect` reports `needsApproval`. `hash` pins the module:
  one with another SHA-256 is refused. The grant is kept with the
  module's hash in the store's `grants.json`, and only that store's
  grants count while it is attached; the same set does not ask again,
  more permissions do, and `plugin.remove` forgets it.
- The Additional Plug-ins Folder and `plugin.reload` install only
  plug-ins the attached store has granted and report the others as
  needing approval. At startup the store skips an ungranted plug-in.
- A filter run without doc.read is given no objects; without state it
  neither reads nor writes `documentData`; without doc.write an output
  that changes the document is refused and the document is unchanged.

Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-07 15:20:07 -08:00
parent ff9deee46d
commit 1f352bc7c1
45 changed files with 1583 additions and 50 deletions
Generated
+1
View File
@@ -6415,6 +6415,7 @@ version = "0.21.0"
dependencies = [
"serde",
"serde_json",
"sha2",
"thiserror 2.0.21",
"vectorcraft-color",
"vectorcraft-doc",
+103 -25
View File
@@ -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(&params).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<Value> {
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<u8>)> {
}
}
/// 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<Option<Vec<Permission>>> {
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<Value> {
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<Value> {
),
));
}
// 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(&registry::install(plugin)))
}
/// `plugin.inspect`: what installing the module would install, without installing it.
fn inspect(_: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<u8>)> {
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<Option<(String, Value)>> = 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<Option<(String, Option<std::path::PathBuf>, 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::<Vec<_>>()})
}
@@ -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<Value> {
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<Value> {
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::<Vec<_>>(), "folder": folder}))
}
@@ -402,6 +477,9 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> {
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
// 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<CommandSpec> {
"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.<id>), format? and extensions? (an exporter's format id, plugin.<id>, 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.<id>), format? and extensions? (an exporter's format id, plugin.<id>, 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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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
),
+81 -2
View File
@@ -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<vectorcraft_plugins::permissions::Grant>) {
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::<Vec<_>>()) {
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<String, vectorcraft_plugins::permissions::Grant> {
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::<Vec<vectorcraft_plugins::permissions::Grant>>(&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<PdfPreset> {
+224
View File
@@ -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<u8> {
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]
+52
View File
@@ -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");
}
}
+2
View File
@@ -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 }
+3 -1
View File
@@ -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;
+68
View File
@@ -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<Vec<String>>,
/// 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<Permission> {
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(
+266
View File
@@ -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<Permission> {
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<Vec<Permission>> {
if names.len() > MAX_PERMISSIONS {
return Err(Error::Manifest(format!("at most {MAX_PERMISSIONS} `permissions`")));
}
let mut out: Vec<Permission> = 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<Permission>,
}
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::<Vec<_>>();
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]));
}
}
+48 -5
View File
@@ -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<BTreeMap<String, Arc<Plugin>>> = RwLock::new(BTreeMap::new());
static REVISION: AtomicU64 = AtomicU64::new(0);
/// What the user approved, by plug-in id.
static GRANTS: RwLock<BTreeMap<String, Grant>> = 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<Grant> {
GRANTS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned()
}
/// Every grant, sorted by id.
pub fn grants() -> Vec<Grant> {
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<Plugin> {
@@ -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<FolderReport> {
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<FolderReport> {
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<FolderReport> {
let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?;
let mut files: Vec<std::path::PathBuf> =
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())),
}
+7 -1
View File
@@ -85,6 +85,7 @@ pub struct Plugin {
module: Module,
limits: Limits,
size: usize,
hash: String,
source: Option<String>,
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()
+2
View File
@@ -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(),
+10
View File
@@ -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 Нов набор
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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 Νέο σύνολο
Can't render this file because it contains an unexpected character in line 163 and column 5.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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})
Can't render this file because it is too large.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2240 and column 5.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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 新規セット
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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 ახალი ნაკრები
Can't render this file because it is too large.
+22
View File
@@ -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 새 세트
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+22
View File
@@ -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 Новый набор
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+22
View File
@@ -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 Новий набір
Can't render this file because it contains an unexpected character in line 177 and column 5.
+22
View File
@@ -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
Can't render this file because it contains an unexpected character in line 199 and column 5.
+22
View File
@@ -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 新建组
Can't render this file because it contains an unexpected character in line 2 and column 73.
+22
View File
@@ -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 新增組
Can't render this file because it contains an unexpected character in line 6 and column 47.
+56 -9
View File
@@ -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<Value, Stri
install_module(app, json!({ "path": path }))
}
/// What permission `name` lets a plug-in do, in plain words in the UI language; a name the app
/// doesn't know is shown as it is.
fn permission_words(name: &str) -> 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<Value, String> {
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", &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)
}
+1 -1
View File
@@ -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",
+64
View File
@@ -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<u8> {
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();
}
+8 -1
View File
@@ -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.
+50 -5
View File
@@ -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.<id>", "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.
+1
View File
@@ -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.