mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
Merge pull request #6176 from dotCooCoo/feat/plugin-permissions
Plug-ins: manifests list permissions, the user grants them at install, and the host enforces the grant
This commit is contained in:
Generated
+1
@@ -6424,6 +6424,7 @@ version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
|
||||
+103
-25
@@ -13,6 +13,12 @@
|
||||
//! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`),
|
||||
//! which loading a folder doesn't ask, so folders skip it; the app runs it in the background with
|
||||
//! Cancel ([`FilterRun`]).
|
||||
//!
|
||||
//! A plug-in whose manifest lists permissions installs once the user grants them
|
||||
//! (`plugin.install {grant}`, recorded with the module's hash and kept in the plug-in store), and
|
||||
//! a filter does only what they allow: without `doc.read` it is given no objects, without
|
||||
//! `doc.write` an output that changes the document is refused, and without `state` it neither
|
||||
//! reads nor writes the data it keeps with the document.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -22,6 +28,8 @@ use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registr
|
||||
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or};
|
||||
|
||||
use vectorcraft_plugins::permissions::{self, Grant, Permission};
|
||||
|
||||
/// What a plug-in error looks like to the user. A run the caller cancelled changed nothing.
|
||||
fn err(e: vectorcraft_plugins::Error) -> EngineError {
|
||||
match e {
|
||||
@@ -37,6 +45,7 @@ pub fn describe(p: &Plugin) -> Value {
|
||||
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description,
|
||||
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
|
||||
"size": p.size(), "source": p.source(),
|
||||
"permissions": permissions::names(&m.permissions()), "hash": p.hash(),
|
||||
});
|
||||
match m.kind {
|
||||
Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)),
|
||||
@@ -122,15 +131,17 @@ impl FilterRun {
|
||||
}
|
||||
// Bad parameters fail before anything runs.
|
||||
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
|
||||
let may = m.permissions();
|
||||
let st = s.doc()?;
|
||||
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
|
||||
let inputs = objects::filter_targets(&st.doc, &selected);
|
||||
// Without `doc.read` a filter is given no objects: it can only add new ones.
|
||||
let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() };
|
||||
let parent = st.insertion_parent();
|
||||
let bounds = st.doc.bounds_of(&inputs, false);
|
||||
let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first());
|
||||
let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
|
||||
// What the plug-in keeps with the document, as it last wrote it.
|
||||
let data = st.doc.plugin_data(&m.id).cloned().unwrap_or(Value::Null);
|
||||
let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null);
|
||||
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data});
|
||||
let input = objects::encode_input(&st.doc, &inputs);
|
||||
let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?;
|
||||
@@ -175,13 +186,23 @@ impl FilterRun {
|
||||
}
|
||||
|
||||
/// Applies the plug-in's `output` to the active document as one undo step: its objects, and what
|
||||
/// it keeps with the document from now on.
|
||||
/// it keeps with the document from now on. An output its permissions don't allow is refused,
|
||||
/// and the document stays as it was.
|
||||
fn finish(self, s: &mut Session, output: &[u8]) -> Result<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(®istry::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
|
||||
),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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())),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -5698,6 +5698,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.
|
@@ -5719,6 +5719,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.
|
@@ -5834,6 +5834,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.
|
@@ -5814,6 +5814,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.
|
@@ -5713,6 +5713,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.
|
@@ -5396,6 +5396,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.
|
@@ -5765,6 +5765,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 168 and column 5.
|
@@ -5755,6 +5755,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.
|
@@ -5844,6 +5844,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.
|
@@ -5809,6 +5809,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.
|
@@ -5206,6 +5206,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.
|
@@ -5797,6 +5797,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.
|
@@ -5713,6 +5713,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 2252 and column 5.
|
@@ -5430,6 +5430,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.
|
@@ -5495,6 +5495,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.
|
@@ -5737,6 +5737,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.
|
@@ -5391,6 +5391,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.
|
@@ -5759,6 +5759,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.
|
@@ -5667,6 +5667,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.
|
@@ -5729,6 +5729,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.
|
@@ -5425,6 +5425,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.
|
@@ -5837,6 +5837,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.
|
@@ -5771,6 +5771,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.
|
@@ -5410,6 +5410,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 182 and column 5.
|
@@ -5411,6 +5411,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 204 and column 5.
|
@@ -5652,6 +5652,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.
|
@@ -5448,6 +5448,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.
|
@@ -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", ×.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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1445,7 +1445,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",
|
||||
|
||||
@@ -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
@@ -6086,7 +6086,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
@@ -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.
|
||||
|
||||
@@ -1481,6 +1481,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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user