mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 04:46:50 -06:00
M7: WASM plugins (#101)
Sandboxed WebAssembly plugins whose API is the command registry: - figcraft-plugin (new crate, L2): the figcraft-plugin.json manifest (menu commands with parameters, read/write access, command allowlist) and the sandbox: wasmi (pure Rust, no JIT, also in the web build) with fuel metering, a memory cap, no WASI and seven host functions (call, selection, reply_len, reply_read, log, notify, set_output). - Engine: plugin.list / plugin.import / plugin.remove / plugin.run, plugins.runLast (Alt+Cmd+P; Ctrl+Alt+Shift+P on Windows/Linux) and plugins.manage. A run is one undo step (Session::grouped, which command.batch now uses too) and is rolled back when it fails. Files, other documents, the undo history, dialogs and plugins are off limits; read-only plugins can't change the document. Installed plugins live in the preferences; development plugins re-read their module every run. - UI: installed plugins in the Plugins menu, a parameters form drawn from the manifest, Manage plugins, Plugins > Development > Import plugin from manifest. - plugins/: a no_std Rust SDK and two example plugins (Tidy names, Swatch grid), built for wasm32 by `cargo xtask plugins` (part of `cargo xtask wasm`). - Docs: docs/plugins.md (manifest, permissions, limits, ABI, SDK), docs/mcp.md, docs/control-protocol.md, README, ROADMAP, parity. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+119
@@ -0,0 +1,119 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin-sdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swatch-grid"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"figcraft-plugin-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tidy-names"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"figcraft-plugin-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,33 @@
|
||||
# FigCraft plugins written in Rust: the SDK and example plugins. A workspace of its own (not part of
|
||||
# the app's): it builds for wasm32-unknown-unknown with `cargo xtask plugins` (from the repository
|
||||
# root), which puts each example's .wasm next to its figcraft-plugin.json under target/plugins/.
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["sdk", "examples/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/figcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
figcraft-plugin-sdk = { path = "sdk" }
|
||||
serde_json = { version = "1", default-features = false, features = ["alloc"] }
|
||||
|
||||
[workspace.lints.clippy]
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
todo = "deny"
|
||||
unimplemented = "deny"
|
||||
unreachable = "deny"
|
||||
|
||||
# Small modules: a plugin is loaded on every run.
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "swatch-grid"
|
||||
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,25 @@
|
||||
{
|
||||
"id": "org.figcraft.examples.swatch-grid",
|
||||
"name": "Swatch grid",
|
||||
"version": "1.0.0",
|
||||
"description": "Makes a grid of colour swatches from a palette of hex colours.",
|
||||
"main": "swatch_grid.wasm",
|
||||
"api": 1,
|
||||
"permissions": {
|
||||
"access": "write",
|
||||
"commands": ["view.get", "shape.rectangle", "text.create", "select.set"]
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"id": "grid",
|
||||
"label": "Swatch grid…",
|
||||
"params": [
|
||||
{ "name": "palette", "label": "Palette", "type": "text", "default": "#FF5A5F, #FFB400, #00A699, #007A87, #7B0051, #3D3D3D" },
|
||||
{ "name": "columns", "label": "Columns", "type": "number", "default": 3, "min": 1, "max": 24 },
|
||||
{ "name": "size", "label": "Swatch size", "type": "number", "default": 64, "min": 8, "max": 512 },
|
||||
{ "name": "gap", "label": "Gap", "type": "number", "default": 16, "min": 0, "max": 256 },
|
||||
{ "name": "labels", "label": "Label each swatch", "type": "boolean", "default": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Swatch grid: a grid of colour swatches from a palette (hex colours), centred in the view, each
|
||||
//! named after its colour and optionally labelled.
|
||||
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, log, notify};
|
||||
|
||||
/// Swatches made in one run, at most.
|
||||
const MAX_SWATCHES: usize = 256;
|
||||
|
||||
/// `#RGB` or `#RRGGBB` (the `#` optional) as `#RRGGBB`.
|
||||
fn hex(s: &str) -> Option<String> {
|
||||
let h = s.trim().trim_start_matches('#');
|
||||
if !h.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
match h.len() {
|
||||
3 => Some(h.chars().flat_map(|c| [c, c]).collect::<String>()),
|
||||
6 => Some(h.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
.map(|h| format!("#{}", h.to_uppercase()))
|
||||
}
|
||||
|
||||
/// The palette's valid colours, logging the rest.
|
||||
fn palette(text: &str) -> Vec<String> {
|
||||
text.split([',', ' ', ';', '\n'])
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.filter_map(|s| {
|
||||
let c = hex(s);
|
||||
if c.is_none() {
|
||||
log(&format!("skipped `{s}`: not a hex colour"));
|
||||
}
|
||||
c
|
||||
})
|
||||
.take(MAX_SWATCHES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
let colors = palette(input.str("palette").unwrap_or_default());
|
||||
if colors.is_empty() {
|
||||
return Err("The palette has no hex colours (like #FF5A5F, #00A699)".into());
|
||||
}
|
||||
let columns = input.f64("columns").unwrap_or(3.0).clamp(1.0, 24.0) as usize;
|
||||
let size = input.f64("size").unwrap_or(64.0).clamp(8.0, 512.0);
|
||||
let gap = input.f64("gap").unwrap_or(16.0).clamp(0.0, 256.0);
|
||||
let labels = input.bool("labels").unwrap_or(true);
|
||||
let rows = colors.len().div_ceil(columns);
|
||||
let cell_h = if labels { size + 20.0 } else { size };
|
||||
let (w, h) = (columns as f64 * (size + gap) - gap, rows as f64 * (cell_h + gap) - gap);
|
||||
// Centred in the view, on whole pixels.
|
||||
let view = call("view.get", &json!({}))?;
|
||||
let (x0, y0) = ((view["cx"].as_f64().unwrap_or(0.0) - w / 2.0).round(), (view["cy"].as_f64().unwrap_or(0.0) - h / 2.0).round());
|
||||
let mut made = Vec::new();
|
||||
for (i, color) in colors.iter().enumerate() {
|
||||
let (x, y) = (x0 + (i % columns) as f64 * (size + gap), y0 + (i / columns) as f64 * (cell_h + gap));
|
||||
let r = call("shape.rectangle", &json!({"x": x, "y": y, "width": size, "height": size, "props": {"name": color, "fill": color}}))?;
|
||||
made.extend(r["id"].as_u64());
|
||||
if labels {
|
||||
let t = call("text.create", &json!({"text": color, "x": x, "y": y + size + 4.0, "props": {"fontSize": 11}}))?;
|
||||
made.extend(t["id"].as_u64());
|
||||
}
|
||||
}
|
||||
call("select.set", &json!({"ids": made}))?;
|
||||
notify(&format!("Made {} swatches", colors.len()));
|
||||
Ok(json!({"swatches": colors.len(), "ids": made}))
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{hex, palette};
|
||||
|
||||
#[test]
|
||||
fn palettes_parse() {
|
||||
assert_eq!(hex("#f0a"), Some("#FF00AA".into()));
|
||||
assert_eq!(hex(" 00a699 "), Some("#00A699".into()));
|
||||
assert_eq!(hex("#12345"), None);
|
||||
assert_eq!(hex("red"), None);
|
||||
assert_eq!(palette("#FF5A5F, nope;#0f0\n00A699"), vec!["#FF5A5F", "#00FF00", "#00A699"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "tidy-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,23 @@
|
||||
{
|
||||
"id": "org.figcraft.examples.tidy-names",
|
||||
"name": "Tidy names",
|
||||
"version": "1.0.0",
|
||||
"description": "Cleans up layer names: extra spaces, the \"copy\" left by duplicating, and letter case.",
|
||||
"main": "tidy_names.wasm",
|
||||
"api": 1,
|
||||
"permissions": {
|
||||
"access": "write",
|
||||
"commands": ["node.get", "object.rename"]
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"id": "tidy",
|
||||
"label": "Tidy names…",
|
||||
"params": [
|
||||
{ "name": "case", "label": "Case", "type": "select", "options": ["Keep", "Title Case", "lower case", "UPPER CASE"], "default": "Keep" },
|
||||
{ "name": "children", "label": "Include layers inside", "type": "boolean", "default": true }
|
||||
]
|
||||
},
|
||||
{ "id": "quick", "label": "Quick tidy" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Tidy names: cleans up the selected layers' names (extra spaces, the "copy" left by duplicating)
|
||||
//! and optionally their letter case, inside the selection too.
|
||||
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, notify};
|
||||
|
||||
/// Layers looked at in one run, at most.
|
||||
const MAX_LAYERS: usize = 10_000;
|
||||
|
||||
/// `name` with single spaces, no trailing "copy" / "copy 2", in `case`.
|
||||
fn tidy(name: &str, case: &str) -> String {
|
||||
let mut words: Vec<&str> = name.split_whitespace().collect();
|
||||
loop {
|
||||
match words.as_slice() {
|
||||
[_, .., "copy"] => {
|
||||
words.pop();
|
||||
}
|
||||
[_, .., "copy", n] if n.chars().all(|c| c.is_ascii_digit()) => {
|
||||
words.truncate(words.len() - 2);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let joined = words.join(" ");
|
||||
match case {
|
||||
"Title Case" => words.iter().map(|w| title(w)).collect::<Vec<_>>().join(" "),
|
||||
"lower case" => joined.to_lowercase(),
|
||||
"UPPER CASE" => joined.to_uppercase(),
|
||||
_ => joined,
|
||||
}
|
||||
}
|
||||
|
||||
fn title(word: &str) -> String {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().chain(chars.flat_map(char::to_lowercase)).collect(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
if input.selection.is_empty() {
|
||||
return Err("Select the layers to tidy first".into());
|
||||
}
|
||||
let case = if input.command == "quick" { "Keep" } else { input.str("case").unwrap_or("Keep") };
|
||||
let inside = input.command == "quick" || input.bool("children").unwrap_or(true);
|
||||
let mut stack = input.selection.clone();
|
||||
let (mut seen, mut renamed) = (0, 0);
|
||||
while let Some(id) = stack.pop() {
|
||||
seen += 1;
|
||||
if seen > MAX_LAYERS {
|
||||
break;
|
||||
}
|
||||
let node = call("node.get", &json!({"id": id}))?;
|
||||
if inside && let Some(children) = node["children"].as_array() {
|
||||
stack.extend(children.iter().filter_map(|c| c["id"].as_u64()));
|
||||
}
|
||||
let name = node["name"].as_str().unwrap_or_default();
|
||||
let clean = tidy(name, case);
|
||||
if !clean.is_empty() && clean != name {
|
||||
call("object.rename", &json!({"ids": [id], "name": clean}))?;
|
||||
renamed += 1;
|
||||
}
|
||||
}
|
||||
notify(&match renamed {
|
||||
0 => "The names were already tidy".to_string(),
|
||||
1 => "Renamed 1 layer".to_string(),
|
||||
n => format!("Renamed {n} layers"),
|
||||
});
|
||||
Ok(json!({"renamed": renamed}))
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tidy;
|
||||
|
||||
#[test]
|
||||
fn names_are_tidied() {
|
||||
assert_eq!(tidy(" Card title copy 2 ", "Keep"), "Card title");
|
||||
assert_eq!(tidy("Button copy copy", "Keep"), "Button");
|
||||
assert_eq!(tidy("copy", "Keep"), "copy", "a name that is only `copy` stays");
|
||||
assert_eq!(tidy("primary BUTTON", "Title Case"), "Primary Button");
|
||||
assert_eq!(tidy("Hero Image", "lower case"), "hero image");
|
||||
assert_eq!(tidy("nav", "UPPER CASE"), "NAV");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "figcraft-plugin-sdk"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Write FigCraft plugins in Rust: the plugin ABI (host functions, the entry point) wrapped with serde_json"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Write FigCraft plugins in Rust.
|
||||
//!
|
||||
//! A plugin is a `cdylib` built for `wasm32-unknown-unknown`. It gets the command the user picked
|
||||
//! with its parameters and the selection, and acts on the document by calling FigCraft commands
|
||||
//! (the ids `figcraft-cli commands` lists) with JSON parameters. Everything one run does is one
|
||||
//! undo step; when the run returns an error (or panics), the document is put back.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json};
|
||||
//!
|
||||
//! fn run(input: Input) -> Result<Value> {
|
||||
//! for id in &input.selection {
|
||||
//! call("object.rename", &json!({"ids": [id], "name": "Tidy"}))?;
|
||||
//! }
|
||||
//! Ok(json!(format!("Renamed {} layers", input.selection.len())))
|
||||
//! }
|
||||
//!
|
||||
//! entry!(run);
|
||||
//! ```
|
||||
//!
|
||||
//! The SDK is `no_std` (it needs `alloc`); outside WebAssembly every host call fails, so a plugin's
|
||||
//! own logic can still be unit-tested on the host. See `docs/plugins.md` for the ABI underneath.
|
||||
#![cfg_attr(not(test), no_std)]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
use alloc::format;
|
||||
use alloc::string::{String, ToString};
|
||||
use alloc::vec::Vec;
|
||||
|
||||
pub use serde_json::{self, Value, json};
|
||||
|
||||
/// What a plugin function returns: its output (shown or returned to the caller), or an error that
|
||||
/// fails the run and rolls the document back.
|
||||
pub type Result<T> = core::result::Result<T, String>;
|
||||
|
||||
/// The run's input.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Input {
|
||||
/// The manifest command the user picked.
|
||||
pub command: String,
|
||||
/// Its parameters, with defaults filled in.
|
||||
pub params: Value,
|
||||
/// The selected layer ids.
|
||||
pub selection: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// Parse the input JSON FigCraft hands to the plugin.
|
||||
pub fn parse(text: &str) -> Result<Input> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("input: {e}"))?;
|
||||
Ok(Input {
|
||||
command: v.get("command").and_then(Value::as_str).unwrap_or_default().to_string(),
|
||||
params: v.get("params").cloned().unwrap_or(Value::Null),
|
||||
selection: ids(v.get("selection")),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn str(&self, name: &str) -> Option<&str> {
|
||||
self.params.get(name).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
pub fn f64(&self, name: &str) -> Option<f64> {
|
||||
self.params.get(name).and_then(Value::as_f64).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
pub fn bool(&self, name: &str) -> Option<bool> {
|
||||
self.params.get(name).and_then(Value::as_bool)
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer ids in a JSON array.
|
||||
fn ids(v: Option<&Value>) -> Vec<u64> {
|
||||
v.and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Run FigCraft command `command` with `params`: its result, or why it was refused or failed.
|
||||
pub fn call(command: &str, params: &Value) -> Result<Value> {
|
||||
let params = serde_json::to_string(params).map_err(|e| e.to_string())?;
|
||||
let status = host::call(command.as_bytes(), params.as_bytes());
|
||||
let reply = String::from_utf8(host::reply()).map_err(|_| "the reply isn't UTF-8".to_string())?;
|
||||
if status != 0 {
|
||||
return Err(reply);
|
||||
}
|
||||
serde_json::from_str(&reply).map_err(|e| format!("`{command}` returned {e}"))
|
||||
}
|
||||
|
||||
/// The selected layer ids now (the input has them as they were when the run started).
|
||||
pub fn selection() -> Vec<u64> {
|
||||
host::selection();
|
||||
let reply = host::reply();
|
||||
ids(serde_json::from_slice::<Value>(&reply).ok().as_ref())
|
||||
}
|
||||
|
||||
/// A line for the run's log (returned with the result, and in FigCraft's log).
|
||||
pub fn log(line: &str) {
|
||||
host::log(line.as_bytes());
|
||||
}
|
||||
|
||||
/// A short message shown to the user.
|
||||
pub fn notify(message: &str) {
|
||||
host::notify(message.as_bytes());
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let input = input.get(..usize::try_from(input_len).unwrap_or(0)).unwrap_or(&input);
|
||||
let result = core::str::from_utf8(input).map_err(|_| "the input isn't UTF-8".to_string()).and_then(Input::parse).and_then(f);
|
||||
match result {
|
||||
Ok(v) => {
|
||||
host::set_output(serde_json::to_string(&v).unwrap_or_default().as_bytes());
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
host::set_output(e.as_bytes());
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export `f: fn(Input) -> Result<Value>` as the plugin's entry point.
|
||||
#[macro_export]
|
||||
macro_rules! entry {
|
||||
($f:path) => {
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn figcraft_run(input_len: i32) -> i32 {
|
||||
$crate::run_plugin(input_len, $f)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// The host functions FigCraft provides (module `figcraft`).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[allow(unsafe_code)]
|
||||
mod host {
|
||||
use alloc::vec;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
mod sys {
|
||||
#[link(wasm_import_module = "figcraft")]
|
||||
unsafe extern "C" {
|
||||
pub fn call(cmd: *const u8, cmd_len: usize, params: *const u8, params_len: usize) -> i32;
|
||||
pub fn selection() -> i32;
|
||||
pub fn reply_len() -> i32;
|
||||
pub fn reply_read(ptr: *mut u8, len: usize) -> i32;
|
||||
pub fn log(ptr: *const u8, len: usize);
|
||||
pub fn notify(ptr: *const u8, len: usize);
|
||||
pub fn set_output(ptr: *const u8, len: usize);
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY (every call below): the host reads only the `len` bytes at `ptr`, which are a live
|
||||
// slice, and writes only up to `len` bytes into the buffer it's given; it checks bounds itself.
|
||||
pub fn call(cmd: &[u8], params: &[u8]) -> i32 {
|
||||
unsafe { sys::call(cmd.as_ptr(), cmd.len(), params.as_ptr(), params.len()) }
|
||||
}
|
||||
pub fn selection() -> i32 {
|
||||
unsafe { sys::selection() }
|
||||
}
|
||||
pub fn reply() -> Vec<u8> {
|
||||
let len = usize::try_from(unsafe { sys::reply_len() }).unwrap_or(0);
|
||||
let mut buf = vec![0u8; len];
|
||||
let got = unsafe { sys::reply_read(buf.as_mut_ptr(), buf.len()) };
|
||||
buf.truncate(usize::try_from(got).unwrap_or(0));
|
||||
buf
|
||||
}
|
||||
pub fn log(s: &[u8]) {
|
||||
unsafe { sys::log(s.as_ptr(), s.len()) }
|
||||
}
|
||||
pub fn notify(s: &[u8]) {
|
||||
unsafe { sys::notify(s.as_ptr(), s.len()) }
|
||||
}
|
||||
pub fn set_output(s: &[u8]) {
|
||||
unsafe { sys::set_output(s.as_ptr(), s.len()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Outside WebAssembly (a plugin's own unit tests) every host call fails.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod host {
|
||||
use alloc::vec::Vec;
|
||||
|
||||
pub fn call(_cmd: &[u8], _params: &[u8]) -> i32 {
|
||||
1
|
||||
}
|
||||
pub fn selection() -> i32 {
|
||||
0
|
||||
}
|
||||
pub fn reply() -> Vec<u8> {
|
||||
b"not running inside FigCraft".to_vec()
|
||||
}
|
||||
pub fn log(_s: &[u8]) {}
|
||||
pub fn notify(_s: &[u8]) {}
|
||||
pub fn set_output(_s: &[u8]) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn input_parses_and_host_calls_fail_off_wasm() {
|
||||
let i = Input::parse(r#"{"api":1,"command":"grid","params":{"n":3,"s":"x","b":true},"selection":[4,"x",7]}"#).unwrap_or_default();
|
||||
assert_eq!(i.command, "grid");
|
||||
assert_eq!(i.selection, alloc::vec![4, 7]);
|
||||
assert_eq!((i.f64("n"), i.str("s"), i.bool("b")), (Some(3.0), Some("x"), Some(true)));
|
||||
assert!(Input::parse("nope").is_err());
|
||||
assert!(call("node.get", &json!({"id": 1})).is_err());
|
||||
assert!(selection().is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user