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 <noreply@anthropic.com>
Assisted-by: Claude (Anthropic)
This commit is contained in:
Dubblbindong
2026-10-16 20:12:54 +02:00
co-authored by Claude Sonnet 5.5
parent d1d8c3f652
commit ed74fc67d0
26 changed files with 638 additions and 22 deletions
+1 -1
View File
@@ -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.
+1
View File
@@ -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(),
+2
View File
@@ -537,6 +537,8 @@ pub struct PluginEntry {
pub commands: Vec<String>,
/// 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,
+4 -1
View File
@@ -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).
+1 -1
View File
@@ -281,7 +281,7 @@ pub fn specs() -> Vec<CommandSpec> {
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
),
+68 -7
View File
@@ -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<String> {
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<RunOutput, (EngineError, bool)> {
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.
+126
View File
@@ -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<u8> {
let hex = |b: &[u8]| b.iter().map(|b| format!("\\{b:02x}")).collect::<String>();
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"));
}
+2
View File
@@ -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;
+9 -2
View File
@@ -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<String>,
/// 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() });
}
+25 -1
View File
@@ -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<u8>),
}
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<Linker<State>, 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<i32, wasmi::Error> {
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)
}
+142
View File
@@ -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<u8>)>);
impl Host for Saves {
fn call(&mut self, _command: &str, _params: &str) -> Result<String, String> {
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<String, String> {
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<u8> {
let bytes = |b: &[u8]| b.iter().map(|b| format!("\\{b:02x}")).collect::<String>();
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"));
}
+1 -1
View File
@@ -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"]}}),
+1 -1
View File
@@ -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");
}
+4
View File
@@ -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}"
+4
View File
@@ -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"
+4
View File
@@ -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"
+3
View File
@@ -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),
_ => {}
@@ -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<Mutex<Vec<(String, Vec<u8>)>>>;
/// 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());
}
+36
View File
@@ -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)]),
+17 -6
View File
@@ -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`,
+7
View File
@@ -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"
+15
View File
@@ -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 }
@@ -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 }
]
}
]
}
+55
View File
@@ -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<Value> {
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<u64> = 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(&[]), "");
}
}
+16
View File
@@ -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<Value>) -> 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
+1 -1
View File
@@ -81,7 +81,7 @@ fn wasm() -> bool {
/// The example plugins in `plugins/examples` (each folder's name is its package; its module is
/// `<name with _>.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 `<target>/plugins/dist/<example>/`.