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:
dotCooCoo
2026-11-08 13:21:15 +07:00
committed by bflatastic
parent c8701a18cd
commit 1c676fc372
48 changed files with 1176 additions and 121 deletions
Generated
+1
View File
@@ -6422,6 +6422,7 @@ dependencies = [
name = "vectorcraft-plugins"
version = "0.21.0"
dependencies = [
"log",
"serde",
"serde_json",
"sha2",
+21 -2
View File
@@ -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));
}
}
+8 -2
View File
@@ -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
),
+29
View File
@@ -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]
+46 -4
View File
@@ -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);
}
}
}
+1
View File
@@ -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.
+492
View File
@@ -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);
}
}
+5 -2
View File
@@ -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};
+15 -4
View File
@@ -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"),
] {
+35 -11
View File
@@ -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`"),
+54 -54
View File
@@ -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}")));
}
+41 -1
View File
@@ -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
View File
@@ -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]
+44 -9
View File
@@ -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());
+1
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+1
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5 -1
View File
@@ -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.
+5
View File
@@ -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.
+1
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+1
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5 -1
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+1
View File
@@ -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()
+25 -15
View File
@@ -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",
+40
View File
@@ -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.
+16
View File
@@ -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();
}
+4
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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.