mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 23:31:23 +00:00
Plug-ins: API level 2 lets a plug-in report progress, see Cancel and write log lines
A plug-in whose manifest gives "api": 2 and lists the new `progress` permission may import three host functions from module `env`: vc_progress(done, total), vc_cancelled() -> i32 and vc_log(level, ptr, len) -> i32. Installing checks every import and refuses, by name, one the host doesn't offer, one with another type and one the manifest doesn't allow. - Progress is clamped and passed on once per tenth of a percent (past 2,000 reports, once per 1,000 changes). A heavy filter's progress window and the export's progress window show it as a percentage bar, and their Cancel makes vc_cancelled return 1. A live effect's progress is not shown. - A run cancelled before it returns ends as cancelled, and one that returns after its time budget ran out ends at that budget, so what a plug-in returns after it sees vc_cancelled changes nothing. This also applies to a plug-in of API level 1 that finishes after a Cancel or past its time budget. - Log text must lie in the module's memory and be UTF-8. Lines go to the app's log as "<id>: <text>" with hidden characters escaped, at most 1,024 characters a line, 200 lines and 32 KiB a run, and the host reads at most 1 MiB of log text a run. - plugin.list and plugin.info give the manifest's `api`; the install prompt names the new permission in plain words. Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
Generated
+1
@@ -6422,6 +6422,7 @@ dependencies = [
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use vectorcraft_doc::cancel::{self, CancelToken, ProgressSink};
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct Progress {
|
||||
total: AtomicU64,
|
||||
/// The steps are files (Export for Screens), not artboards.
|
||||
files: bool,
|
||||
/// The last report came from a plug-in ([`vectorcraft_plugins::host::PROGRESS_STEP`]): its
|
||||
/// steps are its own, shown as a share of the work.
|
||||
share: AtomicBool,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
@@ -43,6 +46,11 @@ impl Progress {
|
||||
self.files
|
||||
}
|
||||
|
||||
/// Are the steps a plug-in's own, which only a share of the work done describes?
|
||||
pub fn counts_share(&self) -> bool {
|
||||
self.share.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// `(steps done, steps in all)`; `total` is 0 while the export can't say (one file made in one go).
|
||||
pub fn counts(&self) -> (u64, u64) {
|
||||
let total = self.total.load(Ordering::Relaxed);
|
||||
@@ -68,7 +76,8 @@ impl Progress {
|
||||
/// Run `f` with `progress`'s token in effect on this thread and the steps reported to it.
|
||||
pub fn scope<R>(progress: &Arc<Progress>, f: impl FnOnce() -> R) -> R {
|
||||
let to = Arc::clone(progress);
|
||||
let sink: ProgressSink = Rc::new(move |done, total, _| {
|
||||
let sink: ProgressSink = Rc::new(move |done, total, what| {
|
||||
to.share.store(what == vectorcraft_plugins::host::PROGRESS_STEP, Ordering::Relaxed);
|
||||
to.total.store(total, Ordering::Relaxed);
|
||||
to.done.store(done, Ordering::Relaxed);
|
||||
});
|
||||
@@ -94,4 +103,14 @@ mod tests {
|
||||
assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{:?}", r.err());
|
||||
assert!(!cancel::is_cancelled(), "the scope ended");
|
||||
}
|
||||
|
||||
/// A plug-in's reports count as a share of the work, not as artboards or files.
|
||||
#[test]
|
||||
fn a_plugin_s_reports_count_as_a_share_of_the_work() {
|
||||
let p = Progress::new();
|
||||
scope(&p, || cancel::progress(1, 4, "artboard"));
|
||||
assert_eq!((p.counts(), p.counts_share()), ((1, 4), false));
|
||||
scope(&p, || cancel::progress(3, 8, vectorcraft_plugins::host::PROGRESS_STEP));
|
||||
assert_eq!((p.counts(), p.counts_share()), ((3, 8), true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! A plug-in of API level 2 that lists `progress` may import the host's `vc_progress`,
|
||||
//! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a
|
||||
//! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its
|
||||
//! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log
|
||||
//! under its id.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -45,7 +51,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(),
|
||||
"permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api,
|
||||
});
|
||||
match m.kind {
|
||||
Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)),
|
||||
@@ -493,7 +499,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, 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}",
|
||||
"{} → {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; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
|
||||
@@ -840,6 +840,35 @@ fn a_filter_run_made_elsewhere_keeps_its_document_data() {
|
||||
assert_eq!(s.doc().unwrap().doc.plugin_data(id), Some(&json!({"pens": 2})), "undone with its objects");
|
||||
}
|
||||
|
||||
/// A plug-in of API level 2 that uses the host imports lists `progress`, which the user grants like
|
||||
/// any permission; a filter run made elsewhere then reports its progress, as a share, to the
|
||||
/// progress the caller shows, and sees the caller's Cancel.
|
||||
#[test]
|
||||
fn a_level_2_filter_installs_with_progress_granted_and_reports_to_the_caller() {
|
||||
use crate::cmd::fileio::progress::{self, Progress};
|
||||
use crate::cmd::plugin::FilterRun;
|
||||
let mut s = session();
|
||||
let id = "test.engine.level2";
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}}"#);
|
||||
let wasm = wat::parse_str(tpl::cooperating(&manifest, 1000, r#"{"objects":[]}"#, r#"{"objects":[]}"#)).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&wasm);
|
||||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data}));
|
||||
assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read", "doc.write", "progress"])));
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read", "doc.write"]})).unwrap_err().to_string();
|
||||
assert!(e.contains("asks for the permissions doc.read, doc.write, progress"), "{e}");
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write", "progress"]}));
|
||||
let shown = Progress::new();
|
||||
let r = FilterRun::prepare(&s, &json!({"id": id})).unwrap();
|
||||
assert!(progress::scope(&shown, || r.run()).is_ok());
|
||||
assert_eq!((shown.counts(), shown.counts_share()), ((999, 1000), true));
|
||||
// Cancelled, it stops on its first step, and what it returns then is discarded.
|
||||
shown.cancel();
|
||||
let cancelled = progress::scope(&shown, || r.run());
|
||||
assert!(matches!(cancelled, Err(EngineError::Cancelled)), "{cancelled:?}");
|
||||
assert_eq!(shown.counts(), (0, 1000));
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between
|
||||
/// two slices of its budget and changes nothing.
|
||||
#[test]
|
||||
|
||||
@@ -286,9 +286,9 @@ proptest! {
|
||||
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()],
|
||||
api in prop_oneof![2 => Just(json!(1)), 1 => Just(json!(2)), 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"]))],
|
||||
grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "progress"]))],
|
||||
) {
|
||||
let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api});
|
||||
if kind == "exporter" {
|
||||
@@ -302,8 +302,7 @@ proptest! {
|
||||
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);
|
||||
prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind)), "{}", 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);
|
||||
@@ -320,4 +319,47 @@ proptest! {
|
||||
vectorcraft_plugins::registry::remove("fuzz.permissions");
|
||||
vectorcraft_plugins::registry::revoke("fuzz.permissions");
|
||||
}
|
||||
|
||||
/// A plug-in of API level 2 that calls the host imports with hostile arguments (any progress
|
||||
/// counts, any log level, pointers and lengths in and out of its memory, text that isn't
|
||||
/// UTF-8) runs to an error or an output, never a panic, and its report stays within the caps.
|
||||
#[test]
|
||||
fn hostile_host_import_arguments_never_crash(
|
||||
calls in prop::collection::vec(
|
||||
prop_oneof![
|
||||
(any::<i64>(), any::<i64>()).prop_map(|(d, t)| format!("(call $vc_progress (i64.const {d}) (i64.const {t}))")),
|
||||
Just("(drop (call $vc_cancelled))".to_string()),
|
||||
(any::<i32>(), prop_oneof![any::<i32>(), 0x1ff00..0x20100i32], prop_oneof![any::<i32>(), 0..5000i32])
|
||||
.prop_map(|(l, p, n)| format!("(drop (call $vc_log (i32.const {l}) (i32.const {p}) (i32.const {n})))")),
|
||||
],
|
||||
0..24,
|
||||
),
|
||||
bytes in prop::collection::vec(any::<u8>(), 0..64),
|
||||
effect in any::<bool>(),
|
||||
) {
|
||||
use vectorcraft_plugins::host::{LOG_TRUNCATED, MAX_LOG_BYTES, MAX_LOG_LINE, MAX_LOG_LINES};
|
||||
let manifest = if effect {
|
||||
r#"{"id":"fuzz.host","name":"F","kind":"effect","api":2,"permissions":["doc.read","effect.render","progress"]}"#
|
||||
} else {
|
||||
r#"{"id":"fuzz.host","name":"F","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}"#
|
||||
};
|
||||
let text: String = bytes.iter().map(|b| format!("\\{b:02x}")).collect();
|
||||
let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::HOST_IMPORTS);
|
||||
let wasm = wat::parse_str(tpl::with_imports(manifest, &imports, &calls.concat(), r#"{"objects":[]}"#)).unwrap();
|
||||
let r = catch_quiet(|| {
|
||||
let p = vectorcraft_plugins::Plugin::load(&wasm, vectorcraft_plugins::Limits::default())?;
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = std::rc::Rc::new(|_, _, _| {});
|
||||
Ok::<_, vectorcraft_plugins::Error>(vectorcraft_doc::cancel::scope(&token, Some(sink), || p.run_reporting(b"{\"objects\":[]}", b"{}", p.limits())))
|
||||
});
|
||||
let r = r.map_err(|msg| TestCaseError::fail(format!("panicked on {calls:?}: {msg}")))?;
|
||||
let (out, report) = r.map_err(|e| TestCaseError::fail(format!("refused at load: {e}")))?;
|
||||
prop_assert!(out.is_ok(), "{:?}", out);
|
||||
prop_assert!(report.lines.len() <= MAX_LOG_LINES + 1);
|
||||
prop_assert!(report.lines.iter().all(|l| l.text.chars().count() <= MAX_LOG_LINE));
|
||||
prop_assert!(report.lines.iter().filter(|l| l.text != LOG_TRUNCATED).map(|l| l.text.len()).sum::<usize>() <= MAX_LOG_BYTES);
|
||||
if let Some((done, total)) = report.progress {
|
||||
prop_assert!(total == 0 || done <= total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# The SHA-256 of a module, which the install prompt shows and a grant records.
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
//! What a plug-in may import from the host ([`IMPORTS`]), from plug-in API level 2 on:
|
||||
//! `vc_progress` reports how far a run has got, `vc_cancelled` tells it the user or a host limit
|
||||
//! asked it to stop, and `vc_log` writes a log line. A module may import them only when its
|
||||
//! manifest gives `api` 2 and lists the `progress` permission, and the host checks each import when
|
||||
//! the module is loaded ([`check_imports`], [`check_granted`]): one it doesn't offer, one with
|
||||
//! another signature and one the manifest doesn't allow are refused by name.
|
||||
//!
|
||||
//! Each call's arguments are checked: progress is clamped and passed on at most once per tenth of
|
||||
//! a percent (and, past [`MAX_PROGRESS_REPORTS`], once per [`PROGRESS_STRIDE`] changes), log text
|
||||
//! must lie inside the module's memory and be UTF-8, and each line ([`MAX_LOG_LINE`]) and each run
|
||||
//! ([`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], [`MAX_LOG_READ`]) are capped. Nothing a module passes can
|
||||
//! panic the host.
|
||||
|
||||
use wasmi::{Caller, Engine, Extern, ExternType, Linker, Module, StoreLimits, ValType};
|
||||
|
||||
use crate::manifest::Manifest;
|
||||
use crate::permissions::{self, PROGRESS_API, Permission};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The module a plug-in imports host functions from (the name a Rust or C toolchain gives
|
||||
/// `extern` functions by default).
|
||||
pub const MODULE: &str = "env";
|
||||
|
||||
/// What the host passes as the step's description with a plug-in's progress
|
||||
/// ([`vectorcraft_doc::cancel::progress`]), so a progress window shows a share rather than a count
|
||||
/// of artboards or files.
|
||||
pub const PROGRESS_STEP: &str = "plug-in";
|
||||
|
||||
/// Longest log line: its first 1,024 bytes of text, shown as at most 1,024 characters with hidden
|
||||
/// characters escaped. A longer one is cut and ends with `…`.
|
||||
pub const MAX_LOG_LINE: usize = 1024;
|
||||
|
||||
/// Most log lines one run writes; later ones are dropped, after one "log truncated" line.
|
||||
pub const MAX_LOG_LINES: usize = 200;
|
||||
|
||||
/// Most bytes of log text, as written with its escapes, one run writes; lines past it are dropped
|
||||
/// as past [`MAX_LOG_LINES`].
|
||||
pub const MAX_LOG_BYTES: usize = 32 * 1024;
|
||||
|
||||
/// Most bytes of text the host reads from a run's `vc_log` calls, checked or not: a call that
|
||||
/// would read past it is dropped as past [`MAX_LOG_LINES`].
|
||||
pub const MAX_LOG_READ: usize = 1024 * 1024;
|
||||
|
||||
/// Most progress reports one run passes on at the full rate; after them, one changed report in
|
||||
/// [`PROGRESS_STRIDE`] is, and so is the first after a total becomes known or unknown. The run's
|
||||
/// last report is always kept ([`Report::progress`]).
|
||||
pub const MAX_PROGRESS_REPORTS: usize = 2000;
|
||||
|
||||
/// Past [`MAX_PROGRESS_REPORTS`], how many changed reports go by for each one passed on.
|
||||
pub const PROGRESS_STRIDE: usize = 1000;
|
||||
|
||||
/// What `vc_log` returns: the line was written.
|
||||
pub const LOG_WRITTEN: i32 = 0;
|
||||
/// What `vc_log` returns: the run's log budget is spent, so the line was dropped.
|
||||
pub const LOG_DROPPED: i32 = 1;
|
||||
/// What `vc_log` returns: the text doesn't lie inside the module's memory.
|
||||
pub const LOG_OUTSIDE_MEMORY: i32 = -1;
|
||||
/// What `vc_log` returns: the text is not UTF-8.
|
||||
pub const LOG_NOT_UTF8: i32 = -2;
|
||||
/// What `vc_log` returns: the level is not 0 to 3.
|
||||
pub const LOG_BAD_LEVEL: i32 = -3;
|
||||
|
||||
/// One host import: its name and its type.
|
||||
pub struct Import {
|
||||
pub name: &'static str,
|
||||
params: &'static [ValType],
|
||||
results: &'static [ValType],
|
||||
/// Its type as WebAssembly text writes it.
|
||||
pub signature: &'static str,
|
||||
}
|
||||
|
||||
/// The host imports of API level 2, in module [`MODULE`]:
|
||||
///
|
||||
/// - `vc_progress(done: i64, total: i64)`: `done` of `total` steps are finished (`total` 0 or less:
|
||||
/// not known). Negative values count as 0 and `done` is clamped to `total`;
|
||||
/// - `vc_cancelled() -> i32`: 1 once the user cancelled the run or its time budget ran out, else 0;
|
||||
/// - `vc_log(level: i32, ptr: i32, len: i32) -> i32`: writes the UTF-8 text at `ptr` as a log line
|
||||
/// of `level` (0 error, 1 warning, 2 information, 3 debug) → [`LOG_WRITTEN`], [`LOG_DROPPED`],
|
||||
/// [`LOG_OUTSIDE_MEMORY`], [`LOG_NOT_UTF8`] or [`LOG_BAD_LEVEL`].
|
||||
pub const IMPORTS: [Import; 3] = [
|
||||
Import { name: "vc_progress", params: &[ValType::I64, ValType::I64], results: &[], signature: "(param i64 i64)" },
|
||||
Import { name: "vc_cancelled", params: &[], results: &[ValType::I32], signature: "(result i32)" },
|
||||
Import {
|
||||
name: "vc_log",
|
||||
params: &[ValType::I32, ValType::I32, ValType::I32],
|
||||
results: &[ValType::I32],
|
||||
signature: "(param i32 i32 i32) (result i32)",
|
||||
},
|
||||
];
|
||||
|
||||
/// A log line's level.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LogLevel {
|
||||
Error,
|
||||
Warn,
|
||||
Info,
|
||||
Debug,
|
||||
}
|
||||
|
||||
impl LogLevel {
|
||||
/// The level `vc_log`'s `level` names.
|
||||
fn from_code(code: i32) -> Option<LogLevel> {
|
||||
match code {
|
||||
0 => Some(LogLevel::Error),
|
||||
1 => Some(LogLevel::Warn),
|
||||
2 => Some(LogLevel::Info),
|
||||
3 => Some(LogLevel::Debug),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn log_level(self) -> log::Level {
|
||||
match self {
|
||||
LogLevel::Error => log::Level::Error,
|
||||
LogLevel::Warn => log::Level::Warn,
|
||||
LogLevel::Info => log::Level::Info,
|
||||
LogLevel::Debug => log::Level::Debug,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line a plug-in wrote with `vc_log`: its control and bidirectional formatting characters
|
||||
/// escaped ([`permissions::display_text`]), and cut to [`MAX_LOG_LINE`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LogLine {
|
||||
pub level: LogLevel,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// The text of the line that stands for the lines a run's log budget dropped.
|
||||
pub const LOG_TRUNCATED: &str = "log truncated: later lines were dropped";
|
||||
|
||||
/// What a run reported through the host imports.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
/// Its last progress report, clamped: `(done, total)`, `total` 0 when not known.
|
||||
pub progress: Option<(u64, u64)>,
|
||||
/// The lines it wrote, then [`LOG_TRUNCATED`] once its budget dropped one.
|
||||
pub lines: Vec<LogLine>,
|
||||
/// Did its log budget drop a line?
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// When a run must stop (native: a wall-clock deadline).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Deadline {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Deadline {
|
||||
pub(crate) fn new(wall_time_ms: u64) -> Self {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = wall_time_ms;
|
||||
Self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant::now() + std::time::Duration::from_millis(wall_time_ms),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn passed(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::time::Instant::now() >= self.at {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn check(&self) -> Result<()> {
|
||||
if self.passed() { Err(Error::Limit("time budget".into())) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// One run's state that the host imports read and write.
|
||||
pub(crate) struct Run {
|
||||
/// The plug-in's id, which its log lines carry.
|
||||
id: String,
|
||||
/// Are its progress reports passed on ([`vectorcraft_doc::cancel::progress`])? A live
|
||||
/// effect's are not: it is drawn as part of something else, such as an export.
|
||||
forward: bool,
|
||||
/// Are its log lines written to the app's log? Not while the module is being loaded.
|
||||
logged: bool,
|
||||
deadline: Deadline,
|
||||
/// The last report passed on, as per mille of a known total or the count of an unknown one,
|
||||
/// how many were, and how many changed ones went by since the last.
|
||||
last: Option<(u64, u64)>,
|
||||
reports: usize,
|
||||
skipped: usize,
|
||||
/// Bytes of log text written (with their escapes) and read.
|
||||
log_bytes: usize,
|
||||
log_read: usize,
|
||||
pub(crate) report: Report,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
pub(crate) fn new(id: &str, forward: bool, logged: bool, deadline: Deadline) -> Self {
|
||||
Run {
|
||||
id: id.to_string(),
|
||||
forward,
|
||||
logged,
|
||||
deadline,
|
||||
last: None,
|
||||
reports: 0,
|
||||
skipped: 0,
|
||||
log_bytes: 0,
|
||||
log_read: 0,
|
||||
report: Report::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn progress(&mut self, done: i64, total: i64) {
|
||||
let total = u64::try_from(total).unwrap_or(0);
|
||||
let done = u64::try_from(done).unwrap_or(0);
|
||||
let done = if total > 0 { done.min(total) } else { done };
|
||||
self.report.progress = Some((done, total));
|
||||
let key = if total > 0 { ((u128::from(done) * 1000 / u128::from(total)) as u64, total) } else { (done, 0) };
|
||||
if !self.forward || self.last == Some(key) {
|
||||
return;
|
||||
}
|
||||
let switched = self.last.is_some_and(|(_, t)| (t > 0) != (total > 0));
|
||||
if self.reports >= MAX_PROGRESS_REPORTS && !switched {
|
||||
self.skipped += 1;
|
||||
if self.skipped < PROGRESS_STRIDE {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.skipped = 0;
|
||||
self.last = Some(key);
|
||||
self.reports += 1;
|
||||
vectorcraft_doc::cancel::progress(done, total, PROGRESS_STEP);
|
||||
}
|
||||
|
||||
fn cancelled(&self) -> i32 {
|
||||
i32::from(vectorcraft_doc::cancel::is_cancelled() || self.deadline.passed())
|
||||
}
|
||||
|
||||
/// Writes the `len` bytes at `ptr` in `memory` as a line of `level` → what `vc_log` returns.
|
||||
fn log(&mut self, level: i32, memory: &[u8], ptr: usize, len: usize) -> i32 {
|
||||
let Some(level) = LogLevel::from_code(level) else { return LOG_BAD_LEVEL };
|
||||
let Some(bytes) = ptr.checked_add(len).and_then(|end| memory.get(ptr..end)) else { return LOG_OUTSIDE_MEMORY };
|
||||
if self.report.truncated {
|
||||
return LOG_DROPPED;
|
||||
}
|
||||
if self.log_read.saturating_add(len) > MAX_LOG_READ {
|
||||
return self.truncate();
|
||||
}
|
||||
self.log_read += len;
|
||||
let Ok(text) = std::str::from_utf8(bytes) else { return LOG_NOT_UTF8 };
|
||||
// A character the cut splits is left out.
|
||||
let head = text.get(..text.floor_char_boundary(MAX_LOG_LINE)).unwrap_or(text);
|
||||
let shown = if head.len() < text.len() {
|
||||
let mut shown = permissions::display_text(head, MAX_LOG_LINE - 1);
|
||||
if !shown.ends_with('…') {
|
||||
shown.push('…');
|
||||
}
|
||||
shown
|
||||
} else {
|
||||
permissions::display_text(head, MAX_LOG_LINE)
|
||||
};
|
||||
if self.report.lines.len() >= MAX_LOG_LINES || self.log_bytes + shown.len() > MAX_LOG_BYTES {
|
||||
return self.truncate();
|
||||
}
|
||||
self.log_bytes += shown.len();
|
||||
self.write(level, shown);
|
||||
LOG_WRITTEN
|
||||
}
|
||||
|
||||
/// Drops the line and the run's later ones, after one line that says so → [`LOG_DROPPED`].
|
||||
fn truncate(&mut self) -> i32 {
|
||||
self.report.truncated = true;
|
||||
self.write(LogLevel::Warn, LOG_TRUNCATED.to_string());
|
||||
LOG_DROPPED
|
||||
}
|
||||
|
||||
fn write(&mut self, level: LogLevel, text: String) {
|
||||
if self.logged {
|
||||
log::log!(target: "vectorcraft_plugins", level.log_level(), "{}: {text}", self.id);
|
||||
}
|
||||
self.report.lines.push(LogLine { level, text });
|
||||
}
|
||||
}
|
||||
|
||||
/// What a store holds: its memory limits and the run's state.
|
||||
pub(crate) struct Host {
|
||||
pub(crate) limits: StoreLimits,
|
||||
pub(crate) run: Run,
|
||||
}
|
||||
|
||||
/// A linker that offers the host imports when `granted`, and no import otherwise.
|
||||
pub(crate) fn linker(engine: &Engine, granted: bool) -> Linker<Host> {
|
||||
let mut linker = Linker::<Host>::new(engine);
|
||||
if granted {
|
||||
// Each name is defined once, so none of these fails.
|
||||
let _ = linker.func_wrap(MODULE, "vc_progress", |mut caller: Caller<'_, Host>, done: i64, total: i64| {
|
||||
caller.data_mut().run.progress(done, total);
|
||||
});
|
||||
let _ = linker.func_wrap(MODULE, "vc_cancelled", |caller: Caller<'_, Host>| -> i32 { caller.data().run.cancelled() });
|
||||
let _ = linker.func_wrap(MODULE, "vc_log", |mut caller: Caller<'_, Host>, level: i32, ptr: i32, len: i32| -> i32 {
|
||||
let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return LOG_OUTSIDE_MEMORY };
|
||||
let (data, host) = memory.data_and_store_mut(&mut caller);
|
||||
host.run.log(level, data, ptr as u32 as usize, len as u32 as usize)
|
||||
});
|
||||
}
|
||||
linker
|
||||
}
|
||||
|
||||
/// Checks that every import of `module` is a host import with the host's signature → their names.
|
||||
/// Any other import is refused, and the error names it.
|
||||
pub(crate) fn check_imports(module: &Module) -> Result<Vec<&'static str>> {
|
||||
let mut names = Vec::new();
|
||||
for imp in module.imports() {
|
||||
let offered = if imp.module() == MODULE { IMPORTS.iter().find(|i| i.name == imp.name()) } else { None };
|
||||
let (from, name) = (permissions::display_text(imp.module(), 64), permissions::display_text(imp.name(), 64));
|
||||
let Some(host) = offered else {
|
||||
return Err(Error::Abi(format!(
|
||||
"the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network"
|
||||
)));
|
||||
};
|
||||
match imp.ty() {
|
||||
ExternType::Func(f) if f.params() == host.params && f.results() == host.results => names.push(host.name),
|
||||
_ => {
|
||||
let signature = host.signature;
|
||||
return Err(Error::Abi(format!("the module imports `env.{name}` with another type than the host's: it is {signature}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Checks that `manifest` allows the host imports `imports`: `api` 2 and the `progress`
|
||||
/// permission. The error names the first import it doesn't allow.
|
||||
pub(crate) fn check_granted(manifest: &Manifest, imports: &[&str]) -> Result<()> {
|
||||
match imports.first() {
|
||||
Some(name) if manifest.api < PROGRESS_API || !manifest.permissions().contains(&Permission::Progress) => Err(Error::Abi(format!(
|
||||
"the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress`"
|
||||
))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run() -> Run {
|
||||
Run::new("test", true, false, Deadline::new(60_000))
|
||||
}
|
||||
|
||||
/// Progress is clamped: negative counts are 0 and `done` never passes a known total.
|
||||
#[test]
|
||||
fn progress_is_clamped() {
|
||||
let mut r = run();
|
||||
for ((done, total), want) in
|
||||
[((5, 3), (3, 3)), ((-4, 10), (0, 10)), ((7, -1), (7, 0)), ((i64::MAX, i64::MAX), (i64::MAX as u64, i64::MAX as u64))]
|
||||
{
|
||||
r.progress(done, total);
|
||||
assert_eq!(r.report.progress, Some(want), "{done} of {total}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports are passed on once per tenth of a percent at most, and past
|
||||
/// [`MAX_PROGRESS_REPORTS`] once per [`PROGRESS_STRIDE`] changes, except the first after the
|
||||
/// total becomes known; the last one is always kept.
|
||||
#[test]
|
||||
fn progress_is_passed_on_at_a_bounded_rate() {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = {
|
||||
let seen = seen.clone();
|
||||
Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string())))
|
||||
};
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
let mut r = run();
|
||||
vectorcraft_doc::cancel::scope(&token, Some(sink), || {
|
||||
for done in 0..=1_000_000 {
|
||||
r.progress(done, 1_000_000);
|
||||
}
|
||||
});
|
||||
let seen = seen.borrow();
|
||||
assert_eq!(seen.len(), 1001, "one per tenth of a percent");
|
||||
assert_eq!(seen.last(), Some(&(1_000_000, 1_000_000, PROGRESS_STEP.to_string())));
|
||||
let mut unknown = run();
|
||||
let later = Rc::new(RefCell::new(Vec::new()));
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = {
|
||||
let later = later.clone();
|
||||
Rc::new(move |d, t, _| later.borrow_mut().push((d, t)))
|
||||
};
|
||||
vectorcraft_doc::cancel::scope(&token, Some(sink), || {
|
||||
for done in 0..10_000 {
|
||||
unknown.progress(done, 0);
|
||||
}
|
||||
assert_eq!(unknown.reports, MAX_PROGRESS_REPORTS + (10_000 - MAX_PROGRESS_REPORTS) / PROGRESS_STRIDE);
|
||||
assert_eq!(unknown.report.progress, Some((9_999, 0)), "the last report is kept");
|
||||
// A total that becomes known is passed on at once, and the reports after it keep
|
||||
// coming at the slower rate.
|
||||
unknown.progress(10, 100);
|
||||
assert_eq!(later.borrow().last(), Some(&(10, 100)));
|
||||
for done in 11..=100 {
|
||||
unknown.progress(done, 100);
|
||||
}
|
||||
});
|
||||
assert_eq!(later.borrow().last(), Some(&(10, 100)), "90 changes are fewer than the stride");
|
||||
assert_eq!(unknown.report.progress, Some((100, 100)));
|
||||
}
|
||||
|
||||
/// Text outside memory, text that isn't UTF-8 and an unknown level are errors; a long line is
|
||||
/// cut, also inside a character, and hidden characters are escaped.
|
||||
#[test]
|
||||
fn log_text_is_checked_and_cut() {
|
||||
let mut r = run();
|
||||
let memory = b"hello\xff\xfe\nworld";
|
||||
assert_eq!(r.log(2, memory, 0, 5), LOG_WRITTEN);
|
||||
assert_eq!(r.log(2, memory, 0, memory.len() + 1), LOG_OUTSIDE_MEMORY);
|
||||
assert_eq!(r.log(2, memory, usize::MAX, 2), LOG_OUTSIDE_MEMORY);
|
||||
assert_eq!(r.log(2, memory, 4, 3), LOG_NOT_UTF8);
|
||||
assert_eq!(r.log(4, memory, 0, 5), LOG_BAD_LEVEL);
|
||||
assert_eq!(r.log(-1, memory, 0, 5), LOG_BAD_LEVEL);
|
||||
assert_eq!(r.log(0, memory, 7, 6), LOG_WRITTEN);
|
||||
// The cut falls inside the 512th "é", which is left out.
|
||||
let long = format!("a{}", "é".repeat(MAX_LOG_LINE));
|
||||
assert_eq!(r.log(1, long.as_bytes(), 0, long.len()), LOG_WRITTEN);
|
||||
let lines = &r.report.lines;
|
||||
assert_eq!(lines[0], LogLine { level: LogLevel::Info, text: "hello".into() });
|
||||
assert_eq!(lines[1], LogLine { level: LogLevel::Error, text: "\\u{000A}world".into() });
|
||||
assert_eq!(lines[2], LogLine { level: LogLevel::Warn, text: format!("a{}…", "é".repeat(MAX_LOG_LINE / 2 - 1)) });
|
||||
assert!(!r.report.truncated);
|
||||
// Text past the cut is checked too.
|
||||
let bad_tail = [vec![b'a'; MAX_LOG_LINE * 2], vec![0xff]].concat();
|
||||
assert_eq!(r.log(2, &bad_tail, 0, bad_tail.len()), LOG_NOT_UTF8);
|
||||
// A cut line of escapes is at most MAX_LOG_LINE characters, its ellipsis included.
|
||||
let controls = vec![1u8; MAX_LOG_LINE * 2];
|
||||
assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_WRITTEN);
|
||||
let text = &r.report.lines[3].text;
|
||||
assert!(text.starts_with("\\u{0001}") && text.ends_with('…') && text.chars().count() <= MAX_LOG_LINE, "{text}");
|
||||
}
|
||||
|
||||
/// The byte budget counts a line as written, with its escapes, so short lines of hidden
|
||||
/// characters can't write more than [`MAX_LOG_BYTES`].
|
||||
#[test]
|
||||
fn log_budget_counts_escaped_text() {
|
||||
let mut r = run();
|
||||
let controls = [1u8; 100];
|
||||
let shown = "\\u{0001}".len() * controls.len();
|
||||
let fit = MAX_LOG_BYTES / shown;
|
||||
for i in 0..fit {
|
||||
assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_WRITTEN, "line {i}");
|
||||
}
|
||||
assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_DROPPED);
|
||||
let written: usize = r.report.lines.iter().filter(|l| l.text != LOG_TRUNCATED).map(|l| l.text.len()).sum();
|
||||
assert!(written <= MAX_LOG_BYTES, "{written}");
|
||||
}
|
||||
|
||||
/// The host reads at most [`MAX_LOG_READ`] bytes of log text in a run, so long texts can't
|
||||
/// make it check more; once past it, later calls are dropped without reading.
|
||||
#[test]
|
||||
fn log_text_read_is_bounded() {
|
||||
let mut r = run();
|
||||
let big = vec![b'a'; MAX_LOG_READ / 4];
|
||||
for _ in 0..4 {
|
||||
assert_eq!(r.log(2, &big, 0, big.len()), LOG_WRITTEN);
|
||||
}
|
||||
assert_eq!(r.log(2, b"x", 0, 1), LOG_DROPPED);
|
||||
assert!(r.report.truncated);
|
||||
let mut huge = run();
|
||||
let over = vec![0xffu8; MAX_LOG_READ + 1];
|
||||
assert_eq!(huge.log(2, &over, 0, over.len()), LOG_DROPPED, "dropped before it is checked");
|
||||
assert_eq!(huge.log(2, b"x", 0, 1), LOG_DROPPED);
|
||||
}
|
||||
|
||||
/// Lines past the run's budget are dropped, with one line that says so.
|
||||
#[test]
|
||||
fn log_lines_past_the_budget_are_dropped_with_one_truncation_line() {
|
||||
let mut r = run();
|
||||
for i in 0..MAX_LOG_LINES + 50 {
|
||||
let want = if i < MAX_LOG_LINES { LOG_WRITTEN } else { LOG_DROPPED };
|
||||
assert_eq!(r.log(2, b"line", 0, 4), want, "line {i}");
|
||||
}
|
||||
assert_eq!(r.report.lines.len(), MAX_LOG_LINES + 1);
|
||||
assert_eq!(r.report.lines.last(), Some(&LogLine { level: LogLevel::Warn, text: LOG_TRUNCATED.into() }));
|
||||
assert!(r.report.truncated);
|
||||
// So is text past the run's bytes.
|
||||
let mut bytes = run();
|
||||
let big = vec![b'x'; MAX_LOG_LINE];
|
||||
let fit = MAX_LOG_BYTES / MAX_LOG_LINE;
|
||||
for _ in 0..fit {
|
||||
assert_eq!(bytes.log(2, &big, 0, big.len()), LOG_WRITTEN);
|
||||
}
|
||||
assert_eq!(bytes.log(2, b"x", 0, 1), LOG_DROPPED);
|
||||
assert_eq!(bytes.report.lines.len(), fit + 1);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
//! A plug-in is a WebAssembly module that implements the VectorCraft plug-in ABI (v1, documented
|
||||
//! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with:
|
||||
//!
|
||||
//! - **no host imports**: no WASI, file system, network, clock or randomness. A module that
|
||||
//! imports anything is rejected;
|
||||
//! - **no host imports but the three of API level 2** ([`host`]: progress, Cancel and log lines,
|
||||
//! with the `progress` permission): no WASI, file system, network, clock or randomness. A module
|
||||
//! that imports anything else is rejected;
|
||||
//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is
|
||||
//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a
|
||||
//! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it;
|
||||
@@ -28,6 +29,7 @@
|
||||
pub mod dialog;
|
||||
pub mod effect;
|
||||
pub mod export;
|
||||
pub mod host;
|
||||
pub mod manifest;
|
||||
pub mod objects;
|
||||
pub mod permissions;
|
||||
@@ -36,6 +38,7 @@ mod runtime;
|
||||
pub mod wat;
|
||||
|
||||
pub use dialog::ParamUi;
|
||||
pub use host::{LogLevel, LogLine, Report};
|
||||
pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec};
|
||||
pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms};
|
||||
|
||||
|
||||
@@ -5,7 +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::permissions::{self, API_LEVEL, PROGRESS_API, Permission};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Longest manifest the host reads.
|
||||
@@ -307,8 +307,11 @@ impl Manifest {
|
||||
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)?;
|
||||
if let Some(names) = &self.permissions
|
||||
&& permissions::validate(self.kind, names)?.contains(&Permission::Progress)
|
||||
&& self.api < PROGRESS_API
|
||||
{
|
||||
return Err(Error::Manifest(format!("permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}")));
|
||||
}
|
||||
self.validate_extensions()?;
|
||||
match (self.budget, self.kind) {
|
||||
@@ -649,12 +652,20 @@ mod tests {
|
||||
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());
|
||||
// Level 2 adds `progress`, which only a level-2 manifest may list.
|
||||
let level2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["progress","doc.read"]}"#).unwrap();
|
||||
assert_eq!((level2.api, level2.permissions()), (2, vec![Permission::DocRead, Permission::Progress]));
|
||||
assert_eq!(serde_json::to_value(&level2).unwrap()["api"], json!(2));
|
||||
let plain2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2}"#).unwrap();
|
||||
assert!(!plain2.permissions().contains(&Permission::Progress), "never implied");
|
||||
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","permissions":["progress"]}"#, "`progress` needs plug-in API level 2"),
|
||||
(r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["progress"]}"#, "`progress` needs plug-in API level 2"),
|
||||
(r#"{"id":"a","name":"x","kind":"filter","api":3}"#, "`api` is 3"),
|
||||
(r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"),
|
||||
(r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"),
|
||||
] {
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
//! 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.
|
||||
//! `doc.read`, `doc.write`, `state` and `progress` are the names of the plug-in capability model in
|
||||
//! storytold/photocraft#662; the other names are VectorCraft's own. A manifest without
|
||||
//! `permissions` gets the ones its kind implies ([`Permission::implied`]): what every plug-in of
|
||||
//! that kind could do before manifests listed them. `progress`, which lets a plug-in use the host
|
||||
//! imports of API level 2 ([`crate::host`]), is never implied: a manifest lists it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -13,7 +14,11 @@ 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;
|
||||
pub const API_LEVEL: u32 = 2;
|
||||
|
||||
/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress`
|
||||
/// permission they need.
|
||||
pub const PROGRESS_API: u32 = 2;
|
||||
|
||||
/// Most permissions a manifest may list.
|
||||
pub const MAX_PERMISSIONS: usize = 16;
|
||||
@@ -26,7 +31,7 @@ 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"];
|
||||
pub const RESERVED: &[&str] = &["ui.panel", "ui.tool"];
|
||||
|
||||
/// One thing a plug-in may do.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
@@ -47,10 +52,15 @@ pub enum Permission {
|
||||
/// Write the document as a file in its own format (an exporter).
|
||||
#[serde(rename = "export.write")]
|
||||
ExportWrite,
|
||||
/// Report how far a run has got, see that the user asked it to stop and write log lines: the
|
||||
/// host imports of API level 2 ([`crate::host::IMPORTS`]).
|
||||
#[serde(rename = "progress")]
|
||||
Progress,
|
||||
}
|
||||
|
||||
/// 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];
|
||||
pub const ALL: [Permission; 6] =
|
||||
[Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite, Permission::Progress];
|
||||
|
||||
impl Permission {
|
||||
/// Its name in a manifest and in `plugin.install`'s `grant`.
|
||||
@@ -61,6 +71,7 @@ impl Permission {
|
||||
Permission::State => "state",
|
||||
Permission::EffectRender => "effect.render",
|
||||
Permission::ExportWrite => "export.write",
|
||||
Permission::Progress => "progress",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,11 +88,18 @@ impl Permission {
|
||||
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",
|
||||
Permission::Progress => "Show its progress, see Cancel and write log lines",
|
||||
}
|
||||
}
|
||||
|
||||
/// The permissions a plug-in of `kind` may list, which are also the ones a manifest without
|
||||
/// `permissions` gets.
|
||||
/// May a plug-in of `kind` list it: one its kind implies, or `progress`, which any kind may
|
||||
/// list.
|
||||
pub fn applies(self, kind: Kind) -> bool {
|
||||
self == Permission::Progress || Permission::implied(kind).contains(&self)
|
||||
}
|
||||
|
||||
/// The permissions a manifest of `kind` without `permissions` gets. A manifest may also list
|
||||
/// `progress` ([`Permission::applies`]).
|
||||
pub fn implied(kind: Kind) -> &'static [Permission] {
|
||||
match kind {
|
||||
Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State],
|
||||
@@ -117,7 +135,7 @@ pub(crate) fn validate(kind: Kind, names: &[String]) -> Result<Vec<Permission>>
|
||||
}
|
||||
return Err(Error::Manifest(format!("unknown permission `{shown}`")));
|
||||
};
|
||||
if !Permission::implied(kind).contains(&p) {
|
||||
if !p.applies(kind) {
|
||||
return Err(Error::Manifest(format!("permission `{shown}` doesn't apply to a plug-in of kind `{}`", kind.name())));
|
||||
}
|
||||
if out.contains(&p) {
|
||||
@@ -218,9 +236,15 @@ mod tests {
|
||||
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());
|
||||
// Any kind may list `progress`, which no kind implies.
|
||||
assert_eq!(validate(Kind::Filter, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]);
|
||||
assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render", "progress"])).is_ok());
|
||||
assert!(validate(Kind::Exporter, &list(&["progress", "export.write", "doc.read"])).is_ok());
|
||||
assert!([Kind::Filter, Kind::Effect, Kind::Exporter].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress)));
|
||||
for (kind, names, says) in [
|
||||
(Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"),
|
||||
(Kind::Filter, &["progress"], "`progress` is reserved"),
|
||||
(Kind::Filter, &["ui.panel"], "`ui.panel` is reserved"),
|
||||
(Kind::Filter, &["progress", "progress"], "`progress` is listed twice"),
|
||||
(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`"),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! The wasmi host: loading, instantiating and calling a plug-in under resource limits.
|
||||
|
||||
use wasmi::{
|
||||
Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc,
|
||||
TypedResumableCall, WasmParams, WasmResults,
|
||||
Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Memory, Module, Store, StoreLimitsBuilder, TypedFunc, TypedResumableCall, WasmParams,
|
||||
WasmResults,
|
||||
};
|
||||
|
||||
use crate::host::{self, Deadline, Host, Report, Run};
|
||||
use crate::manifest::{MAX_MANIFEST_BYTES, Manifest};
|
||||
use crate::{Error, Result};
|
||||
use crate::permissions::Permission;
|
||||
use crate::{Error, Kind, Result};
|
||||
|
||||
/// The plug-in ABI version this host implements (`vc_abi_version` must return it).
|
||||
pub const ABI_VERSION: i32 = 1;
|
||||
@@ -96,33 +98,10 @@ impl std::fmt::Debug for Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// When a run must stop (native: a wall-clock deadline).
|
||||
struct Deadline {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Deadline {
|
||||
fn new(limits: &Limits) -> Self {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = limits;
|
||||
Self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
at: std::time::Instant::now() + std::time::Duration::from_millis(limits.wall_time_ms),
|
||||
}
|
||||
}
|
||||
fn check(&self) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::time::Instant::now() >= self.at {
|
||||
return Err(Error::Limit("time budget".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One live instance: a store with its own memory limit, and the module's exports.
|
||||
/// One live instance: a store with its own memory limit and the run's state, and the module's
|
||||
/// exports.
|
||||
struct Live {
|
||||
store: Store<StoreLimits>,
|
||||
store: Store<Host>,
|
||||
instance: Instance,
|
||||
memory: Memory,
|
||||
}
|
||||
@@ -152,7 +131,9 @@ fn wasm_err(e: wasmi::Error) -> Error {
|
||||
type RunArgs = (i32, i32, i32, i32);
|
||||
|
||||
impl Plugin {
|
||||
/// Validates and loads a module: checks the ABI version, the exports and reads the manifest.
|
||||
/// Validates and loads a module: checks the ABI version, the exports and reads the manifest, and
|
||||
/// checks that each host import it asks for is one the host offers ([`crate::host::IMPORTS`])
|
||||
/// and its manifest allows.
|
||||
pub fn load(bytes: &[u8], limits: Limits) -> Result<Plugin> {
|
||||
if bytes.len() > limits.max_module_bytes {
|
||||
return Err(Error::Module(format!("module is {} bytes (limit {})", bytes.len(), limits.max_module_bytes)));
|
||||
@@ -168,17 +149,12 @@ impl Plugin {
|
||||
.set_max_recursion_depth(limits.max_recursion_depth.max(16));
|
||||
let engine = Engine::new(&config);
|
||||
let module = Module::new(&engine, bytes).map_err(|e| Error::Module(e.to_string()))?;
|
||||
if let Some(imp) = module.imports().next() {
|
||||
return Err(Error::Abi(format!(
|
||||
"the module imports `{}.{}`, but plug-ins get no host imports (no WASI, files or network)",
|
||||
imp.module(),
|
||||
imp.name()
|
||||
)));
|
||||
}
|
||||
let imports = host::check_imports(&module)?;
|
||||
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 deadline = Deadline::new(p.limits.wall_time_ms);
|
||||
// The manifest isn't read yet: every host import is linked, and what it reports is dropped.
|
||||
let mut live = p.instantiate(Run::new("", false, false, deadline), true)?;
|
||||
let version: TypedFunc<(), i32> = live.func("vc_abi_version")?;
|
||||
let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?;
|
||||
if v != ABI_VERSION {
|
||||
@@ -191,6 +167,7 @@ impl Plugin {
|
||||
e => e,
|
||||
})?;
|
||||
p.manifest = Manifest::parse(&text)?;
|
||||
host::check_granted(&p.manifest, &imports)?;
|
||||
// A heavy filter's runs get the larger budget the host allows for one, which the host
|
||||
// lets the user allow or refuse before installing it.
|
||||
if p.manifest.budget == crate::Budget::Heavy {
|
||||
@@ -237,15 +214,16 @@ impl Plugin {
|
||||
self.serial = serial;
|
||||
}
|
||||
|
||||
fn instantiate(&self) -> Result<Live> {
|
||||
/// A new instance for `run`, with the host imports linked when `granted`.
|
||||
fn instantiate(&self, run: Run, granted: bool) -> Result<Live> {
|
||||
let limits =
|
||||
StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
|
||||
let mut store = Store::new(&self.engine, limits);
|
||||
store.limiter(|l| l);
|
||||
let mut store = Store::new(&self.engine, Host { limits, run });
|
||||
store.limiter(|h| &mut h.limits);
|
||||
// The start function (if any) runs on one slice of fuel: it runs before `call` can check
|
||||
// the deadline or a cancel between slices, so a heavy budget would let it run for minutes.
|
||||
store.set_fuel(self.limits.fuel_base.min(FUEL_SLICE)).map_err(wasm_err)?;
|
||||
let linker = Linker::<StoreLimits>::new(&self.engine);
|
||||
let linker = host::linker(&self.engine, granted);
|
||||
let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?;
|
||||
let memory =
|
||||
instance.get_memory(&store, "memory").ok_or_else(|| Error::Abi("the module must export its linear memory as `memory`".into()))?;
|
||||
@@ -288,21 +266,39 @@ impl Plugin {
|
||||
|
||||
/// [`Plugin::run`] with other time and instruction budgets (live effects get a shorter one).
|
||||
pub fn run_limited(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result<Vec<u8>> {
|
||||
// wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must
|
||||
// still not take the document down (native builds; wasm aborts on panic anyway).
|
||||
let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits)));
|
||||
run.map_err(|_| Error::Trap("the runtime panicked".into()))?
|
||||
self.run_reporting(input, params, limits).0
|
||||
}
|
||||
|
||||
fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result<Vec<u8>> {
|
||||
/// [`Plugin::run_limited`], and what the run reported through the host imports: its last
|
||||
/// progress report and its log lines, which also go to the app's log under its id. Inside a
|
||||
/// [`vectorcraft_doc::cancel::scope`] its progress goes to the scope's sink (a live effect's
|
||||
/// doesn't) and `vc_cancelled` sees the scope's Cancel.
|
||||
pub fn run_reporting(&self, input: &[u8], params: &[u8], limits: &Limits) -> (Result<Vec<u8>>, Report) {
|
||||
let mut report = Report::default();
|
||||
// wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must
|
||||
// still not take the document down (native builds; wasm aborts on panic anyway).
|
||||
let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits, &mut report)));
|
||||
(run.map_err(|_| Error::Trap("the runtime panicked".into())).and_then(|r| r), report)
|
||||
}
|
||||
|
||||
fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report) -> Result<Vec<u8>> {
|
||||
let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2);
|
||||
total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?;
|
||||
let deadline = Deadline::new(limits);
|
||||
let mut live = self.instantiate()?;
|
||||
let deadline = Deadline::new(limits.wall_time_ms);
|
||||
let m = &self.manifest;
|
||||
let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline);
|
||||
let mut live = self.instantiate(run, m.permissions().contains(&Permission::Progress))?;
|
||||
let out = self.run_live(&mut live, input, params, limits, &deadline);
|
||||
*report = std::mem::take(&mut live.store.data_mut().run.report);
|
||||
out
|
||||
}
|
||||
|
||||
/// Hands `input` and `params` to the instance `live` and runs `vc_run` → the output JSON bytes.
|
||||
fn run_live(&self, live: &mut Live, input: &[u8], params: &[u8], limits: &Limits, deadline: &Deadline) -> Result<Vec<u8>> {
|
||||
let alloc: TypedFunc<i32, i32> = live.func("vc_alloc")?;
|
||||
let run: TypedFunc<RunArgs, i64> = live.func("vc_run")?;
|
||||
let place = |live: &mut Live, bytes: &[u8]| -> Result<usize> {
|
||||
let ptr = self.call(live, &alloc, bytes.len().max(1) as i32, limits.fuel_base, &deadline)? as u32 as usize;
|
||||
let ptr = self.call(live, &alloc, bytes.len().max(1) as i32, limits.fuel_base, deadline)? as u32 as usize;
|
||||
if ptr == 0 {
|
||||
return Err(Error::Failed("vc_alloc returned 0 (out of memory)".into()));
|
||||
}
|
||||
@@ -311,10 +307,14 @@ impl Plugin {
|
||||
live.memory.data_mut(&mut live.store).get_mut(ptr..end).ok_or_else(outside)?.copy_from_slice(bytes);
|
||||
Ok(ptr)
|
||||
};
|
||||
let iptr = place(&mut live, input)?;
|
||||
let pptr = place(&mut live, params)?;
|
||||
let iptr = place(live, input)?;
|
||||
let pptr = place(live, params)?;
|
||||
let fuel = limits.fuel_base.saturating_add((input.len() as u64).saturating_mul(limits.fuel_per_byte));
|
||||
let packed = self.call(&mut live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, &deadline)?;
|
||||
let packed = self.call(live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, deadline)?;
|
||||
// A cancelled run, or one past its time budget, changes nothing, also when the plug-in saw
|
||||
// `vc_cancelled` and returned.
|
||||
vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?;
|
||||
deadline.check()?;
|
||||
if packed < 0 {
|
||||
return Err(Error::Failed(format!("vc_run returned error code {packed}")));
|
||||
}
|
||||
|
||||
@@ -42,11 +42,51 @@ pub fn returning(manifest: &str, output: &str) -> String {
|
||||
/// A plug-in whose `vc_run` runs `first` (instructions, with the locals it declares first), then
|
||||
/// returns `output`.
|
||||
fn then_returning(manifest: &str, first: &str, output: &str) -> String {
|
||||
with_imports(manifest, "", first, output)
|
||||
}
|
||||
|
||||
/// A plug-in with the module fields `imports` before the others, whose `vc_run` runs `first`, then
|
||||
/// returns `output`.
|
||||
pub fn with_imports(manifest: &str, imports: &str, first: &str, output: &str) -> String {
|
||||
let run = format!("{first} (i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len());
|
||||
let extra = format!(r#"(data (i32.const {OUTPUT_AT}) "{}")"#, string(output));
|
||||
let extra = format!(r#"{imports} (data (i32.const {OUTPUT_AT}) "{}")"#, string(output));
|
||||
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
|
||||
}
|
||||
|
||||
/// The host imports of plug-in API level 2 ([`crate::host::IMPORTS`]), as `$vc_progress`,
|
||||
/// `$vc_cancelled` and `$vc_log`.
|
||||
pub const HOST_IMPORTS: &str = r#"(import "env" "vc_progress" (func $vc_progress (param i64 i64)))
|
||||
(import "env" "vc_cancelled" (func $vc_cancelled (result i32)))
|
||||
(import "env" "vc_log" (func $vc_log (param i32 i32 i32) (result i32)))"#;
|
||||
|
||||
/// A plug-in of API level 2 (its manifest gives `api` 2 and lists `progress`) that imports the
|
||||
/// host functions ([`HOST_IMPORTS`]) and whose `vc_run` runs `first`, which may call them, then
|
||||
/// returns `output`.
|
||||
pub fn reporting(manifest: &str, first: &str, output: &str) -> String {
|
||||
with_imports(manifest, HOST_IMPORTS, first, output)
|
||||
}
|
||||
|
||||
/// A plug-in of API level 2 that counts `steps` down, reporting each step's progress and checking
|
||||
/// `vc_cancelled` before it: when that says the run was cancelled it returns `stopped`, else
|
||||
/// `output` once the steps are done.
|
||||
pub fn cooperating(manifest: &str, steps: u64, stopped: &str, output: &str) -> String {
|
||||
let first = format!(
|
||||
r#"(local $n i64) (local.set $n (i64.const {steps}))
|
||||
(block $done (loop $step
|
||||
(br_if $done (i64.le_s (local.get $n) (i64.const 0)))
|
||||
(call $vc_progress (i64.sub (i64.const {steps}) (local.get $n)) (i64.const {steps}))
|
||||
(if (call $vc_cancelled) (then (return (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const {STOPPED_AT})))))
|
||||
(local.set $n (i64.sub (local.get $n) (i64.const 1)))
|
||||
(br $step)))"#,
|
||||
len = stopped.len(),
|
||||
);
|
||||
let imports = format!(r#"{HOST_IMPORTS} (data (i32.const {STOPPED_AT}) "{}")"#, string(stopped));
|
||||
with_imports(manifest, &imports, &first, output)
|
||||
}
|
||||
|
||||
/// Where [`cooperating`] keeps what it returns when it is cancelled.
|
||||
const STOPPED_AT: usize = 0x8000;
|
||||
|
||||
/// A plug-in that counts `steps` down before it returns `output`. Each step costs a few
|
||||
/// instructions, so a test picks work that a light budget can't finish and a heavy one can, or
|
||||
/// that runs until it is cancelled.
|
||||
|
||||
+135
-1
@@ -6,7 +6,7 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, heavy_terms, registry, wat as tpl};
|
||||
use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, Report, heavy_terms, registry, wat as tpl};
|
||||
|
||||
/// The example Desaturate plug-in, built from `crates/plugins/example`.
|
||||
const DESATURATE: &[u8] = include_bytes!("fixtures/desaturate.wasm");
|
||||
@@ -308,6 +308,140 @@ fn folder_loading_skips_bad_files() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A filter of plug-in API level 2 that may use the host imports.
|
||||
const LEVEL2: &str =
|
||||
r#"{"id":"test.level2","name":"Level 2","version":"1","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}"#;
|
||||
|
||||
/// The error of loading `bytes`, as text.
|
||||
fn load_err(bytes: &[u8]) -> String {
|
||||
Plugin::load(bytes, Limits::default()).expect_err("the module must be refused").to_string()
|
||||
}
|
||||
|
||||
/// A module may import only the host's three functions, with the host's types, and only when its
|
||||
/// manifest gives `api` 2 and lists `progress`; the error names the import it refuses.
|
||||
#[test]
|
||||
fn host_imports_are_checked_when_a_module_is_loaded() {
|
||||
let with = |manifest: &str, import: &str| build(&tpl::with_imports(manifest, import, "", INPUT));
|
||||
let unknown = with(LEVEL2, r#"(import "env" "vc_clock" (func (result i64)))"#);
|
||||
assert!(load_err(&unknown).contains("imports `env.vc_clock`, which the host doesn't offer"), "{}", load_err(&unknown));
|
||||
let elsewhere = with(LEVEL2, r#"(import "vc" "vc_log" (func (param i32 i32 i32) (result i32)))"#);
|
||||
assert!(load_err(&elsewhere).contains("imports `vc.vc_log`, which the host doesn't offer"), "{}", load_err(&elsewhere));
|
||||
let wrong_type = with(LEVEL2, r#"(import "env" "vc_progress" (func (param i32 i32)))"#);
|
||||
assert!(load_err(&wrong_type).contains("imports `env.vc_progress` with another type than the host's: it is (param i64 i64)"));
|
||||
let memory = with(LEVEL2, r#"(import "env" "vc_cancelled" (global i32))"#);
|
||||
assert!(load_err(&memory).contains("imports `env.vc_cancelled` with another type"), "{}", load_err(&memory));
|
||||
let progress = r#"(import "env" "vc_progress" (func (param i64 i64)))"#;
|
||||
let ungranted = r#"{"id":"test.ungranted","name":"U","kind":"filter","api":2,"permissions":["doc.read","doc.write"]}"#;
|
||||
let says = "imports `env.vc_progress`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `progress`";
|
||||
for manifest in [ungranted, r#"{"id":"test.implied","name":"U","kind":"filter","api":2}"#, MANIFEST] {
|
||||
let e = load_err(&with(manifest, progress));
|
||||
assert!(e.contains(says), "{manifest}: {e}");
|
||||
}
|
||||
let p = Plugin::load(&with(LEVEL2, progress), Limits::default()).unwrap();
|
||||
assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes());
|
||||
// A level-2 plug-in that imports nothing runs as a level-1 one does.
|
||||
let plain = Plugin::load(&build(&tpl::returning(LEVEL2, INPUT)), Limits::default()).unwrap();
|
||||
assert_eq!(plain.run_reporting(INPUT.as_bytes(), b"{}", plain.limits()), (Ok(INPUT.as_bytes().to_vec()), Report::default()));
|
||||
}
|
||||
|
||||
/// Progress is clamped and passed on to the caller's sink as a share; the run's last report is kept.
|
||||
#[test]
|
||||
fn progress_reports_are_clamped_and_reach_the_caller() {
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
let first = "(call $vc_progress (i64.const 50) (i64.const 10)) (call $vc_progress (i64.const -3) (i64.const 8)) (call $vc_progress (i64.const 3) (i64.const 8))";
|
||||
let p = Plugin::load(&build(&tpl::reporting(LEVEL2, first, INPUT)), Limits::default()).unwrap();
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = {
|
||||
let seen = seen.clone();
|
||||
Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string())))
|
||||
};
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
let (out, report) = vectorcraft_doc::cancel::scope(&token, Some(sink), || p.run_reporting(INPUT.as_bytes(), b"{}", p.limits()));
|
||||
assert_eq!(out.unwrap(), INPUT.as_bytes());
|
||||
assert_eq!(report.progress, Some((3, 8)));
|
||||
let step = vectorcraft_plugins::host::PROGRESS_STEP.to_string();
|
||||
assert_eq!(*seen.borrow(), [(10, 10, step.clone()), (0, 8, step.clone()), (3, 8, step)]);
|
||||
// A live effect's progress stays its own: it is drawn as part of something else.
|
||||
let effect = r#"{"id":"test.level2.effect","name":"E","kind":"effect","api":2,"permissions":["doc.read","effect.render","progress"]}"#;
|
||||
let e = Plugin::load(&build(&tpl::reporting(effect, first, INPUT)), Limits::default()).unwrap();
|
||||
let quiet = Rc::new(RefCell::new(0));
|
||||
let sink: vectorcraft_doc::cancel::ProgressSink = {
|
||||
let quiet = quiet.clone();
|
||||
Rc::new(move |_, _, _| *quiet.borrow_mut() += 1)
|
||||
};
|
||||
let (_, report) = vectorcraft_doc::cancel::scope(&token, Some(sink), || e.run_reporting(INPUT.as_bytes(), b"{}", e.limits()));
|
||||
assert_eq!((report.progress, *quiet.borrow()), (Some((3, 8)), 0));
|
||||
}
|
||||
|
||||
/// Cancel makes `vc_cancelled` return 1, and a plug-in that checks it stops at once; what it returns
|
||||
/// then is discarded, so the run is `Cancelled`; past the time budget the same holds. One that
|
||||
/// ignores it still ends at its instruction budget. Outside a cancelled scope it returns 0.
|
||||
#[test]
|
||||
fn cancel_reaches_a_cooperating_plugin_and_a_budget_ends_one_that_ignores_it() {
|
||||
let p =
|
||||
Plugin::load(&build(&tpl::cooperating(LEVEL2, 1 << 40, r#"{"stopped":1}"#, INPUT)), Limits { fuel_base: u64::MAX / 4, ..Limits::default() })
|
||||
.unwrap();
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
token.cancel();
|
||||
let (out, report) = vectorcraft_doc::cancel::scope(&token, None, || p.run_reporting(INPUT.as_bytes(), b"{}", p.limits()));
|
||||
assert_eq!(out, Err(Error::Cancelled), "what it returned after the Cancel is discarded");
|
||||
assert_eq!(report.progress, Some((0, 1 << 40)), "it saw the Cancel on its first step");
|
||||
// Not cancelled: it runs all its steps.
|
||||
let short = Plugin::load(&build(&tpl::cooperating(LEVEL2, 1000, r#"{"stopped":1}"#, INPUT)), Limits::default()).unwrap();
|
||||
let (out, report) = short.run_reporting(INPUT.as_bytes(), b"{}", short.limits());
|
||||
assert_eq!((out.unwrap(), report.progress), (INPUT.as_bytes().to_vec(), Some((999, 1000))));
|
||||
// Past its time budget `vc_cancelled` returns 1 too, and what it returns then is discarded.
|
||||
let late =
|
||||
Plugin::load(&build(&tpl::cooperating(LEVEL2, 1000, r#"{"stopped":1}"#, INPUT)), Limits { wall_time_ms: 0, ..Limits::default() }).unwrap();
|
||||
let (out, report) = late.run_reporting(INPUT.as_bytes(), b"{}", late.limits());
|
||||
assert_eq!((out, report.progress), (Err(Error::Limit("time budget".into())), Some((0, 1000))));
|
||||
// One that never checks ends at its budget, cancelled or not.
|
||||
let ignoring = build(&tpl::reporting(LEVEL2, "(loop $l (br $l))", INPUT));
|
||||
let p = Plugin::load(&ignoring, Limits { fuel_base: 1_000_000, ..Limits::default() }).unwrap();
|
||||
let r = vectorcraft_doc::cancel::scope(&token, None, || p.run(INPUT.as_bytes(), b"{}"));
|
||||
assert_eq!(r, Err(Error::Limit("instruction budget".into())));
|
||||
}
|
||||
|
||||
/// A log line longer than the cap is cut; lines past the run's budget are dropped, with one line
|
||||
/// that says so; text outside memory, text that isn't UTF-8 and an unknown level are errors the
|
||||
/// plug-in sees, never a panic.
|
||||
#[test]
|
||||
fn log_lines_are_capped_and_bad_text_is_an_error_the_plugin_sees() {
|
||||
use vectorcraft_plugins::host::{self, LOG_BAD_LEVEL, LOG_NOT_UTF8, LOG_OUTSIDE_MEMORY, MAX_LOG_LINE, MAX_LOG_LINES};
|
||||
// `$text` at 0x2000 holds "hi", then 0xff, then 2000 bytes of "x".
|
||||
let expect = |level: i32, ptr: &str, len: &str, want: i32| {
|
||||
format!("(if (i32.ne (call $vc_log (i32.const {level}) {ptr} {len}) (i32.const {want})) (then unreachable))")
|
||||
};
|
||||
let first = [
|
||||
expect(2, "(i32.const 0x2000)", "(i32.const 2)", 0),
|
||||
expect(1, "(i32.const 0x2003)", "(i32.const 2000)", 0),
|
||||
expect(2, "(i32.const -1)", "(i32.const 5)", LOG_OUTSIDE_MEMORY),
|
||||
expect(2, "(i32.const 0x2000)", "(i32.const -1)", LOG_OUTSIDE_MEMORY),
|
||||
expect(2, "(i32.mul (memory.size) (i32.const 65536))", "(i32.const 1)", LOG_OUTSIDE_MEMORY),
|
||||
expect(2, "(i32.const 0x2000)", "(i32.const 3)", LOG_NOT_UTF8),
|
||||
expect(9, "(i32.const 0x2000)", "(i32.const 2)", LOG_BAD_LEVEL),
|
||||
]
|
||||
.concat();
|
||||
let data = format!(r#"(data (i32.const 0x2000) "hi\ff{}")"#, "x".repeat(2000));
|
||||
let imports = format!("{} {data}", tpl::HOST_IMPORTS);
|
||||
let p = Plugin::load(&build(&tpl::with_imports(LEVEL2, &imports, &first, INPUT)), Limits::default()).unwrap();
|
||||
let (out, report) = p.run_reporting(INPUT.as_bytes(), b"{}", p.limits());
|
||||
assert_eq!(out.unwrap(), INPUT.as_bytes(), "every call returned what it should");
|
||||
assert_eq!(report.lines.len(), 2);
|
||||
assert_eq!(report.lines[0], vectorcraft_plugins::LogLine { level: vectorcraft_plugins::LogLevel::Info, text: "hi".into() });
|
||||
assert_eq!(report.lines[1].text, format!("{}…", "x".repeat(MAX_LOG_LINE - 2)), "cut at the cap, ellipsis included");
|
||||
// Past the budget: dropped, with one line that says so.
|
||||
let flood = format!(
|
||||
"(local $i i32) (loop $l (drop (call $vc_log (i32.const 2) (i32.const 0x2000) (i32.const 2))) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br_if $l (i32.lt_u (local.get $i) (i32.const {}))))",
|
||||
MAX_LOG_LINES * 3
|
||||
);
|
||||
let p = Plugin::load(&build(&tpl::with_imports(LEVEL2, &imports, &flood, INPUT)), Limits::default()).unwrap();
|
||||
let (out, report) = p.run_reporting(INPUT.as_bytes(), b"{}", p.limits());
|
||||
assert!(out.is_ok() && report.truncated);
|
||||
assert_eq!(report.lines.len(), MAX_LOG_LINES + 1);
|
||||
assert_eq!(report.lines.iter().filter(|l| l.text == host::LOG_TRUNCATED).count(), 1);
|
||||
}
|
||||
|
||||
/// A heavy filter installs once the user agrees, which loading a folder doesn't ask: it is skipped
|
||||
/// and reported.
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The export progress dialog: while an export runs in the background (Background Export) a modal
|
||||
//! window shows what is being exported, how far it has got (artboard by artboard for raster
|
||||
//! exports of several artboards, file by file for Export for Screens; busy for one artboard and for
|
||||
//! formats made in one go, which report no steps) and two buttons: Cancel (`file.cancelExport`, also
|
||||
//! exports of several artboards, file by file for Export for Screens, as a share for an exporter
|
||||
//! plug-in that reports its progress; busy for one artboard and for formats made in one go, which
|
||||
//! report no steps) and two buttons: Cancel (`file.cancelExport`, also
|
||||
//! Esc) stops it and writes nothing (Export for Screens keeps the files it wrote before the
|
||||
//! Cancel), Continue in Background (`file.exportInBackground`) closes the
|
||||
//! dialog and leaves the export to the status bar.
|
||||
@@ -24,8 +25,8 @@ fn seen_id() -> egui::Id {
|
||||
}
|
||||
|
||||
/// What the dialog shows: (label, steps done, steps in all, share done, exports after this one,
|
||||
/// whether the steps are files).
|
||||
fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize, bool)> {
|
||||
/// what the steps are).
|
||||
fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize, Steps)> {
|
||||
let jobs: Vec<_> = app.background.jobs.iter().filter(|j| !j.hidden && !j.cancelled()).filter(|j| j.progress.is_some()).collect();
|
||||
let first = jobs.first()?;
|
||||
let since = ctx.data_mut(|m| {
|
||||
@@ -40,8 +41,22 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String,
|
||||
}
|
||||
let (done, total) = first.progress.as_ref().map(|p| p.counts()).unwrap_or_default();
|
||||
let frac = first.progress.as_ref().and_then(|p| p.fraction()).unwrap_or(0.0) as f32;
|
||||
let files = first.progress.as_ref().is_some_and(|p| p.counts_files());
|
||||
Some((first.label.clone(), done, total, frac, jobs.len() - 1, files))
|
||||
let steps = match &first.progress {
|
||||
Some(p) if p.counts_share() => Steps::Share,
|
||||
Some(p) if p.counts_files() => Steps::Files,
|
||||
_ => Steps::Artboards,
|
||||
};
|
||||
Some((first.label.clone(), done, total, frac, jobs.len() - 1, steps))
|
||||
}
|
||||
|
||||
/// What an export's steps are.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Steps {
|
||||
Artboards,
|
||||
/// Files (Export for Screens).
|
||||
Files,
|
||||
/// An exporter plug-in's own steps, shown as a share of the work.
|
||||
Share,
|
||||
}
|
||||
|
||||
/// Draws the window for the first export running in the background that wasn't hidden or
|
||||
@@ -49,7 +64,7 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String,
|
||||
/// of the button pressed.
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let Some((label, done, total, frac, more, files)) = shown(app, now, ctx) else { return };
|
||||
let Some((label, done, total, frac, more, steps)) = shown(app, now, ctx) else { return };
|
||||
if app.ui.dialog.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -57,8 +72,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let heading = format!("{}…", crate::i18n::msg(&label));
|
||||
let pressed = window(ctx, "export-progress", &heading, |ui| {
|
||||
// One step says nothing a spinner doesn't: the bar would sit at 0 % until the file is written.
|
||||
if total > 1 {
|
||||
let step = if files { tl!("File {n} of {total}") } else { tl!("Artboard {n} of {total}") };
|
||||
if steps == Steps::Share && total > 0 {
|
||||
share(ui, tl!("Encoding and writing the file…"), frac);
|
||||
} else if total > 1 {
|
||||
let step = if steps == Steps::Files { tl!("File {n} of {total}") } else { tl!("Artboard {n} of {total}") };
|
||||
let step = crate::i18n::fmt(step, &[("n", &(done + 1).min(total).to_string()), ("total", &total.to_string())]);
|
||||
// The count on its own line: drawn over the bar it would straddle the fill's edge.
|
||||
ui.horizontal(|ui| {
|
||||
@@ -126,6 +143,24 @@ pub fn window(ctx: &egui::Context, id: &str, heading: &str, body: impl FnOnce(&m
|
||||
pressed
|
||||
}
|
||||
|
||||
/// Work that reports the share it has done (0 to 1), in a [`window`]: `what` it does, in the UI
|
||||
/// language, and the share as a percentage, over a bar filled that far. A plug-in's own steps are
|
||||
/// shown this way.
|
||||
pub fn share(ui: &mut egui::Ui, what: &str, done: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let done = if done.is_finite() { done.clamp(0.0, 1.0) } else { 0.0 };
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(what).color(t.text_dim));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(format!("{:.0} %", done * 100.0)).color(t.text_dim));
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.add(egui::ProgressBar::new(done).desired_width(360.0).desired_height(6.0).fill(t.accent));
|
||||
// The work reports from another thread: draw what it reports next.
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
|
||||
/// Work that counts no steps, in a [`window`]: a spinner and `what` it does, in the UI language.
|
||||
pub fn busy(ui: &mut egui::Ui, what: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
||||
@@ -5716,6 +5716,7 @@ axis Y Axis Y ос
|
||||
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 Записване на документа като файл в собствен формат
|
||||
Show its progress, see Cancel and write log lines Показване на напредъка, разпознаване на отказ и записване на редове в дневника
|
||||
# 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,11 @@ axis Y Axis Y osa
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Prikazati napredak, prepoznati otkazivanje i zapisivati redove dnevnika
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `progress` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} modul uvozi `env.{name}` s drugim tipom od domaćinovog: domaćinov tip je {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul uvozi `env.{name}`, koji dodatak smije koristiti samo na nivou API-ja dodataka {PROGRESS_API} (`api` {PROGRESS_API}) i s dozvolom `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul uvozi `{from}.{name}`, koji domaćin ne nudi: dodatak smije uvoziti samo `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivo API-ja dodataka 2), a nikakav WASI, datoteke ni mrežu
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5852,6 +5852,11 @@ digits Malayalam Malaiàlam
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Mostrar el progrés, detectar la cancel·lació i escriure línies de registre
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `progress` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} el mòdul importa `env.{name}` amb un tipus diferent del de l’amfitrió: el de l’amfitrió és {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el mòdul importa `env.{name}`, que un complement només pot fer servir al nivell d’API de complements {PROGRESS_API} (`api` {PROGRESS_API}) i amb el permís `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el mòdul importa `{from}.{name}`, que l’amfitrió no ofereix: un complement només pot importar `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivell d’API de complements 2), i cap WASI, fitxer ni xarxa
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5832,6 +5832,11 @@ axis Y Axis osa Y
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Zobrazit průběh, rozpoznat zrušení a zapisovat řádky protokolu
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `progress` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} modul importuje `env.{name}` s jiným typem, než má hostitel: typ hostitele je {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul importuje `env.{name}`, který zásuvný modul smí používat jen na úrovni API zásuvných modulů {PROGRESS_API} (`api` {PROGRESS_API}) a s oprávněním `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul importuje `{from}.{name}`, který hostitel nenabízí: zásuvný modul smí importovat jen `env.vc_progress`, `env.vc_cancelled` a `env.vc_log` (úroveň API zásuvných modulů 2), a žádné WASI, soubory ani síť
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5731,6 +5731,7 @@ axis Y Axis Y-akse
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Vise sin fremdrift, se en annullering og skrive logrækker
|
||||
# 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.
|
@@ -5414,6 +5414,11 @@ axis Y Axis Y-Achse
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Den Fortschritt anzeigen, ein Abbrechen erkennen und Protokollzeilen schreiben
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `progress` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} das Modul importiert `env.{name}` mit einem anderen Typ als dem des Hosts: Der Typ des Hosts ist {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` das Modul importiert `env.{name}`, das ein Plug-in nur auf der Plug-in-API-Stufe {PROGRESS_API} (`api` {PROGRESS_API}) und mit der Berechtigung `progress` verwenden darf
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network das Modul importiert `{from}.{name}`, das der Host nicht anbietet: Ein Plug-in darf nur `env.vc_progress`, `env.vc_cancelled` und `env.vc_log` importieren (Plug-in-API-Stufe 2), aber kein WASI, keine Dateien und kein Netzwerk
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5783,6 +5783,11 @@ axis Y Axis Άξονας Y
|
||||
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 Εγγραφή του εγγράφου ως αρχείου σε δική του μορφή
|
||||
Show its progress, see Cancel and write log lines Εμφάνιση της προόδου, αναγνώριση της ακύρωσης και εγγραφή γραμμών καταγραφής
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `progress` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} η λειτουργική μονάδα εισάγει το `env.{name}` με άλλον τύπο από αυτόν του κεντρικού υπολογιστή: ο τύπος του είναι {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` η λειτουργική μονάδα εισάγει το `env.{name}`, το οποίο ένα πρόσθετο μπορεί να χρησιμοποιεί μόνο στο επίπεδο API προσθέτων {PROGRESS_API} (`api` {PROGRESS_API}) και με το δικαίωμα `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network η λειτουργική μονάδα εισάγει το `{from}.{name}`, το οποίο δεν προσφέρει ο κεντρικός υπολογιστής: ένα πρόσθετο μπορεί να εισάγει μόνο τα `env.vc_progress`, `env.vc_cancelled` και `env.vc_log` (επίπεδο API προσθέτων 2) και κανένα WASI, αρχεία ή δίκτυο
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 168 and column 5.
|
@@ -5773,6 +5773,11 @@ axis Y Axis Eje Y
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Mostrar su progreso, detectar la cancelación y escribir líneas de registro
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `progress` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} el módulo importa `env.{name}` con un tipo distinto del del host: el del host es {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el módulo importa `env.{name}`, que un plugin solo puede usar en el nivel de API de plugins {PROGRESS_API} (`api` {PROGRESS_API}) y con el permiso `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el módulo importa `{from}.{name}`, que el host no ofrece: un plugin solo puede importar `env.vc_progress`, `env.vc_cancelled` y `env.vc_log` (nivel de API de plugins 2), y ningún WASI, archivo ni red
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5862,6 +5862,11 @@ axis Y Axis Y-akseli
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Näyttää edistymisensä, havaita peruutuksen ja kirjoittaa lokirivejä
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `progress` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} moduuli tuo kohteen `env.{name}` eri tyyppisenä kuin isäntä: isännän tyyppi on {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduuli tuo kohteen `env.{name}`, jota laajennus saa käyttää vain laajennus-API:n tasolla {PROGRESS_API} (`api` {PROGRESS_API}) ja käyttöoikeudella `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduuli tuo kohteen `{from}.{name}`, jota isäntä ei tarjoa: laajennus saa tuoda vain kohteet `env.vc_progress`, `env.vc_cancelled` ja `env.vc_log` (laajennus-API:n taso 2) eikä mitään WASIa, tiedostoja tai verkkoa
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5827,6 +5827,11 @@ axis Y Axis Axe Y
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Afficher sa progression, détecter l’annulation et écrire des lignes de journal
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `progress` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} le module importe `env.{name}` avec un autre type que celui de l’hôte : celui de l’hôte est {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` le module importe `env.{name}`, qu’un module externe ne peut utiliser qu’au niveau d’API de module externe {PROGRESS_API} (`api` {PROGRESS_API}) et avec l’autorisation `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network le module importe `{from}.{name}`, que l’hôte ne propose pas : un module externe ne peut importer que `env.vc_progress`, `env.vc_cancelled` et `env.vc_log` (niveau d’API de module externe 2), et aucun WASI, fichier ni réseau
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5224,6 +5224,11 @@
|
||||
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 डॉक्यूमेंट को अपने फ़ॉर्मेट की फ़ाइल के रूप में लिखना
|
||||
Show its progress, see Cancel and write log lines अपनी प्रगति दिखाना, रद्द करने का अनुरोध देखना और लॉग पंक्तियाँ लिखना
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `progress` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} मॉड्यूल `env.{name}` को होस्ट से अलग प्रकार के साथ आयात करता है: होस्ट का प्रकार {signature} है
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` मॉड्यूल `env.{name}` आयात करता है, जिसे प्लग-इन केवल प्लग-इन API स्तर {PROGRESS_API} (`api` {PROGRESS_API}) पर और अनुमति `progress` के साथ उपयोग कर सकता है
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network मॉड्यूल `{from}.{name}` आयात करता है, जिसे होस्ट उपलब्ध नहीं कराता: प्लग-इन केवल `env.vc_progress`, `env.vc_cancelled` और `env.vc_log` (प्लग-इन API स्तर 2) आयात कर सकता है, और कोई WASI, फ़ाइल या नेटवर्क नहीं
|
||||
@msg `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}` बताता है
|
||||
@@ -7407,7 +7412,6 @@
|
||||
@msg the library holds {MAX_GROUPS} groups already लाइब्रेरी में पहले से ही {MAX_GROUPS} समूह हैं।
|
||||
@msg the library holds {MAX_ITEMS} items of this kind already लाइब्रेरी पहले से ही इस तरह के {MAX_ITEMS} आइटम रखता है
|
||||
@msg the library item is damaged लाइब्रेरी आइटम क्षतिग्रस्त है
|
||||
@msg the module imports `{_1}.{_2}`, but plug-ins get no host imports (no WASI, files or network) मॉड्यूल आयात `{_1}.{_2}`, लेकिन प्लग-इन को कोई होस्ट आयात नहीं मिलता है (कोई WASI, फ़ाइल या नेटवर्क नहीं)
|
||||
@msg the module must export its linear memory as `memory` मॉड्यूल को अपनी रैखिक मेमोरी `memory` के रूप में एक्सपोर्ट करनी चाहिए
|
||||
@msg the name is empty नाम खाली है
|
||||
@msg the object already has an opacity mask ऑब्जेक्ट पहले से ही एक अपारदर्शिता मास्क है
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5815,6 +5815,11 @@ axis Y Axis Y tengely
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Megjelenítheti az előrehaladását, észlelheti a megszakítást és naplósorokat írhat
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a `progress` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} a modul az `env.{name}` elemet a gazdaprogramétól eltérő típussal importálja: a gazdaprogram típusa {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` a modul importálja az `env.{name}` elemet, amelyet bővítmény csak {PROGRESS_API}. szintű bővítmény-API-val (`api` {PROGRESS_API}) és a `progress` engedéllyel használhat
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network a modul importálja a(z) `{from}.{name}` elemet, amelyet a gazdaprogram nem kínál: bővítmény csak az `env.vc_progress`, `env.vc_cancelled` és `env.vc_log` elemet importálhatja (2. szintű bővítmény-API), WASI-t, fájlokat és hálózatot nem
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5731,6 +5731,7 @@ axis Y Axis Sumbu Y
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Menampilkan kemajuannya, mengenali pembatalan, dan menulis baris log
|
||||
# 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 2260 and column 5.
|
@@ -5448,6 +5448,11 @@ axis Y Axis Asse Y
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Mostrare il proprio avanzamento, rilevare l’annullamento e scrivere righe di registro
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `progress` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} il modulo importa `env.{name}` con un tipo diverso da quello dell’host: quello dell’host è {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` il modulo importa `env.{name}`, che un plug-in può usare solo al livello API dei plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e con l’autorizzazione `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network il modulo importa `{from}.{name}`, che l’host non offre: un plug-in può importare solo `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (livello API dei plug-in 2), e nessun WASI, file o rete
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5513,6 +5513,11 @@ axis Y Axis Y 軸
|
||||
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 ドキュメントを独自形式のファイルとして書き出し
|
||||
Show its progress, see Cancel and write log lines 進行状況の表示、キャンセルの検知、ログ行の書き込み
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `progress` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} モジュールが `env.{name}` をホストと異なる型でインポートしています。ホストの型は {signature} です
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` モジュールが `env.{name}` をインポートしていますが、プラグインがこれを使用できるのはプラグイン API レベル {PROGRESS_API} (`api` {PROGRESS_API}) で権限 `progress` がある場合だけです
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network モジュールがホストの提供しない `{from}.{name}` をインポートしています。プラグインがインポートできるのは `env.vc_progress`、`env.vc_cancelled`、`env.vc_log` (プラグイン API レベル 2) だけで、WASI、ファイル、ネットワークは使用できません
|
||||
@msg `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}` が含まれています
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5755,6 +5755,11 @@ axis Y Axis Y ღერძი
|
||||
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 დოკუმენტის ჩაწერა ფაილად საკუთარ ფორმატში
|
||||
Show its progress, see Cancel and write log lines პროგრესის ჩვენება, გაუქმების შემჩნევა და ჟურნალის სტრიქონების ჩაწერა
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `progress` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} მოდულს შემოაქვს `env.{name}` ჰოსტისგან განსხვავებული ტიპით: ჰოსტის ტიპია {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` მოდულს შემოაქვს `env.{name}`, რომლის გამოყენებაც პლაგინს მხოლოდ პლაგინის API-ის დონეზე {PROGRESS_API} (`api` {PROGRESS_API}) და ნებართვით `progress` შეუძლია
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network მოდულს შემოაქვს `{from}.{name}`, რომელსაც ჰოსტი არ გვთავაზობს: პლაგინს შეუძლია შემოიტანოს მხოლოდ `env.vc_progress`, `env.vc_cancelled` და `env.vc_log` (პლაგინის API-ის დონე 2) და არა WASI, ფაილები ან ქსელი
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5409,6 +5409,11 @@ axis Y Axis Y축
|
||||
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 문서를 자체 형식의 파일로 쓰기
|
||||
Show its progress, see Cancel and write log lines 진행률 표시, 취소 감지 및 로그 줄 쓰기
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} 모듈이 `env.{name}`을(를) 호스트와 다른 형식으로 가져옵니다. 호스트의 형식은 {signature}입니다
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 모듈이 `env.{name}`을(를) 가져오지만, 플러그인은 이를 플러그인 API 수준 {PROGRESS_API}(`api` {PROGRESS_API})에서 `progress` 권한이 있을 때만 사용할 수 있습니다
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 모듈이 호스트가 제공하지 않는 `{from}.{name}`을(를) 가져옵니다. 플러그인은 `env.vc_progress`, `env.vc_cancelled`, `env.vc_log`(플러그인 API 수준 2)만 가져올 수 있으며 WASI, 파일, 네트워크는 사용할 수 없습니다
|
||||
@msg `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}`이(가) 있습니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5777,6 +5777,7 @@ axis Y Axis Y-as
|
||||
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
|
||||
Show its progress, see Cancel and write log lines De voortgang tonen, een annulering zien en logregels 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.
|
@@ -5685,6 +5685,11 @@ axis Y Axis Oś Y
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Pokazywać swój postęp, wykrywać anulowanie i zapisywać wiersze dziennika
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `progress` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} moduł importuje `env.{name}` z innym typem niż typ hosta: typ hosta to {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduł importuje `env.{name}`, którego wtyczka może używać tylko na poziomie API wtyczek {PROGRESS_API} (`api` {PROGRESS_API}) i z uprawnieniem `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduł importuje `{from}.{name}`, którego host nie udostępnia: wtyczka może importować tylko `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (poziom API wtyczek 2), bez WASI, plików i sieci
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5747,6 +5747,11 @@ axis Y Axis Eixo Y
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Mostrar seu progresso, detectar o cancelamento e gravar linhas de log
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `progress` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} o módulo importa `env.{name}` com um tipo diferente do tipo do host: o do host é {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` o módulo importa `env.{name}`, que um plug-in só pode usar no nível de API de plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e com a permissão `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network o módulo importa `{from}.{name}`, que o host não oferece: um plug-in só pode importar `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (nível de API de plug-in 2), e nenhum WASI, arquivo ou rede
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -5443,6 +5443,11 @@ axis Y Axis Ось Y
|
||||
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 Записывать документ в файл собственного формата
|
||||
Show its progress, see Cancel and write log lines Показывать свой ход выполнения, замечать отмену и записывать строки журнала
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `progress` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль импортирует `env.{name}` с другим типом, чем у хоста: у хоста тип {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль импортирует `env.{name}`, который модуль может использовать только на уровне API модулей {PROGRESS_API} (`api` {PROGRESS_API}) и с разрешением `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль импортирует `{from}.{name}`, который хост не предоставляет: модуль может импортировать только `env.vc_progress`, `env.vc_cancelled` и `env.vc_log` (уровень API модулей 2), без WASI, файлов и сети
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5855,6 +5855,11 @@ axis Y Axis Y-axel
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Visa sitt förlopp, se när det avbryts och skriva loggrader
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `progress` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} modulen importerar `env.{name}` med en annan typ än värdens: värdens typ är {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modulen importerar `env.{name}`, som ett plugin bara får använda på plugin-API-nivå {PROGRESS_API} (`api` {PROGRESS_API}) och med behörigheten `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modulen importerar `{from}.{name}`, som värden inte erbjuder: ett plugin får bara importera `env.vc_progress`, `env.vc_cancelled` och `env.vc_log` (plugin-API-nivå 2), och ingen WASI, inga filer eller nätverk
|
||||
@msg `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}`
|
||||
@@ -7768,7 +7773,6 @@ digits Hindi Hindi
|
||||
@msg the library has no item `{other}` biblioteket har inget objekt `{other}`
|
||||
@msg the library holds {MAX_GROUPS} groups already biblioteket innehåller redan {MAX_GROUPS} grupper
|
||||
@msg the library holds {MAX_ITEMS} items of this kind already biblioteket innehåller redan {MAX_ITEMS} objekt av den här typen
|
||||
@msg the module imports `{_1}.{_2}`, but plug-ins get no host imports (no WASI, files or network) modulen importerar `{_1}.{_2}`, men plugins får inga värdimporter (ingen WASI, inga filer eller nätverk)
|
||||
@msg the module must export its linear memory as `memory` modulen måste exportera sitt linjära minne som `memory`
|
||||
@msg the name is empty namnet är tomt
|
||||
@msg the object has no fill, stroke or effect objektet har ingen fyllning, kontur eller effekt
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -5789,6 +5789,11 @@ axis Y Axis Y ekseni
|
||||
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
|
||||
Show its progress, see Cancel and write log lines İlerlemesini gösterme, iptali algılama ve günlük satırları yazma
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} modül `env.{name}` öğesini ana bilgisayarınkinden farklı bir türle içe aktarıyor: ana bilgisayarın türü {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modül `env.{name}` öğesini içe aktarıyor; bir eklenti bunu yalnızca eklenti API düzeyi {PROGRESS_API} (`api` {PROGRESS_API}) ile ve `progress` izniyle kullanabilir
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modül, ana bilgisayarın sunmadığı `{from}.{name}` öğesini içe aktarıyor: bir eklenti yalnızca `env.vc_progress`, `env.vc_cancelled` ve `env.vc_log` öğelerini (eklenti API düzeyi 2) içe aktarabilir; WASI, dosya veya ağ kullanamaz
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -5428,6 +5428,11 @@ axis Y Axis Вісь Y
|
||||
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 Записувати документ у файл власного формату
|
||||
Show its progress, see Cancel and write log lines Показувати свій поступ, помічати скасування і записувати рядки журналу
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `progress` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль імпортує `env.{name}` з іншим типом, ніж у хоста: у хоста тип {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль імпортує `env.{name}`, який модуль може використовувати лише на рівні API модулів {PROGRESS_API} (`api` {PROGRESS_API}) і з дозволом `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль імпортує `{from}.{name}`, який хост не надає: модуль може імпортувати лише `env.vc_progress`, `env.vc_cancelled` і `env.vc_log` (рівень API модулів 2), без WASI, файлів і мережі
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -5429,6 +5429,11 @@ axis Y Axis Trục Y
|
||||
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
|
||||
Show its progress, see Cancel and write log lines Hiển thị tiến độ, nhận biết yêu cầu hủy và ghi các dòng nhật ký
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `progress` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} mô-đun nhập `env.{name}` với kiểu khác với kiểu của máy chủ: kiểu của máy chủ là {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` mô-đun nhập `env.{name}`, thứ mà plug-in chỉ được dùng ở API plug-in cấp {PROGRESS_API} (`api` {PROGRESS_API}) và với quyền `progress`
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network mô-đun nhập `{from}.{name}`, thứ mà máy chủ không cung cấp: plug-in chỉ được nhập `env.vc_progress`, `env.vc_cancelled` và `env.vc_log` (API plug-in cấp 2), không có WASI, tệp hay mạng
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -5670,6 +5670,11 @@ axis Y Axis Y 轴
|
||||
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 将文档写为其自有格式的文件
|
||||
Show its progress, see Cancel and write log lines 显示进度、检测取消请求并写入日志行
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `progress` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} 模块以与宿主不同的类型导入 `env.{name}`:宿主的类型为 {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模块导入了 `env.{name}`,增效工具只有在增效工具 API 级别 {PROGRESS_API}(`api` {PROGRESS_API})且具有权限 `progress` 时才能使用它
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模块导入了宿主不提供的 `{from}.{name}`:增效工具只能导入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效工具 API 级别 2),不能使用 WASI、文件或网络
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5466,6 +5466,11 @@ axis Y Axis Y 軸
|
||||
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 將文件寫成其自有格式的檔案
|
||||
Show its progress, see Cancel and write log lines 顯示進度、偵測取消要求並寫入記錄行
|
||||
@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `progress` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API}
|
||||
@msg the module imports `env.{name}` with another type than the host's: it is {signature} 模組以與主機不同的類型匯入 `env.{name}`:主機的類型為 {signature}
|
||||
@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模組匯入了 `env.{name}`,增效模組只有在增效模組 API 層級 {PROGRESS_API}(`api` {PROGRESS_API})且具有權限 `progress` 時才能使用它
|
||||
@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模組匯入了主機未提供的 `{from}.{name}`:增效模組只能匯入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效模組 API 層級 2),不能使用 WASI、檔案或網路
|
||||
@msg `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}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -174,6 +174,7 @@ fn permission_words(name: &str) -> String {
|
||||
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"),
|
||||
Some(permissions::Permission::Progress) => tl!("Show its progress, see Cancel and write log lines"),
|
||||
None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD),
|
||||
};
|
||||
words.to_string()
|
||||
|
||||
@@ -9,12 +9,18 @@
|
||||
//! ([`FilterRun::hand_in`]), so the edit is one undo step like any run, unless the document or the
|
||||
//! plug-in changed meanwhile: then nothing changes and the status bar says to run it again. On the
|
||||
//! web (no threads) it runs at once ([`available`]).
|
||||
//!
|
||||
//! A filter of plug-in API level 2 may report how far it has got (`vc_progress`): the progress
|
||||
//! window then shows a bar with the share done. Its Cancel also makes the filter's `vc_cancelled`
|
||||
//! return 1, so a filter that checks it stops at once; one that doesn't stops at the next slice of
|
||||
//! its budget.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::cancel::{self, CancelToken};
|
||||
use vectorcraft_engine::EngineError;
|
||||
use vectorcraft_engine::cmd::fileio::progress::{self, Progress};
|
||||
use vectorcraft_engine::cmd::plugin::FilterRun;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
@@ -34,8 +40,8 @@ pub struct Running {
|
||||
who: String,
|
||||
/// `plugin.run`'s params that apply its output: the objects it was given as `ids`.
|
||||
params: Value,
|
||||
/// Its Cancel.
|
||||
token: CancelToken,
|
||||
/// How far it has got, as it reports it, and its Cancel.
|
||||
pub progress: Arc<Progress>,
|
||||
/// Continue in Background: the progress window no longer shows it.
|
||||
pub hidden: bool,
|
||||
done: Receiver<Done>,
|
||||
@@ -44,7 +50,7 @@ pub struct Running {
|
||||
impl Running {
|
||||
/// Does the progress window show it: neither hidden (Continue in Background) nor cancelled?
|
||||
fn shown(&self) -> bool {
|
||||
!self.hidden && !self.token.is_cancelled()
|
||||
!self.hidden && !self.progress.cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +78,12 @@ pub fn start(app: &mut VectorcraftApp, p: Value) -> Result<Value, String> {
|
||||
let mut params = p;
|
||||
params["ids"] = json!(run.inputs().iter().map(|i| i.0).collect::<Vec<_>>());
|
||||
let (tx, done) = channel();
|
||||
let token = CancelToken::new();
|
||||
let stop = token.clone();
|
||||
let shown = Progress::new();
|
||||
let reporting = Arc::clone(&shown);
|
||||
std::thread::Builder::new()
|
||||
.name("vectorcraft-plugin".into())
|
||||
.spawn(move || {
|
||||
let output = vectorcraft_engine::guard::catch_panic(|| cancel::scope(&stop, None, || run.run()))
|
||||
let output = vectorcraft_engine::guard::catch_panic(|| progress::scope(&reporting, || run.run()))
|
||||
.map_err(|msg| format!("internal error: {msg} (please report this bug)"))
|
||||
.and_then(|r| r.map_err(|e| e.to_string()));
|
||||
// The app may have quit meanwhile: nothing to hand it to.
|
||||
@@ -87,7 +93,7 @@ pub fn start(app: &mut VectorcraftApp, p: Value) -> Result<Value, String> {
|
||||
let label = format!("Running {who}…");
|
||||
let runs = &mut app.plugin_runs;
|
||||
runs.next_id += 1;
|
||||
runs.running.push(Running { id: runs.next_id, label: label.clone(), who, params, token, hidden: false, done });
|
||||
runs.running.push(Running { id: runs.next_id, label: label.clone(), who, params, progress: shown, hidden: false, done });
|
||||
app.status(label.clone());
|
||||
Ok(json!({ "background": true, "status": label }))
|
||||
}
|
||||
@@ -121,7 +127,7 @@ pub fn poll(app: &mut VectorcraftApp) {
|
||||
/// they were; else why it has none, in the status bar.
|
||||
fn finish(app: &mut VectorcraftApp, r: Running, done: Option<Done>) {
|
||||
// Cancel changes nothing, even when the run ended just before it.
|
||||
if r.token.is_cancelled() {
|
||||
if r.progress.cancelled() {
|
||||
app.status(EngineError::Cancelled.to_string());
|
||||
return;
|
||||
}
|
||||
@@ -146,12 +152,12 @@ fn finish(app: &mut VectorcraftApp, r: Running, done: Option<Done>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The progress window's Cancel (also Esc): every heavy filter running stops at the next slice of
|
||||
/// its budget and changes nothing → `{cancelled: n}`.
|
||||
/// The progress window's Cancel (also Esc): every heavy filter running stops (at once when it checks
|
||||
/// `vc_cancelled`, else at the next slice of its budget) and changes nothing → `{cancelled: n}`.
|
||||
pub fn cancel_all(app: &mut VectorcraftApp) -> Value {
|
||||
let mut n = 0;
|
||||
for r in app.plugin_runs.running.iter().filter(|r| !r.token.is_cancelled()) {
|
||||
r.token.cancel();
|
||||
for r in app.plugin_runs.running.iter().filter(|r| !r.progress.cancelled()) {
|
||||
r.progress.cancel();
|
||||
n += 1;
|
||||
}
|
||||
json!({ "cancelled": n })
|
||||
@@ -170,7 +176,7 @@ pub fn hide_all(app: &mut VectorcraftApp) -> Value {
|
||||
|
||||
/// Is a heavy filter running that hasn't been cancelled?
|
||||
pub fn any_running(app: &VectorcraftApp) -> bool {
|
||||
app.plugin_runs.running.iter().any(|r| !r.token.is_cancelled())
|
||||
app.plugin_runs.running.iter().any(|r| !r.progress.cancelled())
|
||||
}
|
||||
|
||||
/// Is a heavy filter running that the progress window shows?
|
||||
@@ -195,7 +201,11 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
let heading = crate::i18n::msg(&first.label);
|
||||
let pressed = export_progress::window(ctx, "plugin-run-progress", &heading, |ui| export_progress::busy(ui, tl!("The plug-in is computing…")));
|
||||
let reported = first.progress.fraction();
|
||||
let pressed = export_progress::window(ctx, "plugin-run-progress", &heading, |ui| match reported {
|
||||
Some(share) => export_progress::share(ui, tl!("The plug-in is computing…"), share as f32),
|
||||
None => export_progress::busy(ui, tl!("The plug-in is computing…")),
|
||||
});
|
||||
let id = match pressed {
|
||||
Some(Pressed::Cancel) => "plugin.cancelRuns",
|
||||
Some(Pressed::Hide) => "plugin.hideRuns",
|
||||
|
||||
@@ -173,6 +173,46 @@ fn continue_in_background_hides_the_progress_window() {
|
||||
assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string());
|
||||
}
|
||||
|
||||
/// A heavy filter of plug-in API level 2 that reports its progress gets a bar in its progress window,
|
||||
/// with the share it reported, clamped; Cancel makes its `vc_cancelled` return 1 and it stops,
|
||||
/// changing nothing.
|
||||
#[test]
|
||||
fn a_filter_that_reports_progress_shows_a_bar_and_sees_cancel() {
|
||||
let mut app = app();
|
||||
let id = "test.ui.heavy.progress";
|
||||
let manifest =
|
||||
format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","budget":"heavy","api":2,"permissions":["doc.read","doc.write","progress"]}}"#);
|
||||
let first = "(call $vc_progress (i64.const 5000) (i64.const 1000)) (call $vc_progress (i64.const 250) (i64.const 1000))
|
||||
(block $stop (loop $l (br_if $stop (call $vc_cancelled)) (br $l)))";
|
||||
let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::reporting(&manifest, first, r#"{"objects":[]}"#)).unwrap());
|
||||
app.run("plugin.install", json!({"dataBase64": data, "allowHeavy": true, "grant": ["doc.read", "doc.write", "progress"]})).unwrap();
|
||||
let doc = app.session.doc().unwrap().doc.clone();
|
||||
app.run("plugin.dialog", json!({"id": id})).unwrap();
|
||||
// Waits for the run's thread to report the share (a handshake: the thread never stops before
|
||||
// it is cancelled, and a run that ended leaves the loop).
|
||||
loop {
|
||||
plugin_runs::poll(&mut app);
|
||||
match app.plugin_runs.running.first() {
|
||||
Some(r) if r.progress.counts() != (250, 1000) => std::thread::yield_now(),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
assert_eq!(app.plugin_runs.running.first().map(|r| r.progress.counts()), Some((250, 1000)), "the second report, after the first was clamped");
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
progress(&mut app, &ctx, 0.0);
|
||||
progress(&mut app, &ctx, 1.0);
|
||||
let shown = progress(&mut app, &ctx, 1.1);
|
||||
assert!(shown.contains("The plug-in is computing…") && shown.contains("25 %"), "{shown}");
|
||||
assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1);
|
||||
while !app.plugin_runs.running.is_empty() {
|
||||
plugin_runs::poll(&mut app);
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert_eq!(app.session.doc().unwrap().doc, doc);
|
||||
assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string());
|
||||
}
|
||||
|
||||
/// `CRAFT_UI_HEAVY_FILTER_WASM=<folder>` writes a heavy test filter to install in the app
|
||||
/// (`order-paths-test-filter.wasm`): it computes until it is cancelled or its budget runs out, so
|
||||
/// its progress window stays up for a picture.
|
||||
|
||||
@@ -183,3 +183,19 @@ fn installing_a_plugin_that_lists_permissions_asks_first() {
|
||||
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();
|
||||
}
|
||||
|
||||
/// A plug-in of API level 2 that lists `progress` names it in plain words when it asks.
|
||||
#[test]
|
||||
fn installing_a_plugin_that_reports_progress_says_so() {
|
||||
let mut app = app();
|
||||
let id = "test.ui.permissions.progress";
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Count","kind":"filter","api":2,"permissions":["doc.read","progress"]}}"#);
|
||||
let bytes = wat::parse_str(tpl::reporting(&manifest, "(call $vc_progress (i64.const 1) (i64.const 2))", r#"{"objects":[]}"#)).unwrap();
|
||||
let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::confirm::KIND);
|
||||
let detail = app.ui.dialog.as_ref().unwrap().str("detail");
|
||||
assert!(detail.ends_with("• Read the objects it is given\n• Show its progress, see Cancel and write log lines"), "{detail}");
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.permissions.len()), Some(2));
|
||||
app.run("plugin.remove", json!({"id": id})).unwrap();
|
||||
}
|
||||
|
||||
@@ -6100,6 +6100,10 @@ the entry's `hash` as `hash` so that a module that changed since is refused. Wit
|
||||
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.
|
||||
A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write
|
||||
log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log
|
||||
under its id. A module that imports anything else, or imports these without `api` 2 and `progress`, is refused by
|
||||
`plugin.install` and `plugin.inspect` with an error naming the import.
|
||||
`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
|
||||
|
||||
+43
-14
@@ -23,7 +23,7 @@ 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, 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.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) |
|
||||
| `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). |
|
||||
| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. |
|
||||
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. |
|
||||
@@ -67,7 +67,7 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem
|
||||
|
||||
| Limit | Default | On violation |
|
||||
|---|---|---|
|
||||
| Host imports | none: no WASI, file system, network, clock or randomness | module rejected at install |
|
||||
| Host imports | none at API level 1; at level 2 only the three [host imports](#host-imports-api-level-2): no WASI, file system, network, clock or randomness | module rejected at install, naming the import |
|
||||
| Module size | 32 MiB | rejected |
|
||||
| Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails |
|
||||
| Output | 64 MiB of JSON | `Limit("output size")` |
|
||||
@@ -115,15 +115,16 @@ capability model proposed for PhotoCraft plug-ins
|
||||
| `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. |
|
||||
| `progress` | all | Show its progress, see Cancel and write log lines through the [host imports](#host-imports-api-level-2). Needs `api` 2. |
|
||||
|
||||
- A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter
|
||||
`doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and
|
||||
`export.write`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. A filter whose list is
|
||||
empty may do none of these, and also installs without a prompt.
|
||||
- An effect or an exporter that lists permissions lists both of its implied ones. A filter may list any of its three.
|
||||
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.
|
||||
Any kind may add `progress`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply
|
||||
to the kind, more than 16 names, `progress` with an `api` below 2, or one of the names kept for a later plug-in API
|
||||
(`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
|
||||
@@ -140,8 +141,8 @@ capability model proposed for PhotoCraft plug-ins
|
||||
- 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.
|
||||
- `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host
|
||||
imports](#host-imports-api-level-2). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`.
|
||||
- Parameter declarations are unchanged by permissions (`params`, below).
|
||||
|
||||
Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed
|
||||
@@ -164,8 +165,9 @@ geometry: a plug-in runs again only when one of them changes.
|
||||
## ABI v1
|
||||
|
||||
A plug-in is a core WebAssembly module (MVP + the usual post-MVP features: bulk memory, sign extension, multi-value,
|
||||
saturating conversions; no SIMD, no memory64) with **no imports** and these exports. Integers are `i32` unless noted;
|
||||
pointers are offsets into the module's memory.
|
||||
saturating conversions; no SIMD, no memory64) with these exports and no imports other than the [host
|
||||
imports](#host-imports-api-level-2) of API level 2. Integers are `i32` unless noted; pointers are offsets into the
|
||||
module's memory.
|
||||
|
||||
| Export | Signature | Meaning |
|
||||
|---|---|---|
|
||||
@@ -175,6 +177,34 @@ pointers are offsets into the module's memory.
|
||||
| `vc_alloc` | `(size) -> ptr` | A block of `size` bytes the host may write, or `0` on failure. Never freed: each run gets a fresh instance. |
|
||||
| `vc_run` | `(input, input_len, params, params_len) -> i64` | Runs the plug-in on the input JSON with the parameters JSON; returns `(len << 32) \| ptr` of the output JSON, or a negative error code. |
|
||||
|
||||
### Host imports (API level 2)
|
||||
|
||||
A plug-in whose manifest gives `"api": 2` and lists the `progress` [permission](#permissions) may import these
|
||||
functions from module `env` (the module a Rust or C toolchain gives `extern` functions by default). `vc_abi_version`
|
||||
still returns `1`.
|
||||
|
||||
| Import | Signature | Meaning |
|
||||
|---|---|---|
|
||||
| `vc_progress` | `(done: i64, total: i64)` | `done` of `total` steps are finished; `total` 0 or less when not known. Negative values count as 0, and `done` is clamped to `total`. |
|
||||
| `vc_cancelled` | `() -> i32` | `1` once the user cancelled the run or its time budget ran out, else `0`. |
|
||||
| `vc_log` | `(level, ptr, len) -> i32` | Writes the UTF-8 text at `ptr` as a log line of `level` (0 error, 1 warning, 2 information, 3 debug). Returns `0` when written, `1` when the run's log budget is spent and the line was dropped, `-1` when the text doesn't lie inside the module's memory, `-2` when it is not UTF-8, `-3` for another level. |
|
||||
|
||||
- The host checks the imports when the module is installed: an import it doesn't offer, one with another signature,
|
||||
and one the manifest doesn't allow (an `api` below 2, or no `progress` permission) refuse the module, and the error
|
||||
names the import.
|
||||
- A filter's or an exporter's progress is shown as a share: a heavy filter's progress window and the export's progress
|
||||
window draw a bar with a percentage, and Cancel there makes `vc_cancelled` return `1`. A plug-in that checks it can
|
||||
stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that
|
||||
ignores it is still stopped by the instruction and time budgets. Progress is passed on at most
|
||||
once per tenth of a percent; after 2,000 reports in a run, one change in 1,000 is passed on, and so is the first
|
||||
report after the total becomes known or unknown. A live effect's progress is not shown, since it is drawn as part of
|
||||
something else.
|
||||
- Log lines go to the app's log (target `vectorcraft_plugins`) as `<plug-in id>: <text>`, with control and
|
||||
bidirectional characters escaped. The whole text must be UTF-8. A line shows its first 1,024 bytes, as at most 1,024
|
||||
characters with the escapes, and ends with `…` when it is cut. A run writes at most 200 lines and 32 KiB of shown
|
||||
text, and the host reads at most 1 MiB of log text in a run; past any of these, one "log truncated" line is written
|
||||
and the rest are dropped.
|
||||
|
||||
### Manifest
|
||||
|
||||
```json
|
||||
@@ -370,12 +400,12 @@ crates/plugins/example/build.sh # cargo build --release --target wasm32-unk
|
||||
|
||||
The script also refreshes `crates/plugins/tests/fixtures/desaturate.wasm`, the test fixture built from that source.
|
||||
The crate is not a workspace member. Any language that targets core WebAssembly works (C, Zig, AssemblyScript,
|
||||
WAT…), as long as the module has no imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never
|
||||
WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never
|
||||
touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, which the tests
|
||||
build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters (with a
|
||||
base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, and
|
||||
`wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows in a
|
||||
live effect's geometry.
|
||||
live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host functions.
|
||||
|
||||
## Performance
|
||||
|
||||
@@ -385,9 +415,8 @@ per change of their input, parameters or paints (results are cached), not per fr
|
||||
|
||||
## Not in v1
|
||||
|
||||
- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host
|
||||
callbacks (a plug-in reporting progress or asking whether it was cancelled), importers (plug-in formats that open
|
||||
files).
|
||||
- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, importers
|
||||
(plug-in formats that open files).
|
||||
- Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or
|
||||
symbols.
|
||||
- Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet.
|
||||
|
||||
@@ -1484,6 +1484,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **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 progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
|
||||
|
||||
- **Plug-in 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