From ed74fc67d07c4ef27e872edac92a601d28d26ae7 Mon Sep 17 00:00:00 2001 From: Dubblbindong <48181403+Dubblbindong@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:12:54 +0200 Subject: [PATCH 1/4] feat(plugin): add saveFile permission with host-owned save dialog Plugins can hand the host a file name, MIME type and bytes through the new `save_file` import (plugin API 4, `permissions.saveFile`). The host asks the user where to save (browser download on the web), so the plugin never sees a path. Includes the SDK wrapper and the `export-names` example plugin. Co-Authored-By: Claude Sonnet 5.5 Assisted-by: Claude (Anthropic) --- README.md | 2 +- apps/figcraft-server/src/directory.rs | 1 + crates/collab/src/message.rs | 2 + crates/engine/src/cmd/file.rs | 5 +- crates/engine/src/plugin_directory.rs | 2 +- crates/engine/src/plugins.rs | 75 ++++++++- crates/engine/src/plugins_tests.rs | 126 ++++++++++++++++ crates/plugin/src/lib.rs | 2 + crates/plugin/src/manifest.rs | 11 +- crates/plugin/src/sandbox.rs | 26 +++- crates/plugin/src/save_file_tests.rs | 142 ++++++++++++++++++ crates/plugin/src/tests.rs | 2 +- crates/plugin/src/widget_tests.rs | 2 +- crates/ui-egui/locales/de.toml | 4 + crates/ui-egui/locales/en.toml | 4 + crates/ui-egui/locales/es.toml | 4 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/plugin_save_ui_tests.rs | 71 +++++++++ crates/ui-egui/src/plugins_ui.rs | 36 +++++ docs/plugins.md | 23 ++- plugins/Cargo.lock | 7 + plugins/examples/export-names/Cargo.toml | 15 ++ .../export-names/figcraft-plugin.json | 22 +++ plugins/examples/export-names/src/lib.rs | 55 +++++++ plugins/sdk/src/lib.rs | 16 ++ xtask/src/main.rs | 2 +- 26 files changed, 638 insertions(+), 22 deletions(-) create mode 100644 crates/plugin/src/save_file_tests.rs create mode 100644 crates/ui-egui/src/plugin_save_ui_tests.rs create mode 100644 plugins/examples/export-names/Cargo.toml create mode 100644 plugins/examples/export-names/figcraft-plugin.json create mode 100644 plugins/examples/export-names/src/lib.rs diff --git a/README.md b/README.md index 0910bf9e..c232df08 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. - **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). -- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). +- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. diff --git a/apps/figcraft-server/src/directory.rs b/apps/figcraft-server/src/directory.rs index 0443c0f4..cdf18364 100644 --- a/apps/figcraft-server/src/directory.rs +++ b/apps/figcraft-server/src/directory.rs @@ -245,6 +245,7 @@ impl Directory { }, commands: m.permissions.commands.clone(), widget: m.widget, + save_file: m.permissions.save_file, bytes: bytes.len() as u64, sha256: sha.clone(), published: now(), diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index d035042b..734e4076 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -537,6 +537,8 @@ pub struct PluginEntry { pub commands: Vec, /// It draws a widget. pub widget: bool, + /// It may offer files to save (`permissions.saveFile`). + pub save_file: bool, /// The bundle's size in bytes and its SHA-256 (hex). pub bytes: u64, pub sha256: String, diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5e27c572..97be8439 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1103,9 +1103,12 @@ fn export_path(name: &str) -> String { if parts.is_empty() { "Untitled".into() } else { parts.join("/") } } +/// Characters no file name may contain (path separators and ones file systems reject). +pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|"; + /// `name` without path separators and characters file systems reject. fn safe_file_name(name: &str) -> String { - name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect() + name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect() } /// Layers on `page` that have export settings (document order). diff --git a/crates/engine/src/plugin_directory.rs b/crates/engine/src/plugin_directory.rs index c3bf7dc4..5ddc2a81 100644 --- a/crates/engine/src/plugin_directory.rs +++ b/crates/engine/src/plugin_directory.rs @@ -281,7 +281,7 @@ pub fn specs() -> Vec { spec!( "plugin.directory.list", "Browse plugin directory", - "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}", + "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}", false, list ), diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index f1c4075e..1fab5145 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -153,6 +153,39 @@ fn writes_widget_data(params: &Value, depth: usize) -> bool { struct EngineHost<'s> { s: &'s mut Session, manifest: &'s Manifest, + /// Files offered for saving in this call. + saves: u32, +} + +/// Longest file name a plugin may suggest, in characters. +const MAX_SAVE_NAME: usize = 128; +/// Longest MIME type a plugin may name, in bytes. +const MAX_SAVE_MIME: usize = 100; +/// Largest file a plugin may offer for saving, in bytes (the sandbox's message limit). +const MAX_SAVE_BYTES: usize = 16 * 1024 * 1024; +/// Files a plugin may offer per call. +const MAX_SAVES: u32 = 1; + +/// A file name a plugin suggested, cleaned up: a bare name without folders, drive letters, `..` or +/// characters no file system takes. `None` when nothing usable is left. +pub(crate) fn sanitize_save_name(name: &str) -> Option { + let name = name.trim(); + let bad = name.is_empty() + || name.chars().count() > MAX_SAVE_NAME + || name == "." + || name.contains("..") + || name.chars().any(|c| c.is_control() || crate::cmd::file::FORBIDDEN_FILE_NAME_CHARS.contains(c)); + if bad { None } else { Some(name.to_string()) } +} + +/// `mime` when it looks like `type/subtype` in ASCII, else the generic binary type. +fn sanitize_mime(mime: &str) -> String { + let mime = mime.trim(); + let token = |t: &str| !t.is_empty() && t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '_')); + match mime.split_once('/') { + Some((a, b)) if mime.len() <= MAX_SAVE_MIME && token(a) && token(b) => mime.to_ascii_lowercase(), + _ => "application/octet-stream".into(), + } } impl figcraft_plugin::Host for EngineHost<'_> { @@ -226,6 +259,27 @@ impl figcraft_plugin::Host for EngineHost<'_> { } Ok(()) } + + fn save_file(&mut self, name: &str, mime: &str, data: &[u8]) -> std::result::Result<(), String> { + if !self.manifest.permissions.save_file { + return Err(format!("{} didn't ask permission to save files (`permissions.saveFile`)", self.manifest.name)); + } + let name = sanitize_save_name(name).ok_or_else(|| { + format!("not a usable file name: use a plain name of 1–{MAX_SAVE_NAME} characters without folders or `/ \\ : .. < > \" | ? *`") + })?; + if data.len() > MAX_SAVE_BYTES { + return Err(format!("a file to save is limited to {} MB", MAX_SAVE_BYTES / (1024 * 1024))); + } + if self.saves >= MAX_SAVES { + return Err("a plugin can offer one file per run".into()); + } + self.saves += 1; + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(data); + // The app asks the user where it goes; programmatic callers drop the request. + self.s.request_ui("dialog.pluginSave", json!({"plugin": self.manifest.id, "name": name, "mime": sanitize_mime(mime), "data": data})); + Ok(()) + } } /// The installed plugin `id`. @@ -424,14 +478,21 @@ pub(crate) fn call_plugin( input: &str, ) -> std::result::Result { let mut usable = false; - s.grouped(label, |s| { - let mut host = EngineHost { s, manifest: m }; - instance.call(input, &mut host).map_err(|e| { - usable = matches!(e, PluginError::Failed(_)); - plugin_error(&m.name, e) + let ui_before = s.ui_requests.len(); + let r = s + .grouped(label, |s| { + let mut host = EngineHost { s, manifest: m, saves: 0 }; + instance.call(input, &mut host).map_err(|e| { + usable = matches!(e, PluginError::Failed(_)); + plugin_error(&m.name, e) + }) }) - }) - .map_err(|e| (e, usable)) + .map_err(|e| (e, usable)); + // A failed run opens no dialog (a file it offered to save is dropped with it). + if r.is_err() { + s.ui_requests.truncate(ui_before); + } + r } /// What `plugin.run` and `plugin.event` return. diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 102d675a..ebabc09e 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -397,3 +397,129 @@ fn a_full_undo_history_keeps_its_steps_and_tampered_manifests_are_refused() { s.prefs.plugins[0].manifest.main = "../../elsewhere.wasm".into(); assert!(matches!(s.execute("plugin.run", &json!({"id": "org.test.many"})), Err(EngineError::BadParams { .. }))); } + +/// A module that offers `files` (name, mime, data) with `save_file` in order, outputs the last +/// refusal and returns `exit`. +fn saver(files: &[(&str, &str, &[u8])], exit: i32) -> Vec { + let hex = |b: &[u8]| b.iter().map(|b| format!("\\{b:02x}")).collect::(); + let mut data = String::new(); + let mut body = String::new(); + let mut at = 0usize; + for (name, mime, bytes) in files { + let (n, m) = (at, at + name.len()); + let d = m + mime.len(); + data += &format!( + "(data (i32.const {n}) \"{}\") (data (i32.const {m}) \"{}\") (data (i32.const {d}) \"{}\")\n", + hex(name.as_bytes()), + hex(mime.as_bytes()), + hex(bytes) + ); + body += &format!( + "(local.set $st (call $save_file (i32.const {n}) (i32.const {}) (i32.const {m}) (i32.const {}) (i32.const {d}) (i32.const {}))) + (if (local.get $st) (then (call $set_output (i32.const 30000) (call $reply_read (i32.const 30000) (i32.const 500)))))\n", + name.len(), + mime.len(), + bytes.len() + ); + at = d + bytes.len(); + } + let wat = format!( + r#"(module + (import "figcraft" "save_file" (func $save_file (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32))) + (import "figcraft" "set_output" (func $set_output (param i32 i32))) + (memory (export "memory") 1) + {data} + (func (export "figcraft_run") (param $n i32) (result i32) (local $st i32) + {body} + (i32.const {exit})))"# + ); + wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}")) +} + +fn save_manifest(id: &str, save_file: bool) -> Value { + json!({"id": id, "name": "Saver", "version": "1.0.0", "main": "plugin.wasm", "api": 4, "permissions": {"access": "read", "saveFile": save_file}}) +} + +fn saves(s: &Session) -> Vec<&crate::UiRequest> { + s.ui_requests.iter().filter(|r| r.kind == "dialog.pluginSave").collect() +} + +#[test] +fn a_plugin_without_the_permission_cannot_save() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.nosave", false), &saver(&[("a.txt", "text/plain", b"hi")], 0)); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.nosave"})); + assert!(out["output"].as_str().unwrap_or_default().contains("permission"), "{out}"); + assert!(saves(&s).is_empty(), "no dialog without the permission"); +} + +#[test] +fn a_plugin_with_the_permission_requests_one_save_dialog_with_its_bytes() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.save", true), &saver(&[("out.jsx", "TEXT/Javascript", b"\x00hi\xff")], 0)); + ex(&mut s, "plugin.run", json!({"id": "org.test.save"})); + let reqs = saves(&s); + let [r] = reqs.as_slice() else { panic!("one request, got {}", reqs.len()) }; + assert_eq!(r.data["plugin"], "org.test.save"); + assert_eq!(r.data["name"], "out.jsx"); + assert_eq!(r.data["mime"], "text/javascript"); + assert_eq!(r.data["data"], b64(b"\x00hi\xff")); + // Nothing in the request is a path. + assert!(r.data.get("path").is_none()); +} + +#[test] +fn a_second_file_in_one_run_and_bad_names_are_refused() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.two", true), &saver(&[("a.txt", "text/plain", b"1"), ("b.txt", "text/plain", b"2")], 0)); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.two"})); + assert_eq!(saves(&s).len(), 1, "only the first file"); + assert!(out["output"].as_str().unwrap_or_default().contains("one file"), "{out}"); + s.ui_requests.clear(); + install(&mut s, save_manifest("org.test.bad", true), &saver(&[("../etc/passwd", "text/plain", b"1")], 0)); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.bad"})); + assert!(saves(&s).is_empty()); + assert!(out["output"].as_str().unwrap_or_default().contains("file name"), "{out}"); +} + +#[test] +fn an_odd_mime_type_becomes_generic_binary() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.mime", true), &saver(&[("a.bin", "not a type", b"1")], 0)); + ex(&mut s, "plugin.run", json!({"id": "org.test.mime"})); + assert_eq!(saves(&s)[0].data["mime"], "application/octet-stream"); +} + +#[test] +fn a_run_that_fails_afterwards_leaves_no_dialog_behind() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.late", true), &saver(&[("a.txt", "text/plain", b"1")], 1)); + run_err(&mut s, "org.test.late"); + assert!(saves(&s).is_empty(), "a failed run opens no dialog"); +} + +#[test] +fn file_commands_stay_closed_to_plugins() { + let mut s = Session::new(); + let calls = [("file.saveAs", json!({"path": "/tmp/x.figcraft"}))]; + install(&mut s, manifest("org.test.files", "read", &[]), &script(&calls, OnRefusal::Report, "")); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.files"})); + assert!(out["output"].as_str().unwrap_or_default().contains("isn't available to plugins"), "{out}"); +} + +#[test] +fn save_names_are_plain_file_names() { + use crate::plugins::sanitize_save_name as clean; + let long = "a".repeat(129); + let max = "a".repeat(128); + // 128 two-byte characters are within the limit even though they take 256 bytes. + let wide = "é".repeat(128); + for bad in ["", " ", ".", "..", "../x", "a/b", "a\\b", "C:x", "a..b", "a\nb", "a\u{0}b", "a\u{7f}b", "a*b", long.as_str()] { + assert_eq!(clean(bad), None, "{bad:?}"); + } + assert_eq!(clean(" out.jsx ").as_deref(), Some("out.jsx")); + assert_eq!(clean(&max).as_deref(), Some(max.as_str())); + assert_eq!(clean(&wide).as_deref(), Some(wide.as_str())); + assert_eq!(clean("Entwurf – Büro.jsx").as_deref(), Some("Entwurf – Büro.jsx")); +} diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 582b0e64..80a26295 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -16,6 +16,8 @@ pub use widget::{WidgetKind, WidgetNode, WidgetRender}; #[cfg(test)] mod bundle_tests; #[cfg(test)] +mod save_file_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod v2_tests; diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 48e44e88..6af92a95 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -8,7 +8,7 @@ use serde_json::{Map, Value}; /// The manifest's file name. pub const MANIFEST_FILE: &str = "figcraft-plugin.json"; /// The plugin API version this build implements (`api` in the manifest). -pub const API_VERSION: u32 = 3; +pub const API_VERSION: u32 = 4; /// Largest manifest read. pub const MAX_MANIFEST_BYTES: usize = 256 * 1024; @@ -43,7 +43,7 @@ pub struct Manifest { /// The WebAssembly module, relative to the manifest (`plugin.wasm`). pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings - /// need 2, widgets 3). + /// need 2, widgets 3, `saveFile` 4). #[serde(default = "api_version")] pub api: u32, #[serde(default)] @@ -71,6 +71,10 @@ pub struct Permissions { /// command its access allows. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub commands: Vec, + /// API 4: the plugin may hand the host a file to save. The host asks the user where; the plugin + /// never sees a path. + #[serde(default, rename = "saveFile", skip_serializing_if = "std::ops::Not::not")] + pub save_file: bool, } impl Permissions { @@ -314,6 +318,9 @@ impl Manifest { if self.widget && self.api < 3 { return Err("a widget needs plugin API 3 (`\"api\": 3`)".into()); } + if self.permissions.save_file && self.api < 4 { + return Err("saveFile needs plugin API 4 (`\"api\": 4`)".into()); + } if self.commands.is_empty() && !self.widget { self.commands.push(PluginCommand { id: "run".into(), label: "Run".into(), params: Vec::new() }); } diff --git a/crates/plugin/src/sandbox.rs b/crates/plugin/src/sandbox.rs index 22eb0af0..59d66118 100644 --- a/crates/plugin/src/sandbox.rs +++ b/crates/plugin/src/sandbox.rs @@ -2,7 +2,8 @@ //! memory cap, and sees the outside world only through the host functions below. There is no WASI: //! no file system, network, clock or randomness. //! -//! # ABI (version 3; version 1 is the first seven imports, version 2 adds panels and settings) +//! # ABI (version 4; version 1 is the first seven imports, version 2 adds panels and settings, +//! version 3 widgets, version 4 `save_file`) //! //! The module exports its `memory` and `figcraft_run(input_len: i32) -> i32`. Host functions are //! imported from the module `figcraft`; strings are UTF-8 `(ptr, len)` pairs in the plugin's memory. @@ -22,6 +23,7 @@ //! | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> i32` | Stores JSON under the key. 1: refused, the reply says why. | //! | `setting_delete` | `(key_ptr, key_len)` | Forgets a setting. | //! | `widget_render` | `(ptr, len) -> i32` | Draws the widget being called back: a [`WidgetRender`] as JSON. 1: invalid, the reply says why. | +//! | `save_file` | `(name_ptr, name_len, mime_ptr, mime_len, data_ptr, data_len) -> i32` | Offers a file to save (needs `permissions.saveFile`). The host asks the user where it goes; the plugin never gets a path. 0: accepted, 1: refused, the reply says why. | //! //! When `figcraft_run` starts, the reply holds the input JSON (`input_len` bytes): //! `{"api", "command", "params", "selection"}`, plus `event` and `values` when a panel calls the @@ -58,6 +60,7 @@ const IMPORTS: &[(&str, usize, usize)] = &[ ("setting_set", 4, 1), ("setting_delete", 2, 0), ("widget_render", 2, 1), + ("save_file", 6, 1), ]; /// What a run may use. @@ -136,6 +139,11 @@ pub trait Host { fn set_setting(&mut self, _key: &str, _value: Option<&str>) -> Result<(), String> { Err("settings aren't available here".into()) } + /// Offer the file `name` (MIME type `mime`) for saving, or say why not. The host picks the + /// destination; the plugin never learns it. + fn save_file(&mut self, _name: &str, _mime: &str, _data: &[u8]) -> Result<(), String> { + Err("saving files isn't available here".into()) + } } /// What a run asked of the panel. @@ -172,6 +180,7 @@ enum Request { SettingGet(String), SettingSet(String, String), SettingDelete(String), + SaveFile(String, String, Vec), } struct State { @@ -237,6 +246,13 @@ impl State { let _ = host.set_setting(&key, None); None } + Request::SaveFile(name, mime, data) => Some(match host.save_file(&name, &mime, &data) { + Ok(()) => 0, + Err(m) => { + self.set_reply(m.into_bytes()); + 1 + } + }), } } } @@ -575,5 +591,13 @@ fn linker(engine: &Engine) -> Result, PluginError> { Err(ask(&mut c, Request::SettingDelete(key))) }) .map_err(err)?; + l.func_wrap(IMPORT_MODULE, "save_file", |mut c: Ctx<'_>, np: i32, nl: i32, mp: i32, ml: i32, dp: i32, dl: i32| -> Result { + charge(&mut c)?; + let name = read_str(&mut c, np, nl)?; + let mime = read_str(&mut c, mp, ml)?; + let data = read_bytes(&mut c, dp, dl)?; + Err(ask(&mut c, Request::SaveFile(name, mime, data))) + }) + .map_err(err)?; Ok(l) } diff --git a/crates/plugin/src/save_file_tests.rs b/crates/plugin/src/save_file_tests.rs new file mode 100644 index 00000000..34241bb7 --- /dev/null +++ b/crates/plugin/src/save_file_tests.rs @@ -0,0 +1,142 @@ +//! `save_file` (API 4): the manifest permission, the host call with its bytes, and the limits. + +use serde_json::json; + +use crate::{Host, Instance, Limits, Manifest, PluginError}; + +/// A host that records the files offered to it (and refuses names starting with `no`). +#[derive(Default)] +struct Saves(Vec<(String, String, Vec)>); + +impl Host for Saves { + fn call(&mut self, _command: &str, _params: &str) -> Result { + Ok("{}".into()) + } + fn selection(&mut self) -> String { + "[]".into() + } + fn notify(&mut self, _message: &str) {} + fn save_file(&mut self, name: &str, mime: &str, data: &[u8]) -> Result<(), String> { + if name.starts_with("no") { + return Err("nope".into()); + } + self.0.push((name.into(), mime.into(), data.to_vec())); + Ok(()) + } +} + +/// A host that keeps the default `save_file`. +struct Plain; + +impl Host for Plain { + fn call(&mut self, _command: &str, _params: &str) -> Result { + Ok("{}".into()) + } + fn selection(&mut self) -> String { + "[]".into() + } + fn notify(&mut self, _message: &str) {} +} + +/// Memory: name at 0, MIME at 100, data at 200. `call` is the arguments of `save_file`; its status +/// is the run's status, and its reply is the output. +fn module(name: &str, mime: &str, data: &[u8], call: &str) -> Vec { + let bytes = |b: &[u8]| b.iter().map(|b| format!("\\{b:02x}")).collect::(); + let wat = format!( + r#"(module + (import "figcraft" "save_file" (func $save_file (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32))) + (import "figcraft" "set_output" (func $set_output (param i32 i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") (data (i32.const 100) "{}") (data (i32.const 200) "{}") + (func (export "figcraft_run") (param $n i32) (result i32) (local $st i32) + (local.set $st {call}) + (call $set_output (i32.const 1000) (call $reply_read (i32.const 1000) (i32.const 100))) + (local.get $st)))"#, + bytes(name.as_bytes()), + bytes(mime.as_bytes()), + bytes(data) + ); + wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}")) +} + +fn call(name: &str, mime: &str, data: &[u8]) -> String { + format!( + "(call $save_file (i32.const 0) (i32.const {}) (i32.const 100) (i32.const {}) (i32.const 200) (i32.const {}))", + name.len(), + mime.len(), + data.len() + ) +} + +fn manifest(api: u32, save_file: bool) -> String { + json!({"id": "org.test.save", "name": "Save", "main": "p.wasm", "api": api, "permissions": {"saveFile": save_file}}).to_string() +} + +#[test] +fn the_host_gets_the_name_the_type_and_the_exact_bytes() { + let data = [0u8, 1, 2, 255, 0x80, b'x']; + let wasm = module("out.jsx", "text/plain", &data, &call("out.jsx", "text/plain", &data)); + let mut host = Saves::default(); + Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut host).unwrap(); + assert_eq!(host.0, vec![("out.jsx".to_string(), "text/plain".to_string(), data.to_vec())]); +} + +#[test] +fn a_refusal_returns_status_one_with_the_reason() { + let wasm = module("no.txt", "text/plain", b"x", &call("no.txt", "text/plain", b"x")); + // The module returns the status as its own, so the run fails with the reason as its message. + let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut Saves::default()).unwrap_err(); + assert_eq!(err, PluginError::Failed("nope".into())); +} + +#[test] +fn a_host_without_saving_refuses_by_default() { + let wasm = module("a.txt", "text/plain", b"x", &call("a.txt", "text/plain", b"x")); + let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut Plain).unwrap_err(); + assert_eq!(err, PluginError::Failed("saving files isn't available here".into())); +} + +#[test] +fn too_much_data_is_a_limit_error_and_bad_utf8_a_trap() { + let limits = Limits { max_message: 4, ..Limits::default() }; + let wasm = module("a.txt", "text/plain", b"12345", &call("a.txt", "text/plain", b"12345")); + let mut host = Saves::default(); + let err = Instance::new(&wasm, &limits).unwrap().call("{}", &mut host).unwrap_err(); + assert!(matches!(err, PluginError::Limit(_)), "{err:?}"); + assert!(host.0.is_empty()); + + // The name points at a lone 0xff byte: not UTF-8. + let wasm = module( + "a", + "text/plain", + &[0xff], + "(call $save_file (i32.const 200) (i32.const 1) (i32.const 100) (i32.const 1) (i32.const 200) (i32.const 1))", + ); + let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut host).unwrap_err(); + assert!(matches!(err, PluginError::Trap(_)), "{err:?}"); + assert!(host.0.is_empty()); +} + +#[test] +fn a_wrong_signature_is_rejected() { + let wat = r#"(module (import "figcraft" "save_file" (func (param i32 i32) (result i32))) + (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#; + let wasm = wat::parse_str(wat).unwrap(); + assert!(matches!(crate::check_module(&wasm, &Limits::default()), Err(PluginError::InvalidModule(m)) if m.contains("wrong signature"))); +} + +#[test] +fn the_permission_needs_api_four_and_survives_a_round_trip() { + let err = Manifest::parse(&manifest(3, true)).unwrap_err(); + assert!(err.contains("saveFile needs plugin API 4"), "{err}"); + let m = Manifest::parse(&manifest(4, true)).unwrap(); + assert!(m.permissions.save_file); + let again = Manifest::parse(&serde_json::to_string(&m).unwrap()).unwrap(); + assert_eq!(m, again); + assert!(serde_json::to_string(&m).unwrap().contains("\"saveFile\":true")); + // Older manifests stay valid and don't grow the key. + let old = Manifest::parse(&manifest(3, false)).unwrap(); + assert!(!old.permissions.save_file); + assert!(!serde_json::to_string(&old).unwrap().contains("saveFile")); +} diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index 5d094ab4..27d7ac23 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -441,7 +441,7 @@ fn bad_manifests_are_refused() { json!({"main": "C:\\plugin.wasm"}), json!({"main": "plugin.js"}), json!({"main": "a//b.wasm"}), - json!({"api": 4}), + json!({"api": 5}), json!({"api": 0}), json!({"permissions": {"access": "root"}}), json!({"permissions": {"commands": ["*.x"]}}), diff --git a/crates/plugin/src/widget_tests.rs b/crates/plugin/src/widget_tests.rs index 0d4ec2df..35418866 100644 --- a/crates/plugin/src/widget_tests.rs +++ b/crates/plugin/src/widget_tests.rs @@ -110,7 +110,7 @@ fn widgets_need_api_3() { assert!(m(2).unwrap_err().contains("API 3")); let ok = m(3).unwrap(); assert!(ok.widget && ok.commands.is_empty(), "a widget-only plugin has no menu command"); - assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 4})).is_err()); + assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 5})).is_err()); let v2 = Manifest::from_value(&json!({"id": "p", "name": "P", "main": "p.wasm", "api": 2})).unwrap(); assert!(!v2.widget && v2.commands.len() == 1, "v2 manifests are unchanged"); } diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..6d8c9128 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -305,6 +305,7 @@ status = "draft — needs native review" "Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." "Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" +"Couldn't save the file" = "Die Datei konnte nicht gespeichert werden" "Count" = "Anzahl" "Create" = "Erstellen" "Create a variable collection first" = "Erstellen Sie zuerst eine Variablensammlung" @@ -1082,6 +1083,7 @@ status = "draft — needs native review" "Save" = "Speichern" "Save as" = "Speichern unter" "Save as preset…" = "Als Vorgabe speichern…" +"Save file" = "Datei speichern" "Save files automatically" = "Dateien automatisch speichern" "Save version {key}" = "Version speichern {key}" "Saved" = "Gespeichert" @@ -1435,6 +1437,7 @@ status = "draft — needs native review" "call any command its access allows" = "jeden Befehl aufrufen, den sein Zugriff erlaubt" "call only: {commands}" = "nur aufrufen: {commands}" "can edit the document" = "kann das Dokument bearbeiten" +"can save files" = "kann Dateien speichern" "crop marks" = "Schnittmarken" "development: {path}" = "Entwicklung: {path}" "e.g. count + 1" = "z. B. count + 1" @@ -1452,6 +1455,7 @@ status = "draft — needs native review" "read and edit the document" = "das Dokument lesen und bearbeiten" "read the document" = "das Dokument lesen" "reads the document" = "liest das Dokument" +"save files you choose" = "Dateien an einem von Ihnen gewählten Ort speichern" "the plugin" = "das Plugin" "this session only" = "nur diese Sitzung" "{instances} in {files}" = "{instances} in {files}" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..91bf2ae1 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -314,6 +314,7 @@ name = "English" "Couldn't download the variables" = "Couldn't download the variables" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" +"Couldn't save the file" = "Couldn't save the file" "Count" = "Count" "Create" = "Create" "Create a variable collection first" = "Create a variable collection first" @@ -1124,6 +1125,7 @@ name = "English" "Rulers" = "Rulers" "Run" = "Run" "Run {label}" = "Run {label}" +"Save file" = "Save file" "Save files automatically" = "Save files automatically" "Search templates" = "Search templates" "See all templates" = "See all templates" @@ -1506,6 +1508,7 @@ name = "English" "call any command its access allows" = "call any command its access allows" "call only: {commands}" = "call only: {commands}" "can edit the document" = "can edit the document" +"can save files" = "can save files" "crop marks" = "crop marks" "development: {path}" = "development: {path}" "e.g. count + 1" = "e.g. count + 1" @@ -1524,6 +1527,7 @@ name = "English" "read and edit the document" = "read and edit the document" "read the document" = "read the document" "reads the document" = "reads the document" +"save files you choose" = "save files you choose" "the plugin" = "the plugin" "this session only" = "this session only" "yesterday" = "yesterday" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..f551146c 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -315,6 +315,7 @@ status = "draft — needs native review" "Corner smoothing" = "Suavizado de esquinas" "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." "Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" +"Couldn't save the file" = "No se pudo guardar el archivo" "Count" = "Número" "Create" = "Crear" "Create a variable collection first" = "Crea primero una colección de variables" @@ -1125,6 +1126,7 @@ status = "draft — needs native review" "Rulers" = "Reglas" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" +"Save file" = "Guardar archivo" "Save files automatically" = "Guardar archivos automáticamente" "Search templates" = "Buscar plantillas" "See all templates" = "Ver todas las plantillas" @@ -1507,6 +1509,7 @@ status = "draft — needs native review" "call any command its access allows" = "llamar a cualquier comando que su acceso permita" "call only: {commands}" = "llamar solo a: {commands}" "can edit the document" = "puede editar el documento" +"can save files" = "puede guardar archivos" "crop marks" = "marcas de corte" "development: {path}" = "desarrollo: {path}" "e.g. count + 1" = "p. ej. count + 1" @@ -1525,6 +1528,7 @@ status = "draft — needs native review" "read and edit the document" = "leer y editar el documento" "read the document" = "leer el documento" "reads the document" = "lee el documento" +"save files you choose" = "guardar archivos donde tú elijas" "the plugin" = "el plugin" "this session only" = "solo esta sesión" "yesterday" = "ayer" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..3ee02891 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -148,6 +148,8 @@ mod perf_tests; #[cfg(test)] mod plugin_panel_tests; #[cfg(test)] +mod plugin_save_ui_tests; +#[cfg(test)] mod plugins_browse_tests; #[cfg(test)] mod popup_width_tests; @@ -891,6 +893,7 @@ impl FigApp { } "dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")), "dialog.plugins" => self.ui.plugins_window = true, + "dialog.pluginSave" => plugins_ui::save_file(self, &r.data), "dialog.cursorChat" => live::open_chat(self), "window.new" | "window.focus" | "window.fullscreen" => self.window_requests.push(r), _ => {} diff --git a/crates/ui-egui/src/plugin_save_ui_tests.rs b/crates/ui-egui/src/plugin_save_ui_tests.rs new file mode 100644 index 00000000..6302e2d2 --- /dev/null +++ b/crates/ui-egui/src/plugin_save_ui_tests.rs @@ -0,0 +1,71 @@ +//! A plugin's `dialog.pluginSave` request: the browser downloads the file, a platform with save +//! dialogs asks the user (and writes nothing when they cancel). + +use std::sync::{Arc, Mutex}; + +use base64::Engine as _; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, NoServices, Services}; + +type Downloads = Arc)>>>; + +/// Services that save by downloads and keep them (`deliver` false: the browser refused). +struct Browser { + got: Downloads, + deliver: bool, +} + +impl Services for Browser { + fn downloads(&self) -> bool { + true + } + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + self.got.lock().unwrap().push((name.to_string(), bytes.to_vec())); + self.deliver + } +} + +fn request(app: &mut FigApp, name: &str, data: &str) { + app.session.request_ui("dialog.pluginSave", json!({"plugin": "org.test.save", "name": name, "mime": "text/plain", "data": data})); + app.handle_ui_requests(); +} + +fn toast(app: &FigApp) -> String { + app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +fn b64(bytes: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +#[test] +fn the_browser_downloads_the_file_with_its_exact_bytes() { + let got = Downloads::default(); + let mut app = FigApp::new(Session::new(), Box::new(Browser { got: got.clone(), deliver: true })); + request(&mut app, "out.jsx", &b64(b"\x00hello\xff")); + let got = got.lock().unwrap(); + assert_eq!(got.as_slice(), [("out.jsx".to_string(), b"\x00hello\xff".to_vec())]); + assert!(toast(&app).contains("out.jsx"), "{}", toast(&app)); +} + +#[test] +fn a_refused_download_and_broken_data_say_so() { + let got = Downloads::default(); + let mut app = FigApp::new(Session::new(), Box::new(Browser { got: got.clone(), deliver: false })); + request(&mut app, "out.jsx", &b64(b"x")); + assert_eq!(toast(&app), "Couldn't save the file"); + app.ui.toast = None; + request(&mut app, "out.jsx", "***not base64***"); + assert_eq!(toast(&app), "Couldn't save the file"); + assert_eq!(got.lock().unwrap().len(), 1, "broken data is never handed over"); +} + +#[test] +fn a_cancelled_save_dialog_writes_nothing() { + // `NoServices` has no downloads and answers the save dialog with "cancelled". + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + request(&mut app, "out.jsx", &b64(b"x")); + assert!(app.ui.toast.is_none()); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 481b446c..ed00604d 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -110,6 +110,36 @@ pub fn start(app: &mut FigApp, id: &str, command: &str) { } } +/// A plugin offered a file to save (`dialog.pluginSave`: `name`, `data` as base64): the user picks +/// where it goes in FigCraft's own save dialog, or the browser downloads it. The plugin never learns +/// the path. +pub(crate) fn save_file(app: &mut FigApp, data: &Value) { + use base64::Engine as _; + let name = data["name"].as_str().unwrap_or_default().to_string(); + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data["data"].as_str().unwrap_or_default()) else { + app.toast(tr("Couldn't save the file")); + return; + }; + if app.services.downloads() { + if app.services.download(&name, &bytes) { + app.toast(trf("Saved {name}", &[("name", &name)])); + } else { + app.toast(tr("Couldn't save the file")); + } + return; + } + let ext = std::path::Path::new(&name).extension().and_then(|e| e.to_str()).unwrap_or_default().to_string(); + let exts: Vec<&str> = if ext.is_empty() { Vec::new() } else { vec![ext.as_str()] }; + let shown = name.clone(); + app.pick_save(tr("Save file"), &name, &exts, move |app, path| { + let file = path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or(shown); + match std::fs::write(&path, &bytes) { + Ok(()) => app.toast(trf("Saved {name}", &[("name", &file)])), + Err(_) => app.toast(tr("Couldn't save the file")), + } + }); +} + /// Run `plugin.run` or `plugin.event`; a text output is shown as a toast unless the plugin showed /// a message. fn run(app: &mut FigApp, cmd: &str, params: Value) { @@ -447,6 +477,9 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { if e.widget { asks.push(tr("put a widget on the canvas").to_string()); } + if e.save_file { + asks.push(tr("save files you choose").to_string()); + } for a in asks { ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(regular(11.0)).color(t.text)).wrap()); } @@ -501,6 +534,9 @@ fn summary(p: &InstalledPlugin) -> String { parts.push(m.version.clone()); } parts.push(access.to_string()); + if m.permissions.save_file { + parts.push(tr("can save files").to_string()); + } parts.push(match (&p.source, &p.path) { (Some(server), _) => trf("from {server}", &[("server", server)]), (None, Some(path)) => trf("development: {path}", &[("path", path)]), diff --git a/docs/plugins.md b/docs/plugins.md index 6f23e6ec..f5182930 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -135,7 +135,7 @@ plugins: import them in the script. | `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. | | `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | | `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. | -| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | +| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | | `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | | `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. | | `plugin.directory.unpublish` | `{id, version?, server}` | Removes one version (default: every version) from the directory; needs the key. Returns `{id, removed}`. | @@ -224,7 +224,8 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p | `version`, `description` | Optional text. A plugin published to a [directory](#plugin-directories) needs a `version` of one to four numbers (`1.2.0`), each version newer than the last. | | `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | -| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings) or `3` (widgets). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels or widgets fails clearly on an older FigCraft. | +| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | +| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. The install confirmation lists it. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | | `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. | @@ -236,6 +237,9 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p Whatever its manifest says, FigCraft refuses these calls (the plugin gets the refusal as an error it can handle): +- read or write files: a plugin never gets a path or the disk. With `permissions.saveFile` it can + only hand FigCraft a file (one per run, 16 MB at most, a plain file name) for the user to save + through FigCraft's own dialog; the user can cancel it; - files and the app: `file.*`, `app.*`, `pref.*`, `help.*`, `library.add`, `libraries.*`, `image.place` with a `path` (pass the image as base64 `data` instead), `view.theme*`; - other documents and the version history: `doc.*` and `version.*` (except `doc.list` and @@ -262,9 +266,10 @@ allocation or copy. This also applies when the configured per-call overhead is z read is charged for the bytes actually returned, not the requested buffer size. `calls` counts every ABI import, including calls which only query the reply length. -## ABI (version 3) +## ABI (version 4) -Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2 +Version 2 adds panels and settings to version 1, version 3 adds widgets and version 4 adds +`save_file`; a version 1, 2 or 3 module runs unchanged and gets the input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values are `i32`. @@ -311,6 +316,7 @@ included, is refused when the plugin is imported): | `setting_get` | `(key_ptr, key_len) -> status` | API 2. `0`: the reply holds the setting's JSON. `1`: there is none. | | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> status` | API 2. Stores JSON under the key, for this plugin only, kept with the preferences. `1`: refused (not JSON, a bad key, over 64 KB), the reply says why. | | `setting_delete` | `(key_ptr, key_len)` | API 2. Forgets a setting. | +| `save_file` | `(name_ptr, name_len, mime_ptr, mime_len, data_ptr, data_len) -> status` | API 4, needs `permissions.saveFile`. Offers a file to save; FigCraft asks the user where it goes once the run succeeds. `1`: refused (no permission, a bad file name, over 16 MB, a second file in one run), the reply says why. | | `widget_render` | `(ptr, len) -> status` | API 3. Draws the widget being inserted or clicked (the widget description below, as JSON). `1`: the description is invalid, the reply says why. A widget call that doesn't draw keeps the widget as it was (inserting one must draw it). | A pointer or length outside the plugin's memory traps, which fails the run. Host functions can only @@ -369,7 +375,7 @@ image sizes) and refuses it if anything is off. `plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate `figcraft-plugin-sdk`, `no_std` with `alloc`) wraps the ABI with serde_json, and -`plugins/examples` has three plugins: +`plugins/examples` has four plugins: - **Tidy names** (`tidy-names`): cleans up the selected layers' names (extra spaces, the "copy" left by duplicating) and optionally their letter case, inside the selection too. Two commands: one @@ -382,6 +388,9 @@ image sizes) and refuses it if anything is off. hex colours in the parameters form. - **Counter** (`counter`, API 3): a widget, a count between − and + buttons; each click changes the count kept in the widget's state. It only reads the document. +- **Export names** (`export-names`, API 4): saves the names of the selected layers (optionally the + layers inside them too) as `layer-names.txt`. It reads the document and offers the file with + `save_file`; FigCraft asks where it goes. `cargo xtask plugins` (part of `cargo xtask wasm` and `cargo xtask ci`; it needs the `wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`) checks, tests and @@ -438,7 +447,9 @@ panel calls back: `control`, `value` and every field's `values`); returning `Err rolls it back. With `"api": 2` in the manifest: `show_ui(&json!({...}))` and `close_ui()` for the panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. With `"api": 3` and `"widget": true`: `input.widget` (`id`, `state`, `target`) when the widget is -inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. Outside +inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. With +`"api": 4` and `"permissions": {"saveFile": true}`: `save_file(name, mime, &bytes)` offers a file; +FigCraft asks the user where it goes and the plugin never learns a path. Outside WebAssembly every host call fails, so the plugin's own logic can be unit-tested with `cargo test`. Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`, diff --git a/plugins/Cargo.lock b/plugins/Cargo.lock index 7738ceb5..87244377 100644 --- a/plugins/Cargo.lock +++ b/plugins/Cargo.lock @@ -9,6 +9,13 @@ dependencies = [ "figcraft-plugin-sdk", ] +[[package]] +name = "export-names" +version = "0.1.0" +dependencies = [ + "figcraft-plugin-sdk", +] + [[package]] name = "figcraft-plugin-sdk" version = "0.1.0" diff --git a/plugins/examples/export-names/Cargo.toml b/plugins/examples/export-names/Cargo.toml new file mode 100644 index 00000000..35b7eb81 --- /dev/null +++ b/plugins/examples/export-names/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "export-names" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] + +[lints] +workspace = true + +[dependencies] +figcraft-plugin-sdk = { workspace = true } diff --git a/plugins/examples/export-names/figcraft-plugin.json b/plugins/examples/export-names/figcraft-plugin.json new file mode 100644 index 00000000..ab8f943f --- /dev/null +++ b/plugins/examples/export-names/figcraft-plugin.json @@ -0,0 +1,22 @@ +{ + "id": "org.figcraft.examples.export-names", + "name": "Export names", + "version": "1.0.0", + "description": "Saves the names of the selected layers as a text file, one per line.", + "main": "export_names.wasm", + "api": 4, + "permissions": { + "access": "read", + "commands": ["node.get"], + "saveFile": true + }, + "commands": [ + { + "id": "export", + "label": "Export names…", + "params": [ + { "name": "children", "label": "Include layers inside", "type": "boolean", "default": true } + ] + } + ] +} diff --git a/plugins/examples/export-names/src/lib.rs b/plugins/examples/export-names/src/lib.rs new file mode 100644 index 00000000..4525a39c --- /dev/null +++ b/plugins/examples/export-names/src/lib.rs @@ -0,0 +1,55 @@ +//! Export names: saves the names of the selected layers (optionally the layers inside them too) as +//! the text file `layer-names.txt`, through the host's own Save dialog (`save_file`). + +use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, notify, save_file}; + +/// Layers looked at in one run, at most. +const MAX_LAYERS: usize = 10_000; + +/// The names as the file's text: one per line, a line break at the end, line breaks inside a name +/// turned into spaces. +fn names_text(names: &[String]) -> String { + let mut text = String::new(); + for name in names { + text.extend(name.chars().map(|c| if c == '\n' || c == '\r' { ' ' } else { c })); + text.push('\n'); + } + text +} + +fn run(input: Input) -> Result { + if input.selection.is_empty() { + return Err("Select the layers to export first".into()); + } + let inside = input.bool("children").unwrap_or(true); + // Depth first, in layer order: the stack takes the children reversed. + let mut stack: Vec = input.selection.iter().rev().copied().collect(); + let mut names = Vec::new(); + while let Some(id) = stack.pop() { + if names.len() >= MAX_LAYERS { + break; + } + let node = call("node.get", &json!({"id": id}))?; + names.push(node["name"].as_str().unwrap_or_default().to_string()); + if inside && let Some(children) = node["children"].as_array() { + stack.extend(children.iter().rev().filter_map(|c| c["id"].as_u64())); + } + } + save_file("layer-names.txt", "text/plain", names_text(&names).as_bytes())?; + notify(&format!("Exporting {} names", names.len())); + Ok(json!({"names": names.len()})) +} + +entry!(run); + +#[cfg(test)] +mod tests { + use super::names_text; + + #[test] + fn names_become_lines() { + let names = ["Card".to_string(), "Title\nline".to_string(), String::new()]; + assert_eq!(names_text(&names), "Card\nTitle line\n\n"); + assert_eq!(names_text(&[]), ""); + } +} diff --git a/plugins/sdk/src/lib.rs b/plugins/sdk/src/lib.rs index 967af788..65f70f8d 100644 --- a/plugins/sdk/src/lib.rs +++ b/plugins/sdk/src/lib.rs @@ -187,6 +187,14 @@ pub fn delete_setting(key: &str) { host::setting_delete(key.as_bytes()); } +/// Offer a file to save: a suggested file `name`, its MIME type and the bytes. FigCraft asks the +/// user where it goes (a download in the browser) once the run succeeds; the plugin never learns +/// a path. Needs `"api": 4` and `permissions.saveFile` in the manifest. A refusal (no permission, +/// a bad name, too much data, a second file in one run) is an error. +pub fn save_file(name: &str, mime: &str, data: &[u8]) -> Result<()> { + if host::save_file(name.as_bytes(), mime.as_bytes(), data) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) } +} + /// The body of `figcraft_run` (use [`entry!`]): read the input, run `f`, hand back its output. pub fn run_plugin(input_len: i32, f: fn(Input) -> Result) -> i32 { let input = host::reply(); @@ -238,6 +246,7 @@ mod host { pub fn setting_set(key: *const u8, key_len: usize, value: *const u8, value_len: usize) -> i32; pub fn setting_delete(key: *const u8, key_len: usize); pub fn widget_render(ptr: *const u8, len: usize) -> i32; + pub fn save_file(name: *const u8, name_len: usize, mime: *const u8, mime_len: usize, data: *const u8, data_len: usize) -> i32; } } @@ -283,6 +292,9 @@ mod host { pub fn widget_render(s: &[u8]) -> i32 { unsafe { sys::widget_render(s.as_ptr(), s.len()) } } + pub fn save_file(name: &[u8], mime: &[u8], data: &[u8]) -> i32 { + unsafe { sys::save_file(name.as_ptr(), name.len(), mime.as_ptr(), mime.len(), data.as_ptr(), data.len()) } + } } /// Outside WebAssembly (a plugin's own unit tests) every host call fails. @@ -316,6 +328,9 @@ mod host { pub fn widget_render(_s: &[u8]) -> i32 { 1 } + pub fn save_file(_name: &[u8], _mime: &[u8], _data: &[u8]) -> i32 { + 1 + } } #[cfg(test)] @@ -339,6 +354,7 @@ mod tests { assert_eq!((e.control.as_str(), &e.value, &e.values), ("go", &Value::Null, &json!({"n": 2}))); assert!(show_ui(&json!({})).is_err() && setting("k").is_none() && set_setting("k", &json!(1)).is_err()); assert!(render_widget(&json!({})).is_err() && i.widget.is_none()); + assert!(save_file("a.txt", "text/plain", b"x").is_err()); let w = Input::parse(r#"{"command":"widget","widget":{"id":9,"state":{"n":1},"event":{"target":"inc"}}}"#) .unwrap_or_default() .widget diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e3f00e8b..bd863dbe 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -81,7 +81,7 @@ fn wasm() -> bool { /// The example plugins in `plugins/examples` (each folder's name is its package; its module is /// `.wasm`). -const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter"]; +const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter", "export-names"]; /// The plugin workspace: fmt, clippy and build for wasm32, host tests, then each example's manifest /// and module side by side in `/plugins/dist//`. From a370211b486ba7e36b0efc05d4700adbba731aec Mon Sep 17 00:00:00 2001 From: Dubblbindong <48181403+Dubblbindong@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:11:59 +0200 Subject: [PATCH 2/4] fix(plugin): keep the plugin directory's wire format; refuse oversized save_file data - PluginEntry no longer carries save_file: postcard is positional and existing messages keep their fields (docs/collab-protocol.md), so mixed app/server versions keep reading the directory. Manage plugins still shows "can save files". - save_file checks the data length before reading it and refuses with status 1 and the reason, as documented, instead of failing the run with a limit error. Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-server/src/directory.rs | 1 - crates/collab/src/message.rs | 2 -- crates/engine/src/plugin_directory.rs | 2 +- crates/plugin/src/sandbox.rs | 11 +++++++++++ crates/plugin/src/save_file_tests.rs | 10 ++++++---- crates/ui-egui/locales/de.toml | 1 - crates/ui-egui/locales/en.toml | 1 - crates/ui-egui/locales/es.toml | 1 - crates/ui-egui/src/plugins_ui.rs | 3 --- docs/plugins.md | 4 ++-- 10 files changed, 20 insertions(+), 16 deletions(-) diff --git a/apps/figcraft-server/src/directory.rs b/apps/figcraft-server/src/directory.rs index cdf18364..0443c0f4 100644 --- a/apps/figcraft-server/src/directory.rs +++ b/apps/figcraft-server/src/directory.rs @@ -245,7 +245,6 @@ impl Directory { }, commands: m.permissions.commands.clone(), widget: m.widget, - save_file: m.permissions.save_file, bytes: bytes.len() as u64, sha256: sha.clone(), published: now(), diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 734e4076..d035042b 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -537,8 +537,6 @@ pub struct PluginEntry { pub commands: Vec, /// It draws a widget. pub widget: bool, - /// It may offer files to save (`permissions.saveFile`). - pub save_file: bool, /// The bundle's size in bytes and its SHA-256 (hex). pub bytes: u64, pub sha256: String, diff --git a/crates/engine/src/plugin_directory.rs b/crates/engine/src/plugin_directory.rs index 1d33178f..2f721fea 100644 --- a/crates/engine/src/plugin_directory.rs +++ b/crates/engine/src/plugin_directory.rs @@ -288,7 +288,7 @@ pub fn specs() -> Vec { spec!( "plugin.directory.list", "Browse plugin directory", - "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}", + "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}", false, list ), diff --git a/crates/plugin/src/sandbox.rs b/crates/plugin/src/sandbox.rs index 59d66118..c7337dca 100644 --- a/crates/plugin/src/sandbox.rs +++ b/crates/plugin/src/sandbox.rs @@ -195,6 +195,12 @@ struct State { } impl State { + /// Make `message` the reply and return status 1 (refused). + fn refuse(&mut self, message: String) -> i32 { + self.set_reply(message.into_bytes()); + 1 + } + /// Make `bytes` the reply; returns its length. fn set_reply(&mut self, bytes: Vec) -> i32 { let max = self.limits.max_message; @@ -595,6 +601,11 @@ fn linker(engine: &Engine) -> Result, PluginError> { charge(&mut c)?; let name = read_str(&mut c, np, nl)?; let mime = read_str(&mut c, mp, ml)?; + // Too much data is a refusal the plugin can handle, not a limit that fails the run. + let (len, max) = (dl as u32 as usize, c.data().limits.max_message); + if len > max { + return Ok(c.data_mut().refuse(format!("a file to save is limited to {max} bytes, this one has {len}"))); + } let data = read_bytes(&mut c, dp, dl)?; Err(ask(&mut c, Request::SaveFile(name, mime, data))) }) diff --git a/crates/plugin/src/save_file_tests.rs b/crates/plugin/src/save_file_tests.rs index 34241bb7..7915323f 100644 --- a/crates/plugin/src/save_file_tests.rs +++ b/crates/plugin/src/save_file_tests.rs @@ -98,12 +98,14 @@ fn a_host_without_saving_refuses_by_default() { } #[test] -fn too_much_data_is_a_limit_error_and_bad_utf8_a_trap() { - let limits = Limits { max_message: 4, ..Limits::default() }; - let wasm = module("a.txt", "text/plain", b"12345", &call("a.txt", "text/plain", b"12345")); +fn too_much_data_is_refused_and_bad_utf8_a_trap() { + let limits = Limits { max_message: 64, ..Limits::default() }; + let data = [b'x'; 65]; + let wasm = module("a.txt", "text/plain", &data, &call("a.txt", "text/plain", &data)); let mut host = Saves::default(); + // Status 1 with the reason, which the module passes on as its own failure. let err = Instance::new(&wasm, &limits).unwrap().call("{}", &mut host).unwrap_err(); - assert!(matches!(err, PluginError::Limit(_)), "{err:?}"); + assert_eq!(err, PluginError::Failed("a file to save is limited to 64 bytes, this one has 65".into())); assert!(host.0.is_empty()); // The name points at a lone 0xff byte: not UTF-8. diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 08aa5c1a..03de53af 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1533,7 +1533,6 @@ status = "draft — needs native review" "read and edit the document" = "das Dokument lesen und bearbeiten" "read the document" = "das Dokument lesen" "reads the document" = "liest das Dokument" -"save files you choose" = "Dateien an einem von Ihnen gewählten Ort speichern" "set-variable actions on variables that aren't in the variables JSON were skipped" = "Aktionen „Variable festlegen“ für Variablen, die nicht im Variablen-JSON sind, wurden übersprungen" "set-variable actions with values that can't be evaluated were skipped" = "Aktionen „Variable festlegen“ mit nicht auswertbaren Werten wurden übersprungen" "set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 58890934..8a31028e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1621,7 +1621,6 @@ name = "English" "read and edit the document" = "read and edit the document" "read the document" = "read the document" "reads the document" = "reads the document" -"save files you choose" = "save files you choose" "set-variable actions on variables that aren't in the variables JSON were skipped" = "set-variable actions on variables that aren't in the variables JSON were skipped" "set-variable actions with values that can't be evaluated were skipped" = "set-variable actions with values that can't be evaluated were skipped" "set-variable actions with values that don't fit the variable were skipped" = "set-variable actions with values that don't fit the variable were skipped" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1f92bf91..08e47492 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1622,7 +1622,6 @@ status = "draft — needs native review" "read and edit the document" = "leer y editar el documento" "read the document" = "leer el documento" "reads the document" = "lee el documento" -"save files you choose" = "guardar archivos donde tú elijas" "set-variable actions on variables that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer variable» sobre variables que no están en el JSON de variables" "set-variable actions with values that can't be evaluated were skipped" = "se omitieron las acciones «Establecer variable» con valores que no se pueden evaluar" "set-variable actions with values that don't fit the variable were skipped" = "se omitieron las acciones «Establecer variable» con valores que no encajan con la variable" diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 919fd484..7933fa7b 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -477,9 +477,6 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { if e.widget { asks.push(tr("put a widget on the canvas").to_string()); } - if e.save_file { - asks.push(tr("save files you choose").to_string()); - } for a in asks { ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(regular(11.0)).color(t.text)).wrap()); } diff --git a/docs/plugins.md b/docs/plugins.md index ed4b9927..a14dd7a4 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -135,7 +135,7 @@ plugins: import them in the script. | `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. | | `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | | `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. | -| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | +| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | | `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | | `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. | | `plugin.directory.unpublish` | `{id, version?, server}` | Removes one version (default: every version) from the directory; needs the key. Returns `{id, removed}`. | @@ -225,7 +225,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p | `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | | `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | -| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. The install confirmation lists it. | +| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"); a plugin directory's entries don't carry it, so their wire format stays as it is. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | | `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. | From 551d2b9db6e79c0939320afceec753b86aa6727f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:12:40 +0700 Subject: [PATCH 3/4] #698: with plugin API 4 here, "4.0.0" is a valid api version string; the refused one is 5.0.0 --- crates/plugin/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index 3b840f5a..a2d888c9 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -468,6 +468,6 @@ fn api_may_be_a_version_string() { assert_eq!(manifest(json!({"api": "1.0.0"})).unwrap().api, 1); assert_eq!(manifest(json!({"api": "2"})).unwrap().api, 2); assert_eq!(manifest(json!({"api": 2})).unwrap().api, 2); - assert!(manifest(json!({"api": "4.0.0"})).is_err()); + assert!(manifest(json!({"api": "5.0.0"})).is_err()); assert!(manifest(json!({"api": "x"})).is_err()); } From 3b39f7cb3b9ff02a9207a45ade16bc50a21ce63d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:18:12 +0700 Subject: [PATCH 4/4] #698: installing an API 4 plugin from a directory says it may offer files to save; the save strings in every catalog --- crates/ui-egui/locales/bn.toml | 4 ++++ crates/ui-egui/locales/cs.toml | 4 ++++ crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 4 ++++ crates/ui-egui/locales/fr.toml | 4 ++++ crates/ui-egui/locales/id.toml | 4 ++++ crates/ui-egui/locales/ja.toml | 4 ++++ crates/ui-egui/locales/ka.toml | 4 ++++ crates/ui-egui/locales/ko.toml | 4 ++++ crates/ui-egui/locales/nl.toml | 4 ++++ crates/ui-egui/locales/pl.toml | 4 ++++ crates/ui-egui/locales/pt-BR.toml | 4 ++++ crates/ui-egui/locales/ru.toml | 4 ++++ crates/ui-egui/locales/sv.toml | 4 ++++ crates/ui-egui/locales/tr.toml | 4 ++++ crates/ui-egui/locales/zh-Hans.toml | 4 ++++ crates/ui-egui/src/plugins_browse_tests.rs | 5 +++++ crates/ui-egui/src/plugins_ui.rs | 6 ++++++ docs/plugins.md | 2 +- 21 files changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index bc7c948c..8f4c3e10 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -324,6 +324,7 @@ status = "draft — needs native review" "Couldn't download the variables" = "ভেরিয়েবলগুলো ডাউনলোড করা যায়নি" "Couldn't reach {server}: showing what this computer remembers." = "{server}-এ পৌঁছানো যায়নি: এই কম্পিউটার যা মনে রেখেছে তা দেখানো হচ্ছে।" "Couldn't save automatically: {error}" = "স্বয়ংক্রিয়ভাবে সেভ করা যায়নি: {error}" +"Couldn't save the file" = "ফাইলটি সেভ করা যায়নি" "Count" = "গণনা" "Create" = "তৈরি করুন" "Create a variable collection first" = "প্রথমে একটি ভেরিয়েবল সংগ্রহ তৈরি করুন" @@ -1168,6 +1169,7 @@ status = "draft — needs native review" "Rulers" = "রুলার" "Run" = "চালান" "Run {label}" = "চালান {label}" +"Save file" = "ফাইল সেভ করুন" "Save files automatically" = "স্বয়ংক্রিয়ভাবে ফাইল সেভ করুন" "Search templates" = "টেমপ্লেট খুঁজুন" "See all templates" = "সব টেমপ্লেট দেখুন" @@ -1569,6 +1571,7 @@ status = "draft — needs native review" "call any command its access allows" = "এর অ্যাক্সেস অনুমতি দেয় এমন যেকোনো কমান্ড কল করুন" "call only: {commands}" = "শুধুমাত্র কল করুন: {commands}" "can edit the document" = "ডকুমেন্ট এডিট করতে পারেন" +"can save files" = "ফাইল সেভ করতে পারে" "conditional prototype actions nested too deeply were skipped" = "শর্তসাপেক্ষ প্রোটোটাইপ অ্যাকশনগুলো খুব গভীরভাবে নেস্ট করার কারণে বাদ দেওয়া হয়েছে" "conditional prototype actions with conditions that can't be evaluated were skipped" = "মূল্যায়ন করা যায় না এমন শর্তযুক্ত প্রোটোটাইপ অ্যাকশনগুলো বাদ দেওয়া হয়েছে" "connector label background was skipped: style data is missing" = "কানেক্টর লেবেলের ব্যাকগ্রাউন্ড বাদ দেওয়া হয়েছে: স্টাইল ডেটা অনুপস্থিত" @@ -1628,6 +1631,7 @@ status = "draft — needs native review" "nobody else here yet" = "এখানে আর কেউ নেই" "non-default slot settings are not represented and were skipped" = "অ-ডিফল্ট স্লট সেটিংস উপস্থাপিত হয়নি এবং বাদ দেওয়া হয়েছে" "now" = "এখন" +"offer files to save if it asks to (you choose where each one goes)" = "চাইলে সেভ করার জন্য ফাইল দিতে (প্রতিটি কোথায় যাবে, আপনি বেছে নেন)" "only in a text style or variable" = "শুধুমাত্র টেক্সট স্টাইল বা ভেরিয়েবলে" "or" = "অথবা" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "পয়েন্ট স্ট্রোক জয়েন্ট সংরক্ষিত হয়েছে; ড্যাশড বা পরিবর্তনশীল প্রস্থের স্ট্রোক লেয়ার জয়েন্ট দিয়ে রেন্ডার হয়" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index e7a2dec1..c01ebecb 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -314,6 +314,7 @@ status = "draft — needs native review" "Corner smoothing" = "Vyhlazení rohů" "Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači." "Couldn't read {name}." = "Soubor {name} se nepodařilo přečíst." +"Couldn't save the file" = "Soubor se nepodařilo uložit" "Count" = "Počet" "Create" = "Vytvořit" "Create a variable collection first" = "Nejprve vytvořte kolekci proměnných" @@ -1146,6 +1147,7 @@ status = "draft — needs native review" "Rulers" = "Pravítka" "Run" = "Spustit" "Run {label}" = "Spustit {label}" +"Save file" = "Uložit soubor" "Search templates" = "Hledat šablony" "See all templates" = "Zobrazit všechny šablony" "Select layers and they space themselves as their content changes" = "Vyberte vrstvy a jejich rozestupy se přizpůsobí změnám obsahu" @@ -1535,6 +1537,7 @@ status = "draft — needs native review" "call any command its access allows" = "volat libovolný příkaz povolený jeho oprávněními" "call only: {commands}" = "volat pouze: {commands}" "can edit the document" = "může upravovat dokument" +"can save files" = "může ukládat soubory" "conditional prototype actions nested too deeply were skipped" = "podmíněné akce prototypu vnořené příliš hluboko byly přeskočeny" "conditional prototype actions with conditions that can't be evaluated were skipped" = "podmíněné akce prototypu s podmínkami, které nelze vyhodnotit, byly přeskočeny" "connector label background was skipped: style data is missing" = "pozadí popisku spojnice bylo přeskočeno: chybí data stylu" @@ -1594,6 +1597,7 @@ status = "draft — needs native review" "nobody else here yet" = "zatím tu není nikdo další" "non-default slot settings are not represented and were skipped" = "jiná než výchozí nastavení slotů nelze znázornit a byla přeskočena" "now" = "nyní" +"offer files to save if it asks to (you choose where each one goes)" = "nabízet soubory k uložení, pokud o to požádá (kam který půjde, vyberete vy)" "or" = "nebo" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "spojení tahu v bodech byla zachována; přerušované tahy a tahy s proměnnou šířkou se kreslí se spojením vrstvy" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "klíče preferovaných komponent slotů nelze přiřadit k vrstvám souboru, byly přeskočeny" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index e88adab8..d614741e 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1555,6 +1555,7 @@ status = "draft — needs native review" "nobody else here yet" = "noch niemand sonst hier" "non-default slot settings are not represented and were skipped" = "vom Standard abweichende Slot-Einstellungen werden nicht abgebildet und wurden übersprungen" "now" = "jetzt" +"offer files to save if it asks to (you choose where each one goes)" = "Dateien zum Speichern anbieten, falls es darum bittet (Sie wählen, wohin jede kommt)" "or" = "oder" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "die Konturecken der Punkte wurden übernommen; gestrichelte Konturen und Konturen mit variabler Breite werden mit der Ecke der Ebene gezeichnet" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "bevorzugte Komponentenschlüssel von Slots lassen sich keinen Ebenen der Datei zuordnen und wurden übersprungen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index e049d5fe..ae0daed4 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1654,6 +1654,7 @@ name = "English" "nobody else here yet" = "nobody else here yet" "non-default slot settings are not represented and were skipped" = "non-default slot settings are not represented and were skipped" "now" = "now" +"offer files to save if it asks to (you choose where each one goes)" = "offer files to save if it asks to (you choose where each one goes)" "only in a text style or variable" = "only in a text style or variable" "or" = "or" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1c15086d..4e006372 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1655,6 +1655,7 @@ status = "draft — needs native review" "nobody else here yet" = "aún no hay nadie más" "non-default slot settings are not represented and were skipped" = "los ajustes de ranura no predeterminados no se representan y se omitieron" "now" = "ahora" +"offer files to save if it asks to (you choose where each one goes)" = "ofrecer archivos para guardar si lo pide (tú eliges dónde va cada uno)" "only in a text style or variable" = "solo en un estilo de texto o una variable" "or" = "o" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index f18ffeaf..fdd10b9a 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -326,6 +326,7 @@ status = "draft — needs native review" "Couldn't reach {server}: showing what this computer remembers." = "Palvelimeen {server} ei saatu yhteyttä. Näytetään tämän tietokoneen muistamat tiedot." "Couldn't read {name}." = "Tiedostoa {name} ei voitu lukea." "Couldn't save automatically: {error}" = "Automaattinen tallennus epäonnistui: {error}" +"Couldn't save the file" = "Tiedostoa ei voitu tallentaa" "Count" = "Määrä" "Create" = "Luo" "Create a variable collection first" = "Luo ensin muuttujakokoelma" @@ -1178,6 +1179,7 @@ status = "draft — needs native review" "Save" = "Tallenna" "Save as" = "Tallenna nimellä" "Save as preset…" = "Tallenna esiasetuksena…" +"Save file" = "Tallenna tiedosto" "Save files automatically" = "Tallenna tiedostot automaattisesti" "Save version {key}" = "Tallenna versio {key}" "Saved" = "Tallennettu" @@ -1574,6 +1576,7 @@ status = "draft — needs native review" "call any command its access allows" = "kutsua kaikkia käyttöoikeuksiensa sallimia komentoja" "call only: {commands}" = "kutsua vain: {commands}" "can edit the document" = "voi muokata asiakirjaa" +"can save files" = "voi tallentaa tiedostoja" "conditional prototype actions nested too deeply were skipped" = "liian syvälle sisäkkäiset ehdolliset prototyyppitoiminnot ohitettiin" "conditional prototype actions with conditions that can't be evaluated were skipped" = "ehdolliset prototyyppitoiminnot ohitettiin, koska niiden ehtoja ei voi laskea" "connector label background was skipped: style data is missing" = "yhdistimen tekstin tausta ohitettiin: tyylitiedot puuttuvat" @@ -1633,6 +1636,7 @@ status = "draft — needs native review" "nobody else here yet" = "kukaan muu ei ole vielä paikalla" "non-default slot settings are not represented and were skipped" = "oletuksesta poikkeavia sisältöpaikan asetuksia ei esitetä, ja ne ohitettiin" "now" = "nyt" +"offer files to save if it asks to (you choose where each one goes)" = "tarjota tiedostoja tallennettavaksi, jos se pyytää (valitset itse kunkin paikan)" "only in a text style or variable" = "vain tekstityylissä tai muuttujassa" "or" = "tai" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "pisteiden viivaliitokset säilytettiin; katko- ja vaihtelevanlevyiset viivat käyttävät tason liitostyyliä" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index e5823983..1aec92cc 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -324,6 +324,7 @@ status = "draft — needs native review" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." "Couldn't read {name}." = "Impossible de lire {name}." "Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" +"Couldn't save the file" = "Impossible d’enregistrer le fichier" "Count" = "Nombre" "Create" = "Créer" "Create a variable collection first" = "Créez d’abord une collection de variables" @@ -1179,6 +1180,7 @@ status = "draft — needs native review" "Save" = "Enregistrer" "Save as" = "Enregistrer sous" "Save as preset…" = "Enregistrer comme préréglage…" +"Save file" = "Enregistrer le fichier" "Save files automatically" = "Enregistrer les fichiers automatiquement" "Save version {key}" = "Enregistrer une version {key}" "Saved" = "Enregistré" @@ -1577,6 +1579,7 @@ status = "draft — needs native review" "call any command its access allows" = "appeler toute commande que son accès autorise" "call only: {commands}" = "appeler seulement : {commands}" "can edit the document" = "peut modifier le document" +"can save files" = "peut enregistrer des fichiers" "conditional prototype actions nested too deeply were skipped" = "les actions de prototype conditionnelles trop profondément imbriquées ont été ignorées" "conditional prototype actions with conditions that can't be evaluated were skipped" = "les actions de prototype conditionnelles dont les conditions ne peuvent pas être évaluées ont été ignorées" "connector label background was skipped: style data is missing" = "l’arrière-plan du libellé du connecteur a été ignoré : les données de style sont manquantes" @@ -1636,6 +1639,7 @@ status = "draft — needs native review" "nobody else here yet" = "personne d’autre pour l’instant" "non-default slot settings are not represented and were skipped" = "les réglages d’emplacement autres que ceux par défaut ne sont pas représentés et ont été ignorés" "now" = "maintenant" +"offer files to save if it asks to (you choose where each one goes)" = "proposer des fichiers à enregistrer s’il le demande (vous choisissez où va chacun)" "or" = "ou" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "les jointures des points ont été conservées ; les contours en pointillés ou à largeur variable sont dessinés avec la jointure du calque" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "les clés de composants préférés des emplacements ne correspondent à aucun calque du fichier et ont été ignorées" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index c06f2dff..a666b02d 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -318,6 +318,7 @@ status = "draft — needs native review" "Corner smoothing" = "Penghalusan sudut" "Couldn't reach {server}: showing what this computer remembers." = "Tidak dapat terhubung ke {server}: menampilkan data yang diingat komputer ini." "Couldn't read {name}." = "Tidak dapat membaca {name}." +"Couldn't save the file" = "Tidak dapat menyimpan file" "Count" = "Jumlah" "Create" = "Buat" "Create a variable collection first" = "Buat koleksi variabel terlebih dahulu" @@ -1150,6 +1151,7 @@ status = "draft — needs native review" "Rulers" = "Penggaris" "Run" = "Jalankan" "Run {label}" = "Jalankan {label}" +"Save file" = "Simpan file" "Search templates" = "Cari templat" "See all templates" = "Lihat semua templat" "Select layers and they space themselves as their content changes" = "Pilih layer dan jaraknya akan menyesuaikan saat kontennya berubah" @@ -1539,6 +1541,7 @@ status = "draft — needs native review" "call any command its access allows" = "memanggil perintah apa pun yang diizinkan aksesnya" "call only: {commands}" = "hanya memanggil: {commands}" "can edit the document" = "dapat mengedit dokumen" +"can save files" = "dapat menyimpan file" "conditional prototype actions nested too deeply were skipped" = "tindakan prototype kondisional yang bersarang terlalu dalam dilewati" "conditional prototype actions with conditions that can't be evaluated were skipped" = "tindakan prototype kondisional dengan kondisi yang tidak dapat dievaluasi dilewati" "connector label background was skipped: style data is missing" = "latar belakang label konektor dilewati: data gaya tidak ada" @@ -1598,6 +1601,7 @@ status = "draft — needs native review" "nobody else here yet" = "belum ada orang lain di sini" "non-default slot settings are not represented and were skipped" = "pengaturan slot non-default tidak dapat direpresentasikan dan dilewati" "now" = "sekarang" +"offer files to save if it asks to (you choose where each one goes)" = "menawarkan file untuk disimpan jika memintanya (Anda memilih lokasi tiap file)" "or" = "atau" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "sambungan stroke per titik dipertahankan; stroke putus-putus atau berlebar variabel digambar dengan sambungan layer" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "kunci component pilihan slot tidak dapat dicocokkan dengan layer di file dan dilewati" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index d7ee7287..415e67f2 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -322,6 +322,7 @@ status = "draft — needs native review" "Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。" "Couldn't read {name}." = "{name} を読み込めませんでした。" "Couldn't save automatically: {error}" = "自動保存できませんでした:{error}" +"Couldn't save the file" = "ファイルを保存できませんでした" "Count" = "数" "Create" = "作成" "Create a variable collection first" = "先に変数コレクションを作成" @@ -1166,6 +1167,7 @@ status = "draft — needs native review" "Save" = "保存" "Save as" = "名前を付けて保存" "Save as preset…" = "プリセットとして保存…" +"Save file" = "ファイルを保存" "Save files automatically" = "ファイルを自動保存" "Save version {key}" = "バージョンを保存 {key}" "Saved" = "保存済み" @@ -1558,6 +1560,7 @@ status = "draft — needs native review" "call any command its access allows" = "アクセス権で許可された任意のコマンドの呼び出し" "call only: {commands}" = "次のコマンドのみの呼び出し:{commands}" "can edit the document" = "ドキュメントを編集できる" +"can save files" = "ファイルを保存できる" "conditional prototype actions nested too deeply were skipped" = "入れ子が深すぎる条件付きプロトタイプアクションをスキップ" "conditional prototype actions with conditions that can't be evaluated were skipped" = "評価できない条件を持つ条件付きプロトタイプアクションをスキップ" "connector label background was skipped: style data is missing" = "コネクタラベルの背景をスキップ:スタイルデータがない" @@ -1617,6 +1620,7 @@ status = "draft — needs native review" "nobody else here yet" = "まだ他に誰もいない" "non-default slot settings are not represented and were skipped" = "デフォルト以外のスロット設定は再現できないためスキップ" "now" = "たった今" +"offer files to save if it asks to (you choose where each one goes)" = "要求した場合のファイル保存の提案(保存先はその都度選択)" "or" = "または" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "ポイントごとの角の形状は保持した。破線や可変幅の線はレイヤーの角の形状で描画" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "スロットの優先コンポーネントキーをファイル内のレイヤーに解決できないためスキップ" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 42f78be6..de5f60fe 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -326,6 +326,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Couldn't download the variables" = "ცვლადების ჩამოტვირთვა ვერ მოხერხდა" "Couldn't reach {server}: showing what this computer remembers." = "{server}-თან დაკავშირება ვერ მოხერხდა: ნაჩვენებია ის, რაც ამ კომპიუტერს ახსოვს." "Couldn't save automatically: {error}" = "ავტომატურად შენახვა ვერ მოხერხდა: {error}" +"Couldn't save the file" = "ფაილის შენახვა ვერ მოხერხდა" "Count" = "რაოდენობა" "Create" = "შექმნა" "Create a variable collection first" = "ჯერ შექმენით ცვლადების კოლექცია" @@ -1170,6 +1171,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Rulers" = "სახაზავები" "Run" = "გაშვება" "Run {label}" = "გაშვება: {label}" +"Save file" = "ფაილის შენახვა" "Save files automatically" = "ფაილების ავტომატურად შენახვა" "Search templates" = "შაბლონების ძიება" "See all templates" = "ყველა შაბლონის ნახვა" @@ -1571,6 +1573,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "call any command its access allows" = "ნებისმიერი ბრძანების გამოძახება, რასაც მისი წვდომა უშვებს" "call only: {commands}" = "მხოლოდ გამოძახება: {commands}" "can edit the document" = "შეუძლია დოკუმენტის რედაქტირება" +"can save files" = "შეუძლია ფაილების შენახვა" "conditional prototype actions nested too deeply were skipped" = "ზედმეტად ღრმად ჩადგმული პირობითი მოქმედებები პროტოტიპში გამოტოვებულია" "conditional prototype actions with conditions that can't be evaluated were skipped" = "პროტოტიპის პირობითი მოქმედებები, რომელთა პირობების გამოთვლა ვერ ხერხდება, გამოტოვებულია" "connector label background was skipped: style data is missing" = "შემაერთებლის იარლიყის ფონი გამოტოვებულია: სტილის მონაცემები აკლია" @@ -1630,6 +1633,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "nobody else here yet" = "აქ ჯერ სხვა არავინაა" "non-default slot settings are not represented and were skipped" = "სლოტის არანაგულისხმევი პარამეტრები არ არის წარმოდგენილი და გამოტოვებულია" "now" = "ახლა" +"offer files to save if it asks to (you choose where each one goes)" = "შესანახად ფაილების შეთავაზება, თუ ამას ითხოვს (თითოეულის ადგილს თქვენ ირჩევთ)" "only in a text style or variable" = "მხოლოდ ტექსტის სტილში ან ცვლადში" "or" = "ან" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "წერტილების კონტურის შეერთებები შენარჩუნებულია; ტირეებიანი ან ცვლადი სისქის კონტურები შრის შეერთებით გამოისახება" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 66eb54c2..6e7f5ed0 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -311,6 +311,7 @@ status = "draft — needs native review" "Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함." "Couldn't read {name}." = "{name}을(를) 읽을 수 없습니다." "Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}" +"Couldn't save the file" = "파일을 저장할 수 없음" "Count" = "개수" "Create" = "만들기" "Create a variable collection first" = "먼저 변수 컬렉션을 만드세요" @@ -1116,6 +1117,7 @@ status = "draft — needs native review" "Save" = "저장" "Save as" = "다른 이름으로 저장" "Save as preset…" = "프리셋으로 저장…" +"Save file" = "파일 저장" "Save files automatically" = "파일 자동 저장" "Save version {key}" = "버전 저장 {key}" "Saved" = "저장됨" @@ -1485,6 +1487,7 @@ status = "draft — needs native review" "call any command its access allows" = "접근 권한이 허용하는 모든 명령 호출" "call only: {commands}" = "다음 명령만 호출: {commands}" "can edit the document" = "문서를 편집할 수 있음" +"can save files" = "파일을 저장할 수 있음" "conditional prototype actions nested too deeply were skipped" = "너무 깊게 중첩된 조건부 프로토타입 액션을 건너뜀" "conditional prototype actions with conditions that can't be evaluated were skipped" = "평가할 수 없는 조건이 있는 조건부 프로토타입 액션을 건너뜀" "connector label background was skipped: style data is missing" = "커넥터 레이블 배경을 건너뜀: 스타일 데이터가 없음" @@ -1543,6 +1546,7 @@ status = "draft — needs native review" "nobody else here yet" = "아직 다른 사람이 없음" "non-default slot settings are not represented and were skipped" = "기본값이 아닌 슬롯 설정은 표현할 수 없어 건너뜀" "now" = "방금" +"offer files to save if it asks to (you choose where each one goes)" = "요청하는 경우 저장할 파일 제공(각 파일의 위치는 직접 선택)" "or" = "또는" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "점별 선 연결을 유지함. 파선이나 가변 너비 선은 레이어의 선 연결로 그려짐" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "슬롯의 기본 설정 컴포넌트 키를 파일의 레이어로 확인할 수 없어 건너뜀" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 84f13545..eb0cf440 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -326,6 +326,7 @@ status = "draft — needs native review" "Couldn't reach {server}: showing what this computer remembers." = "Kon {server} niet bereiken: toont wat deze computer onthoudt." "Couldn't read {name}." = "Kon {name} niet lezen." "Couldn't save automatically: {error}" = "Kan niet automatisch opslaan: {error}" +"Couldn't save the file" = "Kan het bestand niet opslaan" "Count" = "Aantal" "Create" = "Maken" "Create a variable collection first" = "Maak eerst een variabeleverzameling" @@ -1178,6 +1179,7 @@ status = "draft — needs native review" "Save" = "Opslaan" "Save as" = "Opslaan als" "Save as preset…" = "Opslaan als preset…" +"Save file" = "Bestand opslaan" "Save files automatically" = "Bestanden automatisch opslaan" "Save version {key}" = "Versie opslaan {key}" "Saved" = "Opgeslagen" @@ -1574,6 +1576,7 @@ status = "draft — needs native review" "call any command its access allows" = "elke opdracht aanroepen die diens toegang toestaat" "call only: {commands}" = "alleen aanroepbaar: {commands}" "can edit the document" = "kan het document bewerken" +"can save files" = "kan bestanden opslaan" "conditional prototype actions nested too deeply were skipped" = "Voorwaardelijke prototype-acties die te diep genest zijn, zijn overgeslagen" "conditional prototype actions with conditions that can't be evaluated were skipped" = "voorwaardelijke prototypeacties met voorwaarden die niet konden worden geëvalueerd zijn overgeslagen" "connector label background was skipped: style data is missing" = "achtergrond van connectorlabel overgeslagen: stijldata ontbreekt" @@ -1633,6 +1636,7 @@ status = "draft — needs native review" "nobody else here yet" = "nog niemand anders hier" "non-default slot settings are not represented and were skipped" = "niet-standaard slot-instellingen zijn niet overgenomen en zijn overgeslagen" "now" = "nu" +"offer files to save if it asks to (you choose where each one goes)" = "bestanden aanbieden om op te slaan als het daarom vraagt (jij kiest waar elk bestand komt)" "only in a text style or variable" = "alleen in een tekststijl of variabele" "or" = "of" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "Lijnverbindingen op punten zijn behouden; gestreepte lijnen of lijnen met variabele breedte worden weergegeven met de verbinding van de laag" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 7f301a6e..c3538668 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -323,6 +323,7 @@ status = "draft — needs native review" "Couldn't reach {server}: showing what this computer remembers." = "Nie udało się połączyć z {server}: pokazywanie zawartości zapamiętanej przez ten komputer." "Couldn't read {name}." = "Nie udało się odczytać pliku {name}." "Couldn't save automatically: {error}" = "Nie udało się zapisać automatycznie: {error}" +"Couldn't save the file" = "Nie udało się zapisać pliku" "Count" = "Licznik" "Create" = "Utwórz" "Create a variable collection first" = "Najpierw utwórz kolekcję zmiennych" @@ -1168,6 +1169,7 @@ status = "draft — needs native review" "Rulers" = "Linijki" "Run" = "Uruchom" "Run {label}" = "Uruchom {label}" +"Save file" = "Zapisz plik" "Save files automatically" = "Zapisuj pliki automatycznie" "Search templates" = "Szukaj szablonów" "See all templates" = "Zobacz wszystkie szablony" @@ -1569,6 +1571,7 @@ status = "draft — needs native review" "call any command its access allows" = "wywołaj dowolne polecenie, na które zezwala dostęp" "call only: {commands}" = "wywołaj tylko: {commands}" "can edit the document" = "może edytować dokument" +"can save files" = "może zapisywać pliki" "conditional prototype actions nested too deeply were skipped" = "zbyt głęboko zagnieżdżone warunkowe akcje prototypu zostały pominięte" "conditional prototype actions with conditions that can't be evaluated were skipped" = "warunkowe akcje prototypu z warunkami niemożliwymi do obliczenia zostały pominięte" "connector label background was skipped: style data is missing" = "tło etykiety łącznika zostało pominięte: brak danych stylu" @@ -1628,6 +1631,7 @@ status = "draft — needs native review" "nobody else here yet" = "nikogo innego tu jeszcze nie ma" "non-default slot settings are not represented and were skipped" = "niedomyślne ustawienia gniazd nie są reprezentowane i zostały pominięte" "now" = "teraz" +"offer files to save if it asks to (you choose where each one goes)" = "proponować pliki do zapisania, jeśli o to poprosi (miejsce każdego wybierasz sam)" "only in a text style or variable" = "tylko w stylu tekstu lub zmiennej" "or" = "lub" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "połączenia punktów obrysu zostały zachowane; obrysy przerywane lub o zmiennej szerokości renderują się z łączeniem warstwy" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 22fffb17..62084c28 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -310,6 +310,7 @@ status = "draft — needs native review" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." "Couldn't read {name}." = "Não foi possível ler {name}." "Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}" +"Couldn't save the file" = "Não foi possível salvar o arquivo" "Count" = "Quantidade" "Create" = "Criar" "Create a variable collection first" = "Crie primeiro uma coleção de variáveis" @@ -1115,6 +1116,7 @@ status = "draft — needs native review" "Save" = "Salvar" "Save as" = "Salvar como" "Save as preset…" = "Salvar como predefinição…" +"Save file" = "Salvar arquivo" "Save files automatically" = "Salvar arquivos automaticamente" "Save version {key}" = "Salvar versão {key}" "Saved" = "Salvo" @@ -1484,6 +1486,7 @@ status = "draft — needs native review" "call any command its access allows" = "chamar qualquer comando que seu acesso permita" "call only: {commands}" = "chamar apenas: {commands}" "can edit the document" = "pode editar o documento" +"can save files" = "pode salvar arquivos" "conditional prototype actions nested too deeply were skipped" = "ações condicionais de protótipo aninhadas profundamente demais foram ignoradas" "conditional prototype actions with conditions that can't be evaluated were skipped" = "ações condicionais de protótipo com condições que não podem ser avaliadas foram ignoradas" "connector label background was skipped: style data is missing" = "o fundo do rótulo do conector foi ignorado: faltam os dados de estilo" @@ -1542,6 +1545,7 @@ status = "draft — needs native review" "nobody else here yet" = "ainda não há mais ninguém" "non-default slot settings are not represented and were skipped" = "configurações de slot diferentes do padrão não são representadas e foram ignoradas" "now" = "agora" +"offer files to save if it asks to (you choose where each one goes)" = "oferecer arquivos para salvar, se pedir (você escolhe onde cada um fica)" "or" = "ou" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "as junções de traço por ponto foram mantidas; traços tracejados ou de largura variável são desenhados com a junção da camada" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "chaves de componentes preferidos de slots não correspondem a camadas do arquivo e foram ignoradas" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index baba56fd..3bf386b5 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -325,6 +325,7 @@ name = "Русский" "Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере." "Couldn't read {name}." = "Не удалось прочитать {name}." "Couldn't save automatically: {error}" = "Не удалось сохранить автоматически: {error}" +"Couldn't save the file" = "Не удалось сохранить файл" "Count" = "Количество" "Create" = "Создать" "Create a variable collection first" = "Сначала создайте коллекцию переменных" @@ -1177,6 +1178,7 @@ name = "Русский" "Rulers" = "Линейки" "Run" = "Запустить" "Run {label}" = "Запустить {label}" +"Save file" = "Сохранить файл" "Save files automatically" = "Автоматически сохранять файлы" "Search pages" = "Поиск страниц" "Search templates" = "Поиск шаблонов" @@ -1584,6 +1586,7 @@ name = "Русский" "call any command its access allows" = "вызывать любые команды в рамках разрешений" "call only: {commands}" = "вызывать только: {commands}" "can edit the document" = "может редактировать документ" +"can save files" = "может сохранять файлы" "conditional prototype actions nested too deeply were skipped" = "условные действия прототипа со слишком глубокой вложенностью пропущены" "conditional prototype actions with conditions that can't be evaluated were skipped" = "условные действия прототипа с условиями, которые нельзя вычислить, пропущены" "connector label background was skipped: style data is missing" = "фон подписи соединительной линии пропущен: нет данных стиля" @@ -1643,6 +1646,7 @@ name = "Русский" "nobody else here yet" = "здесь пока больше никого нет" "non-default slot settings are not represented and were skipped" = "нестандартные настройки слотов не передаются и пропущены" "now" = "сейчас" +"offer files to save if it asks to (you choose where each one goes)" = "предлагать файлы для сохранения, если запросит (куда сохранить каждый, выбираете вы)" "only in a text style or variable" = "только в текстовом стиле или переменной" "or" = "или" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "соединения обводки в точках сохранены; пунктирные обводки и обводки переменной ширины рисуются с соединением слоя" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index be25d38e..e5af1e18 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -320,6 +320,7 @@ name = "Svenska" "Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns." "Couldn't read {name}." = "Det gick inte att läsa {name}." "Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}" +"Couldn't save the file" = "Det gick inte att spara filen" "Count" = "Antal" "Create" = "Skapa" "Create a variable collection first" = "Skapa en variabelsamling först" @@ -1160,6 +1161,7 @@ name = "Svenska" "Rulers" = "Linjaler" "Run" = "Kör" "Run {label}" = "Kör {label}" +"Save file" = "Spara fil" "Save files automatically" = "Spara filer automatiskt" "Search templates" = "Sök mallar" "See all templates" = "Se alla mallar" @@ -1557,6 +1559,7 @@ name = "Svenska" "call any command its access allows" = "anropa alla kommandon som dess åtkomst tillåter" "call only: {commands}" = "anropa endast: {commands}" "can edit the document" = "kan redigera dokumentet" +"can save files" = "kan spara filer" "conditional prototype actions nested too deeply were skipped" = "villkorliga prototypåtgärder som var nästlade för djupt hoppades över" "conditional prototype actions with conditions that can't be evaluated were skipped" = "villkorliga prototypåtgärder med villkor som inte kan utvärderas hoppades över" "connector label background was skipped: style data is missing" = "bakgrunden till etiketten på en kopplingslinje hoppades över: stildata saknas" @@ -1616,6 +1619,7 @@ name = "Svenska" "nobody else here yet" = "ingen annan här än" "non-default slot settings are not represented and were skipped" = "fackinställningar som avviker från standard återges inte och hoppades över" "now" = "nu" +"offer files to save if it asks to (you choose where each one goes)" = "erbjuda filer att spara om den ber om det (du väljer var varje fil hamnar)" "or" = "eller" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "punkternas hörntyper behölls; streckade konturer och konturer med variabel bredd ritas med lagrets hörntyp" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "nycklar för föredragna komponenter i fack kan inte kopplas till lager i filen och hoppades över" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 5d15c9c9..5e71a784 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -318,6 +318,7 @@ name = "Türkçe" "Corner smoothing" = "Köşe yumuşatma" "Couldn't reach {server}: showing what this computer remembers." = "{server} sunucusuna ulaşılamadı: bu bilgisayarın hatırladıkları gösteriliyor." "Couldn't read {name}." = "{name} okunamadı." +"Couldn't save the file" = "Dosya kaydedilemedi" "Count" = "Sayı" "Create" = "Oluştur" "Create a variable collection first" = "Önce bir değişken koleksiyonu oluşturun" @@ -1150,6 +1151,7 @@ name = "Türkçe" "Rulers" = "Cetveller" "Run" = "Çalıştır" "Run {label}" = "Çalıştır: {label}" +"Save file" = "Dosyayı kaydet" "Search templates" = "Şablonlarda ara" "See all templates" = "Tüm şablonları gör" "Select layers and they space themselves as their content changes" = "Katmanları seçin; içerikleri değiştikçe kendi aralıklarını ayarlarlar" @@ -1539,6 +1541,7 @@ name = "Türkçe" "call any command its access allows" = "erişiminin izin verdiği her komutu çağırmak" "call only: {commands}" = "yalnızca şunları çağırmak: {commands}" "can edit the document" = "belgeyi düzenleyebilir" +"can save files" = "dosya kaydedebilir" "conditional prototype actions nested too deeply were skipped" = "çok derin iç içe geçmiş koşullu prototip eylemleri atlandı" "conditional prototype actions with conditions that can't be evaluated were skipped" = "değerlendirilemeyen koşullara sahip koşullu prototip eylemleri atlandı" "connector label background was skipped: style data is missing" = "bağlayıcı etiketinin arka planı atlandı: stil verileri eksik" @@ -1598,6 +1601,7 @@ name = "Türkçe" "nobody else here yet" = "burada henüz başka kimse yok" "non-default slot settings are not represented and were skipped" = "varsayılan dışındaki yuva ayarları aktarılamıyor ve atlandı" "now" = "şimdi" +"offer files to save if it asks to (you choose where each one goes)" = "isterse kaydedilecek dosya sunmak (her birinin yerini siz seçersiniz)" "or" = "veya" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "nokta başına kontur birleşimleri korundu; kesikli veya değişken genişlikli konturlar katmanın birleşimiyle çizilir" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "yuvaların tercih edilen bileşen anahtarları dosyadaki katmanlarla eşleştirilemedi ve atlandı" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 7add99c5..1348a1ae 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -312,6 +312,7 @@ status = "draft — needs native review" "Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。" "Couldn't read {name}." = "无法读取 {name}。" "Couldn't save automatically: {error}" = "无法自动保存:{error}" +"Couldn't save the file" = "无法保存文件" "Count" = "数量" "Create" = "创建" "Create a variable collection first" = "请先创建变量集合" @@ -1117,6 +1118,7 @@ status = "draft — needs native review" "Save" = "保存" "Save as" = "另存为" "Save as preset…" = "存储为预设…" +"Save file" = "保存文件" "Save files automatically" = "自动保存文件" "Save version {key}" = "保存版本 {key}" "Saved" = "已保存" @@ -1486,6 +1488,7 @@ status = "draft — needs native review" "call any command its access allows" = "调用其访问权限允许的任何命令" "call only: {commands}" = "仅调用:{commands}" "can edit the document" = "可编辑文档" +"can save files" = "可以保存文件" "conditional prototype actions nested too deeply were skipped" = "已跳过嵌套过深的条件原型操作" "conditional prototype actions with conditions that can't be evaluated were skipped" = "已跳过条件无法求值的条件原型操作" "connector label background was skipped: style data is missing" = "已跳过连接线标签背景:缺少样式数据" @@ -1544,6 +1547,7 @@ status = "draft — needs native review" "nobody else here yet" = "这里还没有其他人" "non-default slot settings are not represented and were skipped" = "非默认的插槽设置无法表示,已跳过" "now" = "刚刚" +"offer files to save if it asks to (you choose where each one goes)" = "在请求时提供要保存的文件(每个文件的保存位置由你选择)" "or" = "或" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "已保留各点的描边转角;虚线描边或可变宽度描边以图层的转角绘制" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "插槽的首选组件键无法对应到文件中的图层,已跳过" diff --git a/crates/ui-egui/src/plugins_browse_tests.rs b/crates/ui-egui/src/plugins_browse_tests.rs index 50585c48..a4a2d6ce 100644 --- a/crates/ui-egui/src/plugins_browse_tests.rs +++ b/crates/ui-egui/src/plugins_browse_tests.rs @@ -170,6 +170,11 @@ fn install_asks_first_and_then_installs() { for shown in ["read and edit the document", "call only: shape.rectangle", "From ws://127.0.0.1:9/"] { assert!(shows(&mut u, shown), "the confirmation shows {shown:?}"); } + // An API 4 plugin may ask to save files: the confirmation says so. + let save = "offer files to save"; + assert!(!shows(&mut u, save)); + u.app.session.plugins.directory.as_mut().unwrap().entries[0].api = 4; + assert!(shows(&mut u, save), "an API 4 plugin's confirmation mentions saving files"); let confirm = named(&mut u, "Install").into_iter().map(|n| centre(&n)).find(|p| p.distance(row_at) > 4.0).unwrap(); click(&mut u, confirm); assert_eq!(*fetched.lock().unwrap(), 1); diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 7933fa7b..2f30ac69 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -477,6 +477,12 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { if e.widget { asks.push(tr("put a widget on the canvas").to_string()); } + // shortcut: a directory entry doesn't say whether the manifest asks for `saveFile` (a new + // field would break the entries' postcard wire format), so every API 4 plugin says it may; + // carry the permission in the entry once the directory protocol negotiates new fields. + if e.api >= 4 { + asks.push(tr("offer files to save if it asks to (you choose where each one goes)").to_string()); + } for a in asks { ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(regular(11.0)).color(t.text)).wrap()); } diff --git a/docs/plugins.md b/docs/plugins.md index 6df9a863..18206e91 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -225,7 +225,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p | `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | | `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | -| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"); a plugin directory's entries don't carry it, so their wire format stays as it is. | +| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"). A plugin directory's entries don't carry it (their wire format stays as it is), so installing any API 4 plugin from one says it may offer files to save. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | | `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. |