M7: plugin panels and settings (#124)
Plugin ABI v2: a plugin can show a panel (text, number, checkbox, select, colour, list, notes, buttons) rendered with FigCraft's widgets, react to events with its state kept between calls (each editing callback one undo step, full sandbox limits on every call), and store per-plugin settings (64 KB). plugin.ui / plugin.event / plugin.close drive panels from the CLI and MCP; v1 plugins run unchanged. The Swatch grid example gains a Palettes panel that remembers the last palette.
This commit is contained in:
parent
09a3b74466
commit
a003e3de4f
|
|
@ -1585,6 +1585,7 @@ dependencies = [
|
|||
name = "figcraft-plugin"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -1673,6 +1674,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
15
ROADMAP.md
15
ROADMAP.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
|
|
@ -19,13 +19,13 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
|||
| Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Mainstream practitioner | **~43%** | estimated: weekly areas 66.8% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~200–365 h** one agent | estimated |
|
||||
| Remaining to full parity | **~373–712 h** one agent (~95–165 h wall-clock with 5 agents) | estimated |
|
||||
| Remaining to full parity | **~363–697 h** one agent (~95–165 h wall-clock with 5 agents) | estimated |
|
||||
|
||||
### Readiness by audience
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~47% | ~355–675 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
|
||||
| Full target (ready for real work) | ~47% | ~345–660 h | multiplayer (45–90), localization (60–100), plugins (15–40), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
|
||||
| Mainstream practitioner | ~43% | ~165–310 h | core editor depth incl. `.fig` import (83–163), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
|
||||
| Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ the localization layer with Figma's own languages (20–35 h).
|
|||
| Hardware | 55% | 13–27 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | 10% (English only) | 60–100 | [localization-parity](docs/localization-parity.md) |
|
||||
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Ecosystem / plugins | 20% (own WASM plugins) | 25–55 | [gaps](docs/gaps.md) |
|
||||
| Ecosystem / plugins | 28% (own WASM plugins with panels and settings) | 15–40 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
|
||||
## Features
|
||||
|
|
@ -89,7 +89,7 @@ the localization layer with Figma's own languages (20–35 h).
|
|||
| Dev Mode / inspect | 90% | 64% | 1–3 | [dev-mode-parity](docs/dev-mode-parity.md) |
|
||||
| Comments, versions, misc | 80% | 58% | 4–8 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Plugins & widgets | 35% | 20% | 25–55 | [plugins](docs/plugins.md) |
|
||||
| Plugins & widgets | 45% | 28% | 15–40 | [plugins](docs/plugins.md) |
|
||||
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
|
||||
| Figma Draw extras | 80% | 55% | 2–5 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
|
|
@ -127,6 +127,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-11** · Plugin panels and settings (gap 6, ABI v2; v1 plugins run unchanged): a plugin shows a panel of
|
||||
fields and buttons drawn with FigCraft's widgets and is called back for each button or changed value (one undo step
|
||||
per callback, same sandbox limits), `plugin.ui` / `plugin.event` / `plugin.close` drive panels from scripts and MCP,
|
||||
and per-plugin settings are kept with the preferences; Swatch grid gained a palette panel. Plugins & widgets 45%.
|
||||
- **2026-10-11** · Canvas fixes: clicks hit boolean groups where their result is (a click in a
|
||||
subtracted hole reaches what's behind), a layer dragged inside a group stays in it, View ▸ Frame
|
||||
outlines and View ▸ Slices draw, and a flaky idle test now waits for the render thread (gap 13).
|
||||
|
|
@ -174,6 +178,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
|
||||
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
|
||||
| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h |
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ pub use figcraft_doc as doc;
|
|||
pub use figcraft_svg::looks_like_svg;
|
||||
pub use tools::{PointerEvent, PointerKind, ToolId};
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{Document, Node, NodeId};
|
||||
|
|
@ -492,6 +492,10 @@ pub struct Prefs {
|
|||
/// Installed plugins (Plugins menu), in menu order.
|
||||
#[serde(serialize_with = "plugins::persisted")]
|
||||
pub plugins: Vec<plugins::InstalledPlugin>,
|
||||
/// Plugins' own settings (`setting_set`), by plugin id, then key: JSON values, capped per
|
||||
/// plugin.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub plugin_settings: BTreeMap<String, BTreeMap<String, Value>>,
|
||||
/// Recently opened or saved files, newest first (File ▸ Open recent; at most
|
||||
/// [`cmd::file::MAX_RECENT`]).
|
||||
pub recent_files: Vec<String>,
|
||||
|
|
@ -512,6 +516,7 @@ impl Default for Prefs {
|
|||
reduce_motion: false,
|
||||
invite_links: None,
|
||||
plugins: Vec::new(),
|
||||
plugin_settings: BTreeMap::new(),
|
||||
recent_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1257,6 +1262,8 @@ mod pdf_print_tests;
|
|||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod plugin_panels_tests;
|
||||
#[cfg(test)]
|
||||
mod plugins_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_blur_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
//! Plugin panels and settings (ABI v2): a panel round trip with state kept between callbacks and
|
||||
//! one undo step per callback that edits, settings that persist and are capped, junk panels and
|
||||
//! values refused, a callback that runs out of fuel rolled back, closing ends the session. Test
|
||||
//! plugins are WAT that do step `k` on their `k`-th call and output their input.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::components::tests::ex;
|
||||
use crate::plugins_tests::{install, layer_count, lit, manifest, rect, undo_len};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// Lays out a test plugin's strings and builds its module.
|
||||
#[derive(Default)]
|
||||
struct Wat {
|
||||
data: String,
|
||||
at: usize,
|
||||
}
|
||||
|
||||
/// Where a refusal is copied to, and where the input is kept.
|
||||
const REFUSAL: usize = 180_000;
|
||||
const INPUT: usize = 200_000;
|
||||
|
||||
impl Wat {
|
||||
/// `text` in the module's memory: (address, length).
|
||||
fn s(&mut self, text: &str) -> (usize, usize) {
|
||||
let a = 1000 + self.at;
|
||||
self.data += &format!("(data (i32.const {a}) \"{}\")\n", lit(text));
|
||||
self.at += text.len();
|
||||
(a, text.len())
|
||||
}
|
||||
|
||||
/// Output the reply (a refusal) and stop.
|
||||
fn refused() -> String {
|
||||
format!("(call $set_output (i32.const {REFUSAL}) (call $reply_read (i32.const {REFUSAL}) (i32.const 20000))) (return (i32.const 0))")
|
||||
}
|
||||
|
||||
fn show(&mut self, spec: &Value) -> String {
|
||||
let (a, l) = self.s(&spec.to_string());
|
||||
format!("(if (call $ui_show (i32.const {a}) (i32.const {l})) (then {}))", Self::refused())
|
||||
}
|
||||
|
||||
fn call(&mut self, cmd: &str, params: &Value) -> String {
|
||||
let ((a, l), (b, m)) = (self.s(cmd), self.s(¶ms.to_string()));
|
||||
format!(
|
||||
"(if (call $call (i32.const {a}) (i32.const {l}) (i32.const {b}) (i32.const {m})) (then {} ))",
|
||||
Self::refused().replace("(i32.const 0))", "(i32.const 1))")
|
||||
)
|
||||
}
|
||||
|
||||
/// Set `key` to `json`; a refusal is the output.
|
||||
fn set(&mut self, key: &str, json: &str) -> String {
|
||||
let ((a, l), (b, m)) = (self.s(key), self.s(json));
|
||||
format!("(if (call $setting_set (i32.const {a}) (i32.const {l}) (i32.const {b}) (i32.const {m})) (then {}))", Self::refused())
|
||||
}
|
||||
|
||||
/// When `key` is set, output its value and stop.
|
||||
fn get(&mut self, key: &str) -> String {
|
||||
let (a, l) = self.s(key);
|
||||
format!("(if (i32.eqz (call $setting_get (i32.const {a}) (i32.const {l}))) (then {}))", Self::refused())
|
||||
}
|
||||
|
||||
fn build(self, steps: &[String]) -> Vec<u8> {
|
||||
let body: String =
|
||||
steps.iter().enumerate().map(|(i, s)| format!("(if (i32.eq (local.get $k) (i32.const {})) (then {s}))\n", i + 1)).collect();
|
||||
let wat = format!(
|
||||
r#"(module
|
||||
(import "figcraft" "call" (func $call (param 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)))
|
||||
(import "figcraft" "ui_show" (func $ui_show (param i32 i32) (result i32)))
|
||||
(import "figcraft" "ui_close" (func $ui_close))
|
||||
(import "figcraft" "setting_get" (func $setting_get (param i32 i32) (result i32)))
|
||||
(import "figcraft" "setting_set" (func $setting_set (param i32 i32 i32 i32) (result i32)))
|
||||
(memory (export "memory") 4)
|
||||
{}
|
||||
(func (export "figcraft_run") (param $n i32) (result i32) (local $k i32)
|
||||
(drop (call $reply_read (i32.const {INPUT}) (local.get $n)))
|
||||
(i32.store (i32.const 0) (i32.add (i32.load (i32.const 0)) (i32.const 1)))
|
||||
(local.set $k (i32.load (i32.const 0)))
|
||||
{body}
|
||||
(call $set_output (i32.const {INPUT}) (local.get $n))
|
||||
(i32.const 0)))"#,
|
||||
self.data
|
||||
);
|
||||
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn v2(id: &str) -> Value {
|
||||
let mut m = manifest(id, "write", &[]);
|
||||
m["api"] = json!(2);
|
||||
m
|
||||
}
|
||||
|
||||
fn panel(title: &str) -> Value {
|
||||
json!({"title": title, "controls": [
|
||||
{"type": "note", "label": "Pick a size"},
|
||||
{"type": "number", "name": "n", "label": "Size", "value": 10, "min": 1, "max": 100},
|
||||
{"type": "list", "name": "palette", "options": ["Warm", "Cool"]},
|
||||
{"type": "color", "name": "accent", "value": "#FF5A5F"},
|
||||
{"type": "button", "name": "go", "label": "Go"}
|
||||
]})
|
||||
}
|
||||
|
||||
fn ui(s: &mut Session) -> Value {
|
||||
ex(s, "plugin.ui", json!({}))
|
||||
}
|
||||
|
||||
fn event_err(s: &mut Session, p: Value) -> String {
|
||||
match s.execute("plugin.event", &p) {
|
||||
Ok(v) => panic!("accepted {p}: {v}"),
|
||||
Err(e) => {
|
||||
assert!(!matches!(e, EngineError::Internal { .. }), "{e}");
|
||||
e.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_round_trip_keeps_state_and_edits_are_one_undo_step_per_callback() {
|
||||
let mut s = Session::new();
|
||||
let mut w = Wat::default();
|
||||
let steps = [
|
||||
w.show(&panel("Sizes")),
|
||||
String::new(),
|
||||
format!(
|
||||
"{} {}",
|
||||
w.call("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})),
|
||||
w.show(&json!({"title": "Done", "controls": [{"type": "number", "name": "n"}]}))
|
||||
),
|
||||
"(call $ui_close)".into(),
|
||||
];
|
||||
install(&mut s, v2("org.test.panel"), &w.build(&steps));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.panel"}));
|
||||
assert_eq!(out["output"]["api"], 2);
|
||||
assert_eq!(out["panel"]["title"], "Sizes");
|
||||
assert_eq!(out["panel"]["values"], json!({"n": 10.0, "palette": "Warm", "accent": "#FF5A5F"}));
|
||||
assert_eq!(ui(&mut s), out["panel"]);
|
||||
assert_eq!(undo_len(&s), 0);
|
||||
// A changed value calls back with the event and every value; nothing edited, no undo step.
|
||||
let out = ex(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "n", "value": "250"}));
|
||||
assert_eq!(out["output"]["event"], json!({"control": "n", "value": 100.0}), "coerced like a parameter");
|
||||
assert_eq!(out["output"]["values"]["n"], 100.0);
|
||||
assert_eq!(out["output"]["command"], "run");
|
||||
assert_eq!(undo_len(&s), 0);
|
||||
// The third call (the module remembers it is the third) edits and updates the panel.
|
||||
let layers = layer_count(&s);
|
||||
let out = ex(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "go"}));
|
||||
assert_eq!(out["output"]["event"], json!({"control": "go", "value": null}));
|
||||
assert_eq!(layer_count(&s), layers + 1);
|
||||
assert_eq!(undo_len(&s), 1, "one undo step for the callback");
|
||||
assert_eq!(s.ds().unwrap().history.undo.last().unwrap().label, "Plugin org.test.panel");
|
||||
assert_eq!(out["panel"]["title"], "Done");
|
||||
assert_eq!(out["panel"]["values"], json!({"n": 100.0}), "a value entered before the update is kept");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(layer_count(&s), layers);
|
||||
// The fourth closes it.
|
||||
let out = ex(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "n", "value": 5}));
|
||||
assert_eq!(out["panel"], Value::Null);
|
||||
assert_eq!(ui(&mut s), Value::Null);
|
||||
assert!(event_err(&mut s, json!({"id": "org.test.panel", "control": "go"})).contains("no panel open"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_plugins_get_the_input_they_always_got() {
|
||||
let mut s = Session::new();
|
||||
let w = Wat::default();
|
||||
let wasm = w.build(&[]);
|
||||
install(&mut s, manifest("org.test.v1", "read", &[]), &wasm);
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.v1"}));
|
||||
assert_eq!(out["output"], json!({"api": 1, "command": "run", "params": {}, "selection": []}));
|
||||
assert_eq!(out["panel"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_persist_across_runs_per_plugin_and_are_capped() {
|
||||
let mut s = Session::new();
|
||||
let mut w = Wat::default();
|
||||
let steps = [format!("{} {}", w.get("palette"), w.set("palette", r#"{"name": "Ocean"}"#))];
|
||||
let wasm = w.build(&steps);
|
||||
install(&mut s, v2("org.test.a"), &wasm);
|
||||
install(&mut s, v2("org.test.b"), &wasm);
|
||||
assert_eq!(ex(&mut s, "plugin.run", json!({"id": "org.test.a"}))["output"]["command"], "run", "nothing stored yet: it stores");
|
||||
assert_eq!(ex(&mut s, "plugin.run", json!({"id": "org.test.a"}))["output"], json!({"name": "Ocean"}), "the next run reads it back");
|
||||
assert_eq!(ex(&mut s, "plugin.run", json!({"id": "org.test.b"}))["output"]["command"], "run", "plugins don't see each other's settings");
|
||||
// Saved with the preferences and read back.
|
||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(saved["plugin_settings"]["org.test.a"]["palette"], json!({"name": "Ocean"}));
|
||||
let back: crate::Prefs = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(back.plugin_settings, s.prefs.plugin_settings);
|
||||
// Removing a plugin forgets its settings.
|
||||
ex(&mut s, "plugin.remove", json!({"id": "org.test.b"}));
|
||||
assert!(!s.prefs.plugin_settings.contains_key("org.test.b"));
|
||||
|
||||
// 64 KB per plugin; keys and values are checked.
|
||||
let big = format!("\"{}\"", "x".repeat(30_000));
|
||||
let mut w = Wat::default();
|
||||
let steps = [format!("{} {} {}", w.set("one", &big), w.set("two", &big), w.set("three", &big))];
|
||||
install(&mut s, v2("org.test.hoard"), &w.build(&steps));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.hoard"}));
|
||||
assert!(out["output"].as_str().unwrap_or_default().contains("limited to 64 KB"), "{out}");
|
||||
assert_eq!(s.prefs.plugin_settings["org.test.hoard"].len(), 2);
|
||||
for (key, value) in [("bad\nkey", "1"), ("", "1"), ("k", "not json"), ("k", &"[".repeat(1000))] {
|
||||
let mut w = Wat::default();
|
||||
let steps = [w.set(key, value)];
|
||||
install(&mut s, v2("org.test.junk"), &w.build(&steps));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.junk"}));
|
||||
assert!(out["output"].is_string(), "{key:?} = {value}: {out}");
|
||||
assert!(!s.prefs.plugin_settings.contains_key("org.test.junk"), "{key:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_panels_and_values_are_refused() {
|
||||
let mut s = Session::new();
|
||||
// A spec over the size cap, deep junk and unknown kinds are refused to the plugin; no panel.
|
||||
let huge = json!({"controls": [{"type": "note", "label": "x".repeat(70_000)}]});
|
||||
let deep: Value = serde_json::from_str(&format!("{}1{}", "[".repeat(100), "]".repeat(100))).unwrap();
|
||||
for spec in [
|
||||
huge,
|
||||
json!({"controls": [{"type": "number", "name": "n", "value": deep}], "title": 5}),
|
||||
json!({"controls": [{"type": "iframe", "name": "x"}]}),
|
||||
] {
|
||||
let mut w = Wat::default();
|
||||
let steps = [w.show(&spec)];
|
||||
install(&mut s, v2("org.test.junk"), &w.build(&steps));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.junk"}));
|
||||
assert!(out["output"].as_str().unwrap_or_default().contains("panel"), "{out}");
|
||||
assert_eq!(ui(&mut s), Value::Null);
|
||||
}
|
||||
// Junk events never reach the plugin.
|
||||
let mut w = Wat::default();
|
||||
let steps = [w.show(&panel("P"))];
|
||||
install(&mut s, v2("org.test.p"), &w.build(&steps));
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.p"}));
|
||||
let before = ui(&mut s);
|
||||
for bad in [
|
||||
json!({"id": "org.test.p", "control": "n", "value": "many"}),
|
||||
json!({"id": "org.test.p", "control": "n"}),
|
||||
json!({"id": "org.test.p", "control": "palette", "value": "Neon"}),
|
||||
json!({"id": "org.test.p", "control": "accent", "value": "#12345"}),
|
||||
json!({"id": "org.test.p", "control": "accent", "value": {"r": 1}}),
|
||||
json!({"id": "org.test.p", "control": "", "value": 1}),
|
||||
json!({"id": "org.test.p", "control": "nope"}),
|
||||
json!({"id": "org.test.other", "control": "go"}),
|
||||
json!({"id": "org.test.p"}),
|
||||
json!({"control": "go"}),
|
||||
json!(null),
|
||||
json!([1]),
|
||||
] {
|
||||
event_err(&mut s, bad);
|
||||
}
|
||||
assert_eq!(ui(&mut s), before, "the panel is as it was");
|
||||
for cmd in ["plugin.ui", "plugin.close"] {
|
||||
for j in [json!(null), json!([1]), json!({"id": 5})] {
|
||||
if let Err(EngineError::Internal { msg, .. }) = s.execute(cmd, &j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_callback_out_of_fuel_rolls_back_and_closes_the_panel() {
|
||||
let mut s = Session::new();
|
||||
let mut w = Wat::default();
|
||||
let edit = w.call("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
||||
let steps = [w.show(&panel("P")), format!("{edit} (return (i32.const 1))"), format!("{edit} (loop $l (br $l))")];
|
||||
install(&mut s, v2("org.test.loop"), &w.build(&steps));
|
||||
// The run's lower budget holds for its callbacks too.
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.loop", "fuel": 2_000_000}));
|
||||
let (layers, undo) = (layer_count(&s), undo_len(&s));
|
||||
// A plugin that returns an error is rolled back and keeps its panel.
|
||||
event_err(&mut s, json!({"id": "org.test.loop", "control": "go"}));
|
||||
assert_eq!((layer_count(&s), undo_len(&s)), (layers, undo));
|
||||
assert_ne!(ui(&mut s), Value::Null);
|
||||
// One that runs out of fuel is rolled back and loses it.
|
||||
let e = event_err(&mut s, json!({"id": "org.test.loop", "control": "go"}));
|
||||
assert!(e.contains("instruction budget"), "{e}");
|
||||
assert_eq!((layer_count(&s), undo_len(&s)), (layers, undo));
|
||||
assert_eq!(ui(&mut s), Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_removing_or_another_panel_ends_the_session() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, 0.0);
|
||||
let mut w = Wat::default();
|
||||
let steps = [w.show(&panel("P"))];
|
||||
let wasm = w.build(&steps);
|
||||
install(&mut s, v2("org.test.a"), &wasm);
|
||||
install(&mut s, v2("org.test.b"), &wasm);
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.a"}));
|
||||
assert_eq!(ex(&mut s, "plugin.close", json!({"id": "org.test.b"}))["closed"], false, "only its own");
|
||||
assert_eq!(ex(&mut s, "plugin.close", json!({"id": "org.test.a"}))["closed"], true);
|
||||
assert_eq!(ui(&mut s), Value::Null);
|
||||
assert!(event_err(&mut s, json!({"id": "org.test.a", "control": "go"})).contains("no panel open"));
|
||||
// Another plugin's panel replaces it; removing the plugin closes it.
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.a"}));
|
||||
ex(&mut s, "plugin.run", json!({"id": "org.test.b"}));
|
||||
assert_eq!(ui(&mut s)["plugin"], "org.test.b");
|
||||
assert_eq!(ex(&mut s, "plugin.ui", json!({"id": "org.test.a"})), Value::Null);
|
||||
ex(&mut s, "plugin.remove", json!({"id": "org.test.b"}));
|
||||
assert_eq!(ui(&mut s), Value::Null);
|
||||
}
|
||||
|
|
@ -1,21 +1,26 @@
|
|||
//! Plugins: WebAssembly modules that act on the document through the same commands as the menus,
|
||||
//! CLI and MCP server, inside the sandbox of `figcraft-plugin`. This module is the policy: which
|
||||
//! commands a plugin may call (its manifest's permissions, and never files, other documents, the
|
||||
//! undo history, dialogs or plugins), one undo step per run, and the installed-plugin list kept in
|
||||
//! the preferences. See `docs/plugins.md`.
|
||||
//! undo history, dialogs or plugins), one undo step per run or panel callback, the panel a plugin
|
||||
//! keeps open, its settings and the installed-plugin list kept in the preferences. See
|
||||
//! `docs/plugins.md`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_plugin::{Limits, PluginError};
|
||||
use figcraft_plugin::{Instance, Limits, PluginError, RunOutput, UiChange};
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::cmd::{CommandSpec, find_command};
|
||||
use crate::params::{get, num, string};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub use figcraft_plugin::{Access, MANIFEST_FILE, Manifest, Param, ParamKind};
|
||||
pub use figcraft_plugin::{Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind};
|
||||
|
||||
/// What one plugin may keep in its settings (keys and JSON values, in bytes), and its longest key.
|
||||
const MAX_SETTINGS_BYTES: usize = 64 * 1024;
|
||||
const MAX_SETTING_KEY: usize = 128;
|
||||
|
||||
/// An installed plugin (in the preferences).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -33,12 +38,41 @@ pub(crate) fn persisted<S: Serializer>(list: &[InstalledPlugin], s: S) -> std::r
|
|||
}
|
||||
|
||||
/// Per-session plugin state.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Runtime {
|
||||
/// Modules of plugins imported without a manifest file, by plugin id.
|
||||
modules: HashMap<String, Arc<Vec<u8>>>,
|
||||
/// The last run (plugin id, command id, params): "Run last plugin".
|
||||
pub last: Option<(String, String, Value)>,
|
||||
/// The plugin panel open now.
|
||||
// shortcut: one panel at a time (another plugin's panel replaces it); keep a list when
|
||||
// plugins need panels side by side.
|
||||
pub panel: Option<OpenPanel>,
|
||||
}
|
||||
|
||||
/// A plugin's open panel: the running module (its memory lasts from one callback to the next),
|
||||
/// the panel it showed and the values entered.
|
||||
#[derive(Debug)]
|
||||
pub struct OpenPanel {
|
||||
pub manifest: Manifest,
|
||||
/// The command that opened it, its parameters and its undo step's name.
|
||||
pub command: String,
|
||||
params: Value,
|
||||
label: String,
|
||||
pub panel: Panel,
|
||||
pub values: Map<String, Value>,
|
||||
instance: Instance,
|
||||
}
|
||||
|
||||
impl OpenPanel {
|
||||
/// The panel's title: its own, else the plugin's name.
|
||||
pub fn title(&self) -> &str {
|
||||
if self.panel.title.is_empty() { &self.manifest.name } else { &self.panel.title }
|
||||
}
|
||||
|
||||
fn describe(&self) -> Value {
|
||||
json!({"plugin": self.manifest.id, "command": self.command, "title": self.title(), "controls": self.panel.controls, "values": self.values})
|
||||
}
|
||||
}
|
||||
|
||||
/// Command prefixes no plugin may call, whatever its permissions: files and the app (plugins get
|
||||
|
|
@ -123,6 +157,38 @@ impl figcraft_plugin::Host for EngineHost<'_> {
|
|||
fn log(&mut self, line: &str) {
|
||||
log::info!("plugin {}: {line}", self.manifest.id);
|
||||
}
|
||||
|
||||
fn setting(&mut self, key: &str) -> Option<String> {
|
||||
Some(self.s.prefs.plugin_settings.get(&self.manifest.id)?.get(key)?.to_string())
|
||||
}
|
||||
|
||||
fn set_setting(&mut self, key: &str, value: Option<&str>) -> std::result::Result<(), String> {
|
||||
let (all, id) = (&mut self.s.prefs.plugin_settings, &self.manifest.id);
|
||||
let Some(text) = value else {
|
||||
if let Some(mine) = all.get_mut(id) {
|
||||
mine.remove(key);
|
||||
if mine.is_empty() {
|
||||
all.remove(id);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
if key.is_empty() || key.chars().count() > MAX_SETTING_KEY || key.chars().any(char::is_control) {
|
||||
return Err(format!("a setting's key must be 1–{MAX_SETTING_KEY} characters without control characters"));
|
||||
}
|
||||
let too_big = || format!("a plugin's settings are limited to {} KB", MAX_SETTINGS_BYTES / 1024);
|
||||
if key.len() + text.len() > MAX_SETTINGS_BYTES {
|
||||
return Err(too_big());
|
||||
}
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("a setting must be JSON: {e}"))?;
|
||||
let others: usize =
|
||||
all.get(id).map_or(0, |mine| mine.iter().filter(|(k, _)| k.as_str() != key).map(|(k, v)| k.len() + v.to_string().len()).sum());
|
||||
if others + key.len() + v.to_string().len() > MAX_SETTINGS_BYTES {
|
||||
return Err(too_big());
|
||||
}
|
||||
all.entry(id.clone()).or_default().insert(key.to_string(), v);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed plugin `id`.
|
||||
|
|
@ -253,7 +319,11 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required"))?;
|
||||
installed(s, cmd, &id)?;
|
||||
s.prefs.plugins.retain(|x| x.manifest.id != id);
|
||||
s.prefs.plugin_settings.remove(&id);
|
||||
s.plugins.modules.remove(&id);
|
||||
if s.plugins.panel.as_ref().is_some_and(|o| o.manifest.id == id) {
|
||||
s.plugins.panel = None;
|
||||
}
|
||||
if s.plugins.last.as_ref().is_some_and(|(last, ..)| *last == id) {
|
||||
s.plugins.last = None;
|
||||
}
|
||||
|
|
@ -274,15 +344,112 @@ pub fn run(s: &mut Session, id: &str, command: Option<&str>, params: &Value, fue
|
|||
if let Some(f) = fuel {
|
||||
limits.fuel = (f.max(1000.0) as u64).min(limits.fuel);
|
||||
}
|
||||
let input = json!({"api": figcraft_plugin::API_VERSION, "command": c.id, "params": params, "selection": s.selection()}).to_string();
|
||||
s.plugins.last = Some((m.id.clone(), c.id.clone(), params));
|
||||
let label = if m.commands.len() > 1 { format!("{}: {}", m.name, c.label) } else { m.name.clone() };
|
||||
let out = s.grouped(&label, |s| {
|
||||
// The API the plugin was written for: a v1 plugin gets the input it always got.
|
||||
let input = json!({"api": m.api, "command": c.id, "params": params, "selection": s.selection()}).to_string();
|
||||
s.plugins.last = Some((m.id.clone(), c.id.clone(), params.clone()));
|
||||
let label = if m.commands.len() > 1 { format!("{}: {}", m.name, c.label.trim_end_matches(['…', '.'])) } else { m.name.clone() };
|
||||
let mut instance = Instance::new(&wasm, &limits).map_err(|e| plugin_error(&m.name, e))?;
|
||||
let mut out = call_plugin(s, m, &label, &mut instance, &input).map_err(|(e, _)| e)?;
|
||||
match out.ui.take() {
|
||||
Some(UiChange::Show(panel)) => {
|
||||
let values = panel.values(&Map::new());
|
||||
s.plugins.panel = Some(OpenPanel { manifest: m.clone(), command: c.id.clone(), params, label, panel, values, instance });
|
||||
}
|
||||
Some(UiChange::Close) => close_panel(s, &m.id),
|
||||
None => {}
|
||||
}
|
||||
Ok(report(s, &m.id, &c.id, out))
|
||||
}
|
||||
|
||||
/// Call the plugin with `input` as one undo step (none when it changed nothing), rolled back when
|
||||
/// it fails. On failure, also whether the module is still fit for more calls: it returned an
|
||||
/// error itself rather than trapping or running out of fuel or memory.
|
||||
fn call_plugin(
|
||||
s: &mut Session,
|
||||
m: &Manifest,
|
||||
label: &str,
|
||||
instance: &mut Instance,
|
||||
input: &str,
|
||||
) -> std::result::Result<RunOutput, (EngineError, bool)> {
|
||||
let mut usable = false;
|
||||
s.grouped(label, |s| {
|
||||
let mut host = EngineHost { s, manifest: m };
|
||||
figcraft_plugin::run(&wasm, &input, &limits, &mut host).map_err(|e| plugin_error(&m.name, e))
|
||||
})?;
|
||||
instance.call(input, &mut host).map_err(|e| {
|
||||
usable = matches!(e, PluginError::Failed(_));
|
||||
plugin_error(&m.name, e)
|
||||
})
|
||||
})
|
||||
.map_err(|e| (e, usable))
|
||||
}
|
||||
|
||||
/// What `plugin.run` and `plugin.event` return.
|
||||
fn report(s: &Session, id: &str, command: &str, out: RunOutput) -> Value {
|
||||
let output = serde_json::from_str::<Value>(&out.output).unwrap_or(Value::String(out.output));
|
||||
Ok(json!({"plugin": m.id, "command": c.id, "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}))
|
||||
let panel = s.plugins.panel.as_ref().filter(|o| o.manifest.id == id).map(OpenPanel::describe);
|
||||
json!({"plugin": id, "command": command, "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel, "panel": panel})
|
||||
}
|
||||
|
||||
/// Close plugin `id`'s panel, if it has one open.
|
||||
fn close_panel(s: &mut Session, id: &str) {
|
||||
if s.plugins.panel.as_ref().is_some_and(|o| o.manifest.id == id) {
|
||||
s.plugins.panel = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = string(p, "id");
|
||||
let open = s.plugins.panel.as_ref().filter(|o| id.as_ref().is_none_or(|id| *id == o.manifest.id));
|
||||
Ok(open.map_or(Value::Null, OpenPanel::describe))
|
||||
}
|
||||
|
||||
/// A button press or a changed value in a plugin's panel: the plugin is called back with the
|
||||
/// event and every value, as one undo step.
|
||||
fn event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "plugin.event";
|
||||
let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required (the plugin whose panel is open)"))?;
|
||||
let control = string(p, "control").ok_or_else(|| bad(cmd, "`control` required (a control's name)"))?;
|
||||
let open = s.plugins.panel.as_ref().filter(|o| o.manifest.id == id).ok_or_else(|| bad(cmd, format!("`{id}` has no panel open")))?;
|
||||
let c = open
|
||||
.panel
|
||||
.control(&control)
|
||||
.filter(|c| c.kind != ParamKind::Note)
|
||||
.ok_or_else(|| bad(cmd, format!("the panel has no control `{control}`")))?;
|
||||
let field = c.is_field();
|
||||
let value = if field { c.coerce(get(p, "value").unwrap_or(&Value::Null)).map_err(|e| bad(cmd, e))? } else { Value::Null };
|
||||
let Some(mut open) = s.plugins.panel.take() else { return Err(bad(cmd, "no panel is open")) };
|
||||
if field {
|
||||
open.values.insert(control.clone(), value.clone());
|
||||
}
|
||||
let command = open.command.clone();
|
||||
let event = json!({"control": control, "value": value});
|
||||
let input = json!({"api": open.manifest.api, "command": open.command, "params": open.params, "selection": s.selection(), "event": event, "values": open.values});
|
||||
let mut out = match call_plugin(s, &open.manifest, &open.label, &mut open.instance, &input.to_string()) {
|
||||
Ok(out) => out,
|
||||
Err((e, usable)) => {
|
||||
// A plugin that returned an error keeps its panel; one that crashed loses it.
|
||||
if usable {
|
||||
s.plugins.panel = Some(open);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
match out.ui.take() {
|
||||
Some(UiChange::Show(panel)) => {
|
||||
open.values = panel.values(&open.values);
|
||||
open.panel = panel;
|
||||
s.plugins.panel = Some(open);
|
||||
}
|
||||
Some(UiChange::Close) => {}
|
||||
None => s.plugins.panel = Some(open),
|
||||
}
|
||||
Ok(report(s, &id, &command, out))
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = string(p, "id").ok_or_else(|| bad("plugin.close", "`id` required"))?;
|
||||
let closed = s.plugins.panel.as_ref().is_some_and(|o| o.manifest.id == id);
|
||||
close_panel(s, &id);
|
||||
Ok(json!({"closed": closed}))
|
||||
}
|
||||
|
||||
fn run_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -317,10 +484,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"plugin.run",
|
||||
"Run plugin",
|
||||
"{id, command? (default: the first), params? (the command's parameters), fuel? (lower instruction budget)} one undo step → {output, logs, calls, fuel}",
|
||||
"{id, command? (default: the first), params? (the command's parameters), fuel? (lower instruction budget)} one undo step → {output, logs, calls, fuel, panel}",
|
||||
false,
|
||||
run_cmd
|
||||
),
|
||||
spec!("plugin.ui", "Plugin panel", "{id?} → the open plugin panel {plugin, command, title, controls, values}, or null", false, ui),
|
||||
spec!(
|
||||
"plugin.event",
|
||||
"Use plugin panel control",
|
||||
"{id, control, value? (a field's new value)} presses a button or sets a field in the plugin's open panel; the plugin is called back as one undo step → like plugin.run",
|
||||
false,
|
||||
event
|
||||
),
|
||||
spec!("plugin.close", "Close plugin panel", "{id} → {closed}", false, close),
|
||||
spec!("plugins.runLast", "Run last plugin", "{}", false, run_last),
|
||||
spec!("plugins.manage", "Manage plugins…", "{} opens the plugins window; returns the list like `plugin.list`", false, manage),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ enum OnRefusal {
|
|||
}
|
||||
|
||||
/// `s` as a WAT string literal.
|
||||
fn lit(s: &str) -> String {
|
||||
pub(crate) fn lit(s: &str) -> String {
|
||||
s.bytes()
|
||||
.map(|b| if b.is_ascii_graphic() && b != b'"' && b != b'\\' || b == b' ' { (b as char).to_string() } else { format!("\\{b:02x}") })
|
||||
.collect()
|
||||
|
|
@ -62,30 +62,30 @@ fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8>
|
|||
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
pub(crate) fn b64(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
fn manifest(id: &str, access: &str, allow: &[&str]) -> Value {
|
||||
pub(crate) fn manifest(id: &str, access: &str, allow: &[&str]) -> Value {
|
||||
json!({"id": id, "name": format!("Plugin {id}"), "version": "1.0.0", "main": "plugin.wasm",
|
||||
"permissions": {"access": access, "commands": allow}})
|
||||
}
|
||||
|
||||
/// Install a plugin from a manifest and module bytes (no files).
|
||||
fn install(s: &mut Session, manifest: Value, wasm: &[u8]) {
|
||||
pub(crate) fn install(s: &mut Session, manifest: Value, wasm: &[u8]) {
|
||||
ex(s, "plugin.import", json!({"manifest": manifest, "wasm": b64(wasm)}));
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
pub(crate) fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
idv(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 10, "height": 10})))
|
||||
}
|
||||
|
||||
fn layer_count(s: &Session) -> usize {
|
||||
pub(crate) fn layer_count(s: &Session) -> usize {
|
||||
let ds = s.ds().unwrap();
|
||||
ds.doc.node(ds.page).unwrap().children.len()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
pub(crate) fn undo_len(s: &Session) -> usize {
|
||||
s.ds().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ description = "FigCraft plugins: the plugin manifest and the WebAssembly sandbox
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
figcraft-color = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
//! FigCraft plugins: the manifest (`figcraft-plugin.json`) and the WebAssembly sandbox a plugin
|
||||
//! runs in. The engine decides what a plugin may do (its permissions, which commands it may call,
|
||||
//! one undo step per run); this crate only runs modules safely: a metered interpreter with a memory
|
||||
//! cap, no WASI and a handful of host functions. See `docs/plugins.md`.
|
||||
//! one undo step per run or panel callback, where settings are kept); this crate only runs modules
|
||||
//! safely: a metered interpreter with a memory cap, no WASI and a handful of host functions. See
|
||||
//! `docs/plugins.md`.
|
||||
|
||||
pub mod manifest;
|
||||
mod sandbox;
|
||||
|
||||
pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Param, ParamKind, Permissions, PluginCommand};
|
||||
pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Limits, PluginError, RunOutput, check_module, run};
|
||||
pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand};
|
||||
pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod v2_tests;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! The plugin manifest, `figcraft-plugin.json`: who the plugin is, which module to run, the menu
|
||||
//! commands it adds (with their parameters) and the permissions it asks for.
|
||||
//! commands it adds (with their parameters) and the permissions it asks for. Also the panel a
|
||||
//! plugin shows while it runs (ABI v2), whose controls are parameters too.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
|
@ -7,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 = 1;
|
||||
pub const API_VERSION: u32 = 2;
|
||||
/// Largest manifest read.
|
||||
pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;
|
||||
|
||||
|
|
@ -19,6 +20,9 @@ const MAX_COMMANDS: usize = 64;
|
|||
const MAX_PARAMS: usize = 32;
|
||||
const MAX_OPTIONS: usize = 64;
|
||||
const MAX_ALLOW: usize = 256;
|
||||
/// Largest panel spec a plugin may show, and its most controls.
|
||||
pub const MAX_PANEL_BYTES: usize = 64 * 1024;
|
||||
const MAX_CONTROLS: usize = 64;
|
||||
/// Longest text parameter value.
|
||||
const MAX_TEXT_VALUE: usize = 10_000;
|
||||
|
||||
|
|
@ -35,7 +39,8 @@ pub struct Manifest {
|
|||
pub description: String,
|
||||
/// The WebAssembly module, relative to the manifest (`plugin.wasm`).
|
||||
pub main: String,
|
||||
/// The plugin API version the plugin was written for.
|
||||
/// The plugin API version the plugin was written for (1 when not given; panels and settings
|
||||
/// need 2).
|
||||
#[serde(default = "api_version")]
|
||||
pub api: u32,
|
||||
#[serde(default)]
|
||||
|
|
@ -45,8 +50,9 @@ pub struct Manifest {
|
|||
pub commands: Vec<PluginCommand>,
|
||||
}
|
||||
|
||||
/// A manifest without `api` was written for the first one.
|
||||
fn api_version() -> u32 {
|
||||
API_VERSION
|
||||
1
|
||||
}
|
||||
|
||||
/// What a plugin may do.
|
||||
|
|
@ -95,14 +101,17 @@ pub struct PluginCommand {
|
|||
/// A command parameter: a form field in the app, a key of `params` everywhere.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Param {
|
||||
/// Empty only for a panel's notes.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub label: String,
|
||||
#[serde(rename = "type", default)]
|
||||
pub kind: ParamKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// In a panel, the control's current value (`value` there).
|
||||
#[serde(default, alias = "value", skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<Value>,
|
||||
/// The choices of a `select`.
|
||||
/// The choices of a `select` or `list`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<String>,
|
||||
/// Bounds of a `number`.
|
||||
|
|
@ -121,6 +130,14 @@ pub enum ParamKind {
|
|||
Number,
|
||||
Boolean,
|
||||
Select,
|
||||
/// A hex colour, `#RRGGBB` or `#RRGGBBAA`.
|
||||
Color,
|
||||
/// One of `options`, shown as a list rather than a menu.
|
||||
List,
|
||||
/// Panels only: a button (it has no value).
|
||||
Button,
|
||||
/// Panels only: text (`label`, up to 1000 characters).
|
||||
Note,
|
||||
}
|
||||
|
||||
impl Param {
|
||||
|
|
@ -129,6 +146,11 @@ impl Param {
|
|||
if self.label.is_empty() { &self.name } else { &self.label }
|
||||
}
|
||||
|
||||
/// Whether it holds a value (not a button or a note).
|
||||
pub fn is_field(&self) -> bool {
|
||||
!matches!(self.kind, ParamKind::Button | ParamKind::Note)
|
||||
}
|
||||
|
||||
/// The value used when none is given.
|
||||
pub fn default_value(&self) -> Value {
|
||||
if let Some(d) = self.default.as_ref().and_then(|d| self.coerce(d).ok()) {
|
||||
|
|
@ -138,7 +160,9 @@ impl Param {
|
|||
ParamKind::Text => Value::String(String::new()),
|
||||
ParamKind::Number => serde_json::json!(self.clamp(0.0)),
|
||||
ParamKind::Boolean => Value::Bool(false),
|
||||
ParamKind::Select => self.options.first().map_or(Value::Null, |o| Value::String(o.clone())),
|
||||
ParamKind::Select | ParamKind::List => self.options.first().map_or(Value::Null, |o| Value::String(o.clone())),
|
||||
ParamKind::Color => Value::String("#000000".into()),
|
||||
ParamKind::Button | ParamKind::Note => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,13 +187,47 @@ impl Param {
|
|||
Ok(serde_json::json!(self.clamp(n)))
|
||||
}
|
||||
ParamKind::Boolean => v.as_bool().map(Value::Bool).ok_or_else(|| format!("`{name}` must be true or false")),
|
||||
ParamKind::Select => match v.as_str() {
|
||||
ParamKind::Select | ParamKind::List => match v.as_str() {
|
||||
Some(s) if self.options.iter().any(|o| o == s) => Ok(v.clone()),
|
||||
_ => Err(format!("`{name}` must be one of: {}", self.options.join(", "))),
|
||||
},
|
||||
ParamKind::Color => v
|
||||
.as_str()
|
||||
.filter(|s| s.len() <= 9)
|
||||
.and_then(figcraft_color::Color::from_hex)
|
||||
.map(|c| Value::String(c.to_css_hex()))
|
||||
.ok_or_else(|| format!("`{name}` must be a hex colour like #FF5A5F")),
|
||||
ParamKind::Button | ParamKind::Note => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a parameter or panel control: its name, label, options and bounds. A default that
|
||||
/// doesn't fit its kind is dropped.
|
||||
fn check(&mut self) -> Result<(), String> {
|
||||
if !(self.kind == ParamKind::Note && self.name.is_empty()) {
|
||||
check_id("parameter name", &self.name, MAX_NAME)?;
|
||||
}
|
||||
let max = if self.kind == ParamKind::Note { MAX_TEXT } else { MAX_LABEL };
|
||||
self.label = clean("parameter label", &self.label, max)?;
|
||||
if self.options.len() > MAX_OPTIONS {
|
||||
return Err(format!("parameter `{}` has more than {MAX_OPTIONS} options", self.name));
|
||||
}
|
||||
for o in &mut self.options {
|
||||
*o = clean("option", o, MAX_LABEL)?;
|
||||
}
|
||||
if matches!(self.kind, ParamKind::Select | ParamKind::List) && self.options.is_empty() {
|
||||
return Err(format!("parameter `{}` has no options", self.name));
|
||||
}
|
||||
if self.min.is_some_and(|v| !v.is_finite())
|
||||
|| self.max.is_some_and(|v| !v.is_finite())
|
||||
|| matches!((self.min, self.max), (Some(a), Some(b)) if a > b)
|
||||
{
|
||||
return Err(format!("parameter `{}` has bad bounds", self.name));
|
||||
}
|
||||
self.default = self.default.as_ref().and_then(|d| self.coerce(d).ok());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `n` within the number's bounds.
|
||||
fn clamp(&self, n: f64) -> f64 {
|
||||
n.max(self.min.unwrap_or(f64::MIN)).min(self.max.unwrap_or(f64::MAX))
|
||||
|
|
@ -221,34 +279,39 @@ impl Manifest {
|
|||
|
||||
/// Check every field and fill in the defaults (a `run` command when there is none).
|
||||
fn validated(mut self) -> Result<Manifest, String> {
|
||||
self.check().map_err(|e| format!("manifest: {e}"))?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
fn check(&mut self) -> Result<(), String> {
|
||||
check_id("id", &self.id, MAX_ID)?;
|
||||
self.name = clean("name", &self.name, MAX_NAME)?;
|
||||
if self.name.is_empty() {
|
||||
return Err("manifest: `name` is empty".into());
|
||||
return Err("`name` is empty".into());
|
||||
}
|
||||
self.version = clean("version", &self.version, 32)?;
|
||||
self.description = clean("description", &self.description, MAX_TEXT)?;
|
||||
check_main(&self.main)?;
|
||||
if self.api == 0 || self.api > API_VERSION {
|
||||
return Err(format!("manifest: plugin API {} isn't supported (this FigCraft runs API {API_VERSION})", self.api));
|
||||
return Err(format!("plugin API {} isn't supported (this FigCraft runs API {API_VERSION})", self.api));
|
||||
}
|
||||
if self.permissions.commands.len() > MAX_ALLOW {
|
||||
return Err(format!("manifest: more than {MAX_ALLOW} entries in `permissions.commands`"));
|
||||
return Err(format!("more than {MAX_ALLOW} entries in `permissions.commands`"));
|
||||
}
|
||||
for p in &self.permissions.commands {
|
||||
let bare = p.strip_suffix('*').unwrap_or(p);
|
||||
if p.is_empty() || p.len() > MAX_ID || bare.contains('*') || !bare.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_') {
|
||||
return Err(format!("manifest: `{}` in `permissions.commands` isn't a command id or `prefix.*`", truncate(p)));
|
||||
return Err(format!("`{}` in `permissions.commands` isn't a command id or `prefix.*`", truncate(p)));
|
||||
}
|
||||
}
|
||||
if self.commands.is_empty() {
|
||||
self.commands.push(PluginCommand { id: "run".into(), label: "Run".into(), params: Vec::new() });
|
||||
}
|
||||
if self.commands.len() > MAX_COMMANDS {
|
||||
return Err(format!("manifest: more than {MAX_COMMANDS} commands"));
|
||||
return Err(format!("more than {MAX_COMMANDS} commands"));
|
||||
}
|
||||
if let Some(dup) = first_duplicate(self.commands.iter().map(|c| c.id.as_str())) {
|
||||
return Err(format!("manifest: two commands are called `{}`", truncate(dup)));
|
||||
return Err(format!("two commands are called `{}`", truncate(dup)));
|
||||
}
|
||||
for c in &mut self.commands {
|
||||
check_id("command id", &c.id, MAX_NAME)?;
|
||||
|
|
@ -257,32 +320,68 @@ impl Manifest {
|
|||
c.label = c.id.clone();
|
||||
}
|
||||
if c.params.len() > MAX_PARAMS {
|
||||
return Err(format!("manifest: command `{}` has more than {MAX_PARAMS} parameters", c.id));
|
||||
return Err(format!("command `{}` has more than {MAX_PARAMS} parameters", c.id));
|
||||
}
|
||||
for p in &mut c.params {
|
||||
check_id("parameter name", &p.name, MAX_NAME)?;
|
||||
p.label = clean("parameter label", &p.label, MAX_LABEL)?;
|
||||
if p.options.len() > MAX_OPTIONS {
|
||||
return Err(format!("manifest: parameter `{}` has more than {MAX_OPTIONS} options", p.name));
|
||||
}
|
||||
for o in &mut p.options {
|
||||
*o = clean("option", o, MAX_LABEL)?;
|
||||
}
|
||||
if p.kind == ParamKind::Select && p.options.is_empty() {
|
||||
return Err(format!("manifest: select parameter `{}` has no options", p.name));
|
||||
}
|
||||
if p.min.is_some_and(|v| !v.is_finite())
|
||||
|| p.max.is_some_and(|v| !v.is_finite())
|
||||
|| matches!((p.min, p.max), (Some(a), Some(b)) if a > b)
|
||||
{
|
||||
return Err(format!("manifest: parameter `{}` has bad bounds", p.name));
|
||||
p.check()?;
|
||||
if !p.is_field() {
|
||||
return Err(format!("parameter `{}`: buttons and notes only go in panels", p.name));
|
||||
}
|
||||
}
|
||||
if let Some(dup) = first_duplicate(c.params.iter().map(|p| p.name.as_str())) {
|
||||
return Err(format!("manifest: command `{}` declares `{dup}` twice", c.id));
|
||||
return Err(format!("command `{}` declares `{dup}` twice", c.id));
|
||||
}
|
||||
}
|
||||
Ok(self)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel a plugin shows while it runs (ABI v2): a title and controls drawn with FigCraft's own
|
||||
/// widgets. Fields hold values; a button press or a changed value calls the plugin back.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Panel {
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub controls: Vec<Param>,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
/// Parse and validate a panel spec (it comes from the plugin: untrusted).
|
||||
pub fn parse(text: &str) -> Result<Panel, String> {
|
||||
if text.len() > MAX_PANEL_BYTES {
|
||||
return Err(format!("the panel is larger than {} KB", MAX_PANEL_BYTES / 1024));
|
||||
}
|
||||
let mut p: Panel = serde_json::from_str(text).map_err(|e| format!("panel: {e}"))?;
|
||||
p.title = clean("title", &p.title, MAX_NAME).map_err(|e| format!("panel: {e}"))?;
|
||||
if p.controls.len() > MAX_CONTROLS {
|
||||
return Err(format!("panel: more than {MAX_CONTROLS} controls"));
|
||||
}
|
||||
for c in &mut p.controls {
|
||||
c.check().map_err(|e| format!("panel: {e}"))?;
|
||||
}
|
||||
if let Some(dup) = first_duplicate(p.controls.iter().map(|c| c.name.as_str()).filter(|n| !n.is_empty())) {
|
||||
return Err(format!("panel: two controls are called `{dup}`"));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// The control called `name`.
|
||||
pub fn control(&self, name: &str) -> Option<&Param> {
|
||||
self.controls.iter().find(|c| !c.name.is_empty() && c.name == name)
|
||||
}
|
||||
|
||||
/// Its fields' values: a field's own value when the spec gives one, else its value in `old`
|
||||
/// (what the user entered before the plugin updated the panel), else its default.
|
||||
pub fn values(&self, old: &Map<String, Value>) -> Map<String, Value> {
|
||||
self.controls
|
||||
.iter()
|
||||
.filter(|c| c.is_field())
|
||||
.map(|c| {
|
||||
let v = c.default.clone().or_else(|| old.get(&c.name).and_then(|v| c.coerce(v).ok())).unwrap_or_else(|| c.default_value());
|
||||
(c.name.clone(), v)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,7 +394,7 @@ fn first_duplicate<'a>(names: impl Iterator<Item = &'a str>) -> Option<&'a str>
|
|||
/// An id: 1..=`max` ASCII letters, digits, `.`, `-` or `_`.
|
||||
fn check_id(what: &str, id: &str, max: usize) -> Result<(), String> {
|
||||
if id.is_empty() || id.len() > max || !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) {
|
||||
return Err(format!("manifest: {what} `{}` must be 1–{max} letters, digits, `.`, `-` or `_`", truncate(id)));
|
||||
return Err(format!("{what} `{}` must be 1–{max} letters, digits, `.`, `-` or `_`", truncate(id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -304,10 +403,10 @@ fn check_id(what: &str, id: &str, max: usize) -> Result<(), String> {
|
|||
fn clean(what: &str, s: &str, max: usize) -> Result<String, String> {
|
||||
let s = s.trim();
|
||||
if s.chars().count() > max {
|
||||
return Err(format!("manifest: {what} is longer than {max} characters"));
|
||||
return Err(format!("{what} is longer than {max} characters"));
|
||||
}
|
||||
if s.chars().any(char::is_control) {
|
||||
return Err(format!("manifest: {what} contains control characters"));
|
||||
return Err(format!("{what} contains control characters"));
|
||||
}
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
|
@ -322,7 +421,7 @@ fn check_main(main: &str) -> Result<(), String> {
|
|||
|| main.chars().any(char::is_control)
|
||||
|| main.split(['/', '\\']).any(|part| part == ".." || part.is_empty());
|
||||
if bad {
|
||||
return Err(format!("manifest: `main` must be a relative path to a .wasm file inside the plugin's folder (got `{}`)", truncate(main)));
|
||||
return Err(format!("`main` must be a relative path to a .wasm file inside the plugin's folder (got `{}`)", truncate(main)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! 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 1)
|
||||
//! # ABI (version 2; version 1 is the first seven imports)
|
||||
//!
|
||||
//! 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.
|
||||
|
|
@ -16,11 +16,25 @@
|
|||
//! | `log` | `(ptr, len)` | A line for the run's log. |
|
||||
//! | `notify` | `(ptr, len)` | A short message shown to the user. |
|
||||
//! | `set_output` | `(ptr, len)` | The run's result (JSON or text), or its error message when it fails. |
|
||||
//! | `ui_show` | `(ptr, len) -> i32` | Shows (or replaces) the plugin's panel: a [`Panel`] as JSON. 1: invalid, the reply says why. |
|
||||
//! | `ui_close` | `()` | Closes the panel. |
|
||||
//! | `setting_get` | `(key_ptr, key_len) -> i32` | 0: the reply holds the stored JSON; 1: no such setting. |
|
||||
//! | `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. |
|
||||
//!
|
||||
//! When `figcraft_run` starts, the reply holds the input JSON (`input_len` bytes):
|
||||
//! `{"command", "params", "selection"}`. It returns 0 on success; anything else fails the run.
|
||||
//! `{"api", "command", "params", "selection"}`, plus `event` and `values` when a panel calls the
|
||||
//! plugin back. It returns 0 on success; anything else fails the run.
|
||||
//!
|
||||
//! An [`Instance`] keeps the module's memory between calls, so a plugin with a panel open keeps its
|
||||
//! state from one callback to the next. Every call gets the full [`Limits`] again.
|
||||
|
||||
use wasmi::{Caller, Config, Engine, Extern, FuncType, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, TrapCode, ValType};
|
||||
use wasmi::{
|
||||
Caller, Config, Engine, Extern, FuncType, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, TrapCode, TypedFunc, TypedResumableCall, Val,
|
||||
ValType,
|
||||
};
|
||||
|
||||
use crate::manifest::Panel;
|
||||
|
||||
/// The module host functions are imported from.
|
||||
pub const IMPORT_MODULE: &str = "figcraft";
|
||||
|
|
@ -28,8 +42,20 @@ pub const IMPORT_MODULE: &str = "figcraft";
|
|||
pub const ENTRY: &str = "figcraft_run";
|
||||
|
||||
/// The host functions: (name, params, results), all i32.
|
||||
const IMPORTS: &[(&str, usize, usize)] =
|
||||
&[("call", 4, 1), ("selection", 0, 1), ("reply_len", 0, 1), ("reply_read", 2, 1), ("log", 2, 0), ("notify", 2, 0), ("set_output", 2, 0)];
|
||||
const IMPORTS: &[(&str, usize, usize)] = &[
|
||||
("call", 4, 1),
|
||||
("selection", 0, 1),
|
||||
("reply_len", 0, 1),
|
||||
("reply_read", 2, 1),
|
||||
("log", 2, 0),
|
||||
("notify", 2, 0),
|
||||
("set_output", 2, 0),
|
||||
("ui_show", 2, 1),
|
||||
("ui_close", 0, 0),
|
||||
("setting_get", 2, 1),
|
||||
("setting_set", 4, 1),
|
||||
("setting_delete", 2, 0),
|
||||
];
|
||||
|
||||
/// What a run may use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
|
@ -80,7 +106,8 @@ pub enum PluginError {
|
|||
Failed(String),
|
||||
}
|
||||
|
||||
/// What the sandbox asks of its host (the engine): run commands, read the selection, log.
|
||||
/// What the sandbox asks of its host (the engine): run commands, read the selection, log, keep
|
||||
/// settings.
|
||||
pub trait Host {
|
||||
/// Run `command` with `params` (JSON text): the result JSON, or why it was refused or failed.
|
||||
fn call(&mut self, command: &str, params: &str) -> Result<String, String>;
|
||||
|
|
@ -90,6 +117,22 @@ pub trait Host {
|
|||
fn notify(&mut self, message: &str);
|
||||
/// A line of the run's log, as the plugin writes it (it's also in [`RunOutput::logs`]).
|
||||
fn log(&mut self, _line: &str) {}
|
||||
/// The plugin's setting `key` (JSON text).
|
||||
fn setting(&mut self, _key: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// Store (`Some`) or forget (`None`) setting `key`, or say why not.
|
||||
fn set_setting(&mut self, _key: &str, _value: Option<&str>) -> Result<(), String> {
|
||||
Err("settings aren't available here".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// What a run asked of the panel.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum UiChange {
|
||||
/// Show this panel (or replace the one showing).
|
||||
Show(Panel),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// A finished run.
|
||||
|
|
@ -102,16 +145,88 @@ pub struct RunOutput {
|
|||
pub calls: u32,
|
||||
/// Fuel used.
|
||||
pub fuel: u64,
|
||||
/// The last `ui_show` / `ui_close` of the run.
|
||||
pub ui: Option<UiChange>,
|
||||
}
|
||||
|
||||
struct State<'h> {
|
||||
host: &'h mut dyn Host,
|
||||
/// A host call that needs the [`Host`]: the plugin is paused while the host answers it (see
|
||||
/// [`Instance::call`]), so the store never borrows the host and can outlive one call.
|
||||
enum Request {
|
||||
Call(String, String),
|
||||
Selection,
|
||||
Log(String),
|
||||
Notify(String),
|
||||
SettingGet(String),
|
||||
SettingSet(String, String),
|
||||
SettingDelete(String),
|
||||
}
|
||||
|
||||
struct State {
|
||||
limits: Limits,
|
||||
store_limits: StoreLimits,
|
||||
reply: Vec<u8>,
|
||||
out: RunOutput,
|
||||
/// A limit the host tripped (reported instead of the trap it causes).
|
||||
abort: Option<PluginError>,
|
||||
request: Option<Request>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Make `bytes` the reply; returns its length.
|
||||
fn set_reply(&mut self, bytes: Vec<u8>) -> i32 {
|
||||
let max = self.limits.max_message;
|
||||
let bytes = if bytes.len() > max { format!("the result is larger than {max} bytes").into_bytes() } else { bytes };
|
||||
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
|
||||
self.reply = bytes;
|
||||
len
|
||||
}
|
||||
|
||||
/// Answer a paused host call; the host function's result, if it has one.
|
||||
fn answer(&mut self, host: &mut dyn Host, request: Request) -> Option<i32> {
|
||||
match request {
|
||||
Request::Call(cmd, params) => {
|
||||
let (status, reply) = match host.call(&cmd, ¶ms) {
|
||||
Ok(v) => (0, v),
|
||||
Err(m) => (1, m),
|
||||
};
|
||||
self.set_reply(reply.into_bytes());
|
||||
Some(status)
|
||||
}
|
||||
Request::Selection => Some(self.set_reply(host.selection().into_bytes())),
|
||||
Request::Log(line) => {
|
||||
host.log(&line);
|
||||
if self.out.logs.len() < self.limits.max_logs {
|
||||
self.out.logs.push(line);
|
||||
}
|
||||
None
|
||||
}
|
||||
Request::Notify(msg) => {
|
||||
host.notify(&msg);
|
||||
None
|
||||
}
|
||||
Request::SettingGet(key) => Some(match host.setting(&key) {
|
||||
Some(v) => {
|
||||
self.set_reply(v.into_bytes());
|
||||
0
|
||||
}
|
||||
None => {
|
||||
self.set_reply(Vec::new());
|
||||
1
|
||||
}
|
||||
}),
|
||||
Request::SettingSet(key, value) => Some(match host.set_setting(&key, Some(&value)) {
|
||||
Ok(()) => 0,
|
||||
Err(m) => {
|
||||
self.set_reply(m.into_bytes());
|
||||
1
|
||||
}
|
||||
}),
|
||||
Request::SettingDelete(key) => {
|
||||
let _ = host.set_setting(&key, None);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn engine() -> Engine {
|
||||
|
|
@ -164,36 +279,80 @@ fn compile(engine: &Engine, bytes: &[u8], limits: &Limits) -> Result<Module, Plu
|
|||
Ok(module)
|
||||
}
|
||||
|
||||
/// Run a plugin module: `input` is the JSON handed to `figcraft_run`.
|
||||
/// Run a plugin module once: `input` is the JSON handed to `figcraft_run`.
|
||||
pub fn run(bytes: &[u8], input: &str, limits: &Limits, host: &mut dyn Host) -> Result<RunOutput, PluginError> {
|
||||
let engine = engine();
|
||||
let module = compile(&engine, bytes, limits)?;
|
||||
if input.len() > limits.max_message {
|
||||
return Err(PluginError::Limit("the input is too large".into()));
|
||||
Instance::new(bytes, limits)?.call(input, host)
|
||||
}
|
||||
|
||||
/// A started plugin module whose memory lasts from one call to the next.
|
||||
pub struct Instance {
|
||||
store: Store<State>,
|
||||
entry: TypedFunc<i32, i32>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Instance {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Instance").finish_non_exhaustive()
|
||||
}
|
||||
let store_limits = StoreLimitsBuilder::new().memory_size(limits.memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
|
||||
let state = State { host, limits: *limits, store_limits, reply: input.as_bytes().to_vec(), out: RunOutput::default(), abort: None };
|
||||
let mut store = Store::new(&engine, state);
|
||||
store.limiter(|s| &mut s.store_limits);
|
||||
store.set_fuel(limits.fuel).map_err(|e| PluginError::Trap(e.to_string()))?;
|
||||
let linker = linker(&engine)?;
|
||||
let instance = linker.instantiate_and_start(&mut store, &module).map_err(|e| classify(&mut store, &e, true))?;
|
||||
let entry = instance.get_typed_func::<i32, i32>(&store, ENTRY).map_err(|e| PluginError::InvalidModule(e.to_string()))?;
|
||||
let input_len = i32::try_from(input.len()).map_err(|_| PluginError::Limit("the input is too large".into()))?;
|
||||
let status = entry.call(&mut store, input_len).map_err(|e| classify(&mut store, &e, false))?;
|
||||
let fuel = limits.fuel.saturating_sub(store.get_fuel().unwrap_or(0));
|
||||
let mut out = std::mem::take(&mut store.data_mut().out);
|
||||
out.fuel = fuel;
|
||||
if status != 0 {
|
||||
let msg = if out.output.trim().is_empty() { format!("the plugin failed (status {status})") } else { out.output };
|
||||
return Err(PluginError::Failed(msg));
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
/// Compile, check and start a module. A start function can't call host functions.
|
||||
pub fn new(bytes: &[u8], limits: &Limits) -> Result<Self, PluginError> {
|
||||
let engine = engine();
|
||||
let module = compile(&engine, bytes, limits)?;
|
||||
let store_limits =
|
||||
StoreLimitsBuilder::new().memory_size(limits.memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
|
||||
let state = State { limits: *limits, store_limits, reply: Vec::new(), out: RunOutput::default(), abort: None, request: None };
|
||||
let mut store = Store::new(&engine, state);
|
||||
store.limiter(|s| &mut s.store_limits);
|
||||
store.set_fuel(limits.fuel).map_err(|e| PluginError::Trap(e.to_string()))?;
|
||||
let linker = linker(&engine)?;
|
||||
let instance = linker.instantiate_and_start(&mut store, &module).map_err(|e| classify(&mut store, &e, true))?;
|
||||
let entry = instance.get_typed_func::<i32, i32>(&store, ENTRY).map_err(|e| PluginError::InvalidModule(e.to_string()))?;
|
||||
Ok(Self { store, entry })
|
||||
}
|
||||
|
||||
/// Call `figcraft_run` with `input`, with the full limits. Host calls that need `host` pause
|
||||
/// the plugin, are answered here and resume it.
|
||||
pub fn call(&mut self, input: &str, host: &mut dyn Host) -> Result<RunOutput, PluginError> {
|
||||
let limits = self.store.data().limits;
|
||||
let input_len = i32::try_from(input.len()).ok().filter(|_| input.len() <= limits.max_message);
|
||||
let input_len = input_len.ok_or_else(|| PluginError::Limit("the input is too large".into()))?;
|
||||
let st = self.store.data_mut();
|
||||
st.reply = input.as_bytes().to_vec();
|
||||
st.out = RunOutput::default();
|
||||
st.abort = None;
|
||||
st.request = None;
|
||||
self.store.set_fuel(limits.fuel).map_err(|e| PluginError::Trap(e.to_string()))?;
|
||||
let mut call = self.entry.call_resumable(&mut self.store, input_len).map_err(|e| classify(&mut self.store, &e, false))?;
|
||||
let status = loop {
|
||||
match call {
|
||||
TypedResumableCall::Finished(status) => break status,
|
||||
TypedResumableCall::OutOfFuel(_) => return Err(PluginError::OutOfFuel),
|
||||
TypedResumableCall::HostTrap(paused) => {
|
||||
let Some(request) = self.store.data_mut().request.take() else {
|
||||
return Err(classify(&mut self.store, paused.host_error(), false));
|
||||
};
|
||||
let results: Vec<Val> = self.store.data_mut().answer(host, request).map(Val::I32).into_iter().collect();
|
||||
call = paused.resume(&mut self.store, &results).map_err(|e| classify(&mut self.store, &e, false))?;
|
||||
}
|
||||
}
|
||||
};
|
||||
let fuel = limits.fuel.saturating_sub(self.store.get_fuel().unwrap_or(0));
|
||||
let mut out = std::mem::take(&mut self.store.data_mut().out);
|
||||
out.fuel = fuel;
|
||||
if status != 0 {
|
||||
let msg = if out.output.trim().is_empty() { format!("the plugin failed (status {status})") } else { out.output };
|
||||
return Err(PluginError::Failed(msg));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The error a failed instantiation or call stands for: a limit the host tripped first, else the
|
||||
/// trap.
|
||||
fn classify(store: &mut Store<State<'_>>, e: &wasmi::Error, instantiating: bool) -> PluginError {
|
||||
fn classify(store: &mut Store<State>, e: &wasmi::Error, instantiating: bool) -> PluginError {
|
||||
if let Some(abort) = store.data_mut().abort.take() {
|
||||
return abort;
|
||||
}
|
||||
|
|
@ -208,17 +367,25 @@ fn classify(store: &mut Store<State<'_>>, e: &wasmi::Error, instantiating: bool)
|
|||
}
|
||||
}
|
||||
|
||||
type Ctx<'a, 'h> = Caller<'a, State<'h>>;
|
||||
type Ctx<'a> = Caller<'a, State>;
|
||||
|
||||
/// Stop the run because of `err` (the trap it causes reports `err`).
|
||||
fn abort(caller: &mut Ctx<'_, '_>, err: PluginError) -> wasmi::Error {
|
||||
fn abort(caller: &mut Ctx<'_>, err: PluginError) -> wasmi::Error {
|
||||
let msg = err.to_string();
|
||||
caller.data_mut().abort = Some(err);
|
||||
wasmi::Error::new(msg)
|
||||
}
|
||||
|
||||
/// Pause the plugin until the host answers `request`.
|
||||
// shortcut: a host function tail-called (`return_call`) from `figcraft_run` itself can't be
|
||||
// resumed and fails the run; fine for compiled plugins, which don't do that.
|
||||
fn ask(caller: &mut Ctx<'_>, request: Request) -> wasmi::Error {
|
||||
caller.data_mut().request = Some(request);
|
||||
wasmi::Error::new("a host function was called outside figcraft_run")
|
||||
}
|
||||
|
||||
/// Charge a host call: fuel and the call count.
|
||||
fn charge(caller: &mut Ctx<'_, '_>) -> Result<(), wasmi::Error> {
|
||||
fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
|
||||
let cost = caller.data().limits.call_fuel;
|
||||
let left = caller.get_fuel()?;
|
||||
if left < cost {
|
||||
|
|
@ -235,7 +402,7 @@ fn charge(caller: &mut Ctx<'_, '_>) -> Result<(), wasmi::Error> {
|
|||
}
|
||||
|
||||
/// The bytes at `(ptr, len)` in the plugin's memory.
|
||||
fn read_bytes(caller: &mut Ctx<'_, '_>, ptr: i32, len: i32) -> Result<Vec<u8>, wasmi::Error> {
|
||||
fn read_bytes(caller: &mut Ctx<'_>, ptr: i32, len: i32) -> Result<Vec<u8>, wasmi::Error> {
|
||||
let (ptr, len) = (ptr as u32 as usize, len as u32 as usize);
|
||||
if len > caller.data().limits.max_message {
|
||||
return Err(abort(caller, PluginError::Limit(format!("a {len}-byte string is too long"))));
|
||||
|
|
@ -250,44 +417,29 @@ fn read_bytes(caller: &mut Ctx<'_, '_>, ptr: i32, len: i32) -> Result<Vec<u8>, w
|
|||
}
|
||||
|
||||
/// The UTF-8 text at `(ptr, len)`.
|
||||
fn read_str(caller: &mut Ctx<'_, '_>, ptr: i32, len: i32) -> Result<String, wasmi::Error> {
|
||||
fn read_str(caller: &mut Ctx<'_>, ptr: i32, len: i32) -> Result<String, wasmi::Error> {
|
||||
let bytes = read_bytes(caller, ptr, len)?;
|
||||
String::from_utf8(bytes).map_err(|_| abort(caller, PluginError::Trap("a string isn't valid UTF-8".into())))
|
||||
}
|
||||
|
||||
/// Make `bytes` the reply; returns its length.
|
||||
fn set_reply(caller: &mut Ctx<'_, '_>, bytes: Vec<u8>) -> Result<i32, wasmi::Error> {
|
||||
let max = caller.data().limits.max_message;
|
||||
let bytes = if bytes.len() > max { format!("the result is larger than {max} bytes").into_bytes() } else { bytes };
|
||||
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
|
||||
caller.data_mut().reply = bytes;
|
||||
Ok(len)
|
||||
}
|
||||
|
||||
fn linker<'h>(engine: &Engine) -> Result<Linker<State<'h>>, PluginError> {
|
||||
let mut l = <Linker<State<'h>>>::new(engine);
|
||||
fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
||||
let mut l = <Linker<State>>::new(engine);
|
||||
let err = |e: wasmi::errors::LinkerError| PluginError::InvalidModule(e.to_string());
|
||||
l.func_wrap(IMPORT_MODULE, "call", |mut c: Ctx<'_, 'h>, cp: i32, cl: i32, pp: i32, pl: i32| -> Result<i32, wasmi::Error> {
|
||||
l.func_wrap(IMPORT_MODULE, "call", |mut c: Ctx<'_>, cp: i32, cl: i32, pp: i32, pl: i32| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let cmd = read_str(&mut c, cp, cl)?;
|
||||
let params = read_str(&mut c, pp, pl)?;
|
||||
let params = if params.trim().is_empty() { "{}".to_string() } else { params };
|
||||
let (status, reply) = match c.data_mut().host.call(&cmd, ¶ms) {
|
||||
Ok(v) => (0, v),
|
||||
Err(m) => (1, m),
|
||||
};
|
||||
set_reply(&mut c, reply.into_bytes())?;
|
||||
Ok(status)
|
||||
Err(ask(&mut c, Request::Call(cmd, params)))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "selection", |mut c: Ctx<'_, 'h>| -> Result<i32, wasmi::Error> {
|
||||
l.func_wrap(IMPORT_MODULE, "selection", |mut c: Ctx<'_>| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let sel = c.data_mut().host.selection();
|
||||
set_reply(&mut c, sel.into_bytes())
|
||||
Err(ask(&mut c, Request::Selection))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "reply_len", |c: Ctx<'_, 'h>| -> i32 { i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX) }).map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "reply_read", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<i32, wasmi::Error> {
|
||||
l.func_wrap(IMPORT_MODULE, "reply_len", |c: Ctx<'_>| -> i32 { i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX) }).map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "reply_read", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<i32, wasmi::Error> {
|
||||
let (ptr, want) = (ptr as u32 as usize, len as u32 as usize);
|
||||
let Some(mem) = c.get_export("memory").and_then(Extern::into_memory) else {
|
||||
return Err(abort(&mut c, PluginError::InvalidModule("no memory".into())));
|
||||
|
|
@ -300,30 +452,65 @@ fn linker<'h>(engine: &Engine) -> Result<Linker<State<'h>>, PluginError> {
|
|||
Ok(i32::try_from(n).unwrap_or(i32::MAX))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "log", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
l.func_wrap(IMPORT_MODULE, "log", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let line = read_str(&mut c, ptr, len)?;
|
||||
let st = c.data_mut();
|
||||
st.host.log(&line);
|
||||
if st.out.logs.len() < st.limits.max_logs {
|
||||
st.out.logs.push(line);
|
||||
}
|
||||
Ok(())
|
||||
Err(ask(&mut c, Request::Log(line)))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "notify", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
l.func_wrap(IMPORT_MODULE, "notify", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let msg = read_str(&mut c, ptr, len)?;
|
||||
let msg: String = msg.chars().filter(|ch| !ch.is_control()).take(200).collect();
|
||||
c.data_mut().host.notify(&msg);
|
||||
Ok(())
|
||||
Err(ask(&mut c, Request::Notify(msg)))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
let out = read_str(&mut c, ptr, len)?;
|
||||
c.data_mut().out.output = out;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "ui_show", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let spec = read_str(&mut c, ptr, len)?;
|
||||
let st = c.data_mut();
|
||||
Ok(match Panel::parse(&spec) {
|
||||
Ok(panel) => {
|
||||
st.out.ui = Some(UiChange::Show(panel));
|
||||
0
|
||||
}
|
||||
Err(why) => {
|
||||
st.set_reply(why.into_bytes());
|
||||
1
|
||||
}
|
||||
})
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "ui_close", |mut c: Ctx<'_>| -> Result<(), wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
c.data_mut().out.ui = Some(UiChange::Close);
|
||||
Ok(())
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "setting_get", |mut c: Ctx<'_>, kp: i32, kl: i32| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let key = read_str(&mut c, kp, kl)?;
|
||||
Err(ask(&mut c, Request::SettingGet(key)))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "setting_set", |mut c: Ctx<'_>, kp: i32, kl: i32, vp: i32, vl: i32| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let key = read_str(&mut c, kp, kl)?;
|
||||
let value = read_str(&mut c, vp, vl)?;
|
||||
Err(ask(&mut c, Request::SettingSet(key, value)))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "setting_delete", |mut c: Ctx<'_>, kp: i32, kl: i32| -> Result<(), wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let key = read_str(&mut c, kp, kl)?;
|
||||
Err(ask(&mut c, Request::SettingDelete(key)))
|
||||
})
|
||||
.map_err(err)?;
|
||||
Ok(l)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ fn bad_manifests_are_refused() {
|
|||
json!({"main": "C:\\plugin.wasm"}),
|
||||
json!({"main": "plugin.js"}),
|
||||
json!({"main": "a//b.wasm"}),
|
||||
json!({"api": 2}),
|
||||
json!({"api": 3}),
|
||||
json!({"api": 0}),
|
||||
json!({"permissions": {"access": "root"}}),
|
||||
json!({"permissions": {"commands": ["*.x"]}}),
|
||||
|
|
@ -277,7 +277,7 @@ fn bad_manifests_are_refused() {
|
|||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "s", "type": "select"}]}]}),
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "number", "min": 5, "max": 1}]}]}),
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n"}, {"name": "n"}]}]}),
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "color"}]}]}),
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "date"}]}]}),
|
||||
json!({"commands": "run"}),
|
||||
];
|
||||
for extra in bad {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
//! ABI v2 in the sandbox: an instance keeps its memory between calls, panels are validated and
|
||||
//! reported, settings go through the host.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Map, json};
|
||||
|
||||
use crate::{Host, Instance, Limits, Manifest, Panel, ParamKind, PluginError, UiChange};
|
||||
|
||||
/// A host that keeps settings.
|
||||
#[derive(Default)]
|
||||
struct Settings(BTreeMap<String, String>);
|
||||
|
||||
impl Host for Settings {
|
||||
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 setting(&mut self, key: &str) -> Option<String> {
|
||||
self.0.get(key).cloned()
|
||||
}
|
||||
fn set_setting(&mut self, key: &str, value: Option<&str>) -> Result<(), String> {
|
||||
match value {
|
||||
Some(v) if v.len() > 10 => Err("too big".into()),
|
||||
Some(v) => {
|
||||
self.0.insert(key.into(), v.into());
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
self.0.remove(key);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn v2_module(data: &str, body: &str) -> Vec<u8> {
|
||||
let wat = format!(
|
||||
r#"(module
|
||||
(import "figcraft" "reply_len" (func $reply_len (result i32)))
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
(import "figcraft" "ui_show" (func $ui_show (param i32 i32) (result i32)))
|
||||
(import "figcraft" "ui_close" (func $ui_close))
|
||||
(import "figcraft" "setting_get" (func $setting_get (param i32 i32) (result i32)))
|
||||
(import "figcraft" "setting_set" (func $setting_set (param i32 i32 i32 i32) (result i32)))
|
||||
(import "figcraft" "setting_delete" (func $setting_delete (param i32 i32)))
|
||||
(memory (export "memory") 1)
|
||||
{data}
|
||||
(func (export "figcraft_run") (param $n i32) (result i32) {body}))"#
|
||||
);
|
||||
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
|
||||
}
|
||||
|
||||
const PANEL: &str = r#"{"title":"T","controls":[{"type":"button","name":"go","label":"Go"}]}"#;
|
||||
|
||||
#[test]
|
||||
fn an_instance_keeps_its_memory_and_reports_panel_changes() {
|
||||
// A counter at 100 ('0' + calls): call 1 shows a panel, call 2 an invalid one (the refusal is
|
||||
// its output), call 3 closes it.
|
||||
let bad = r#"{"controls":[{"type":"rocket"}]}"#;
|
||||
let m = v2_module(
|
||||
&format!(
|
||||
r#"(data (i32.const 100) "0") (data (i32.const 200) "{}") (data (i32.const 1000) "{}")"#,
|
||||
PANEL.replace('"', "\\\""),
|
||||
bad.replace('"', "\\\"")
|
||||
),
|
||||
&format!(
|
||||
r#"(i32.store8 (i32.const 100) (i32.add (i32.load8_u (i32.const 100)) (i32.const 1)))
|
||||
(if (i32.eq (i32.load8_u (i32.const 100)) (i32.const 49)) (then (drop (call $ui_show (i32.const 200) (i32.const {})))))
|
||||
(if (i32.eq (i32.load8_u (i32.const 100)) (i32.const 50))
|
||||
(then (if (call $ui_show (i32.const 1000) (i32.const {}))
|
||||
(then (call $set_output (i32.const 2000) (call $reply_read (i32.const 2000) (call $reply_len))) (return (i32.const 0))))))
|
||||
(if (i32.eq (i32.load8_u (i32.const 100)) (i32.const 51)) (then (call $ui_close)))
|
||||
(call $set_output (i32.const 100) (i32.const 1))
|
||||
(i32.const 0)"#,
|
||||
PANEL.len(),
|
||||
bad.len()
|
||||
),
|
||||
);
|
||||
let mut inst = Instance::new(&m, &Limits::default()).unwrap();
|
||||
let mut host = Settings::default();
|
||||
let first = inst.call("{}", &mut host).unwrap();
|
||||
assert_eq!((first.output.as_str(), first.ui), ("1", Some(UiChange::Show(Panel::parse(PANEL).unwrap()))));
|
||||
let second = inst.call("{}", &mut host).unwrap();
|
||||
assert!(second.output.contains("panel:") && second.ui.is_none(), "{second:?}");
|
||||
let third = inst.call("{}", &mut host).unwrap();
|
||||
assert_eq!((third.output.as_str(), third.ui), ("3", Some(UiChange::Close)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_go_through_the_host() {
|
||||
let m = v2_module(
|
||||
r#"(data (i32.const 0) "k") (data (i32.const 10) "[1,2]") (data (i32.const 20) "\"far too long\"")"#,
|
||||
r#"(if (call $setting_set (i32.const 0) (i32.const 1) (i32.const 10) (i32.const 5)) (then (return (i32.const 2))))
|
||||
;; Refused by the host: status 1.
|
||||
(if (i32.eqz (call $setting_set (i32.const 0) (i32.const 1) (i32.const 20) (i32.const 14))) (then (return (i32.const 3))))
|
||||
(if (call $setting_get (i32.const 0) (i32.const 1)) (then (return (i32.const 4))))
|
||||
(call $set_output (i32.const 100) (call $reply_read (i32.const 100) (call $reply_len)))
|
||||
(call $setting_delete (i32.const 0) (i32.const 1))
|
||||
(if (i32.eqz (call $setting_get (i32.const 0) (i32.const 1))) (then (return (i32.const 5))))
|
||||
(i32.const 0)"#,
|
||||
);
|
||||
let mut host = Settings::default();
|
||||
let out = crate::run(&m, "{}", &Limits::default(), &mut host).unwrap();
|
||||
assert_eq!(out.output, "[1,2]");
|
||||
assert!(host.0.is_empty());
|
||||
// Settings cost host calls like any other.
|
||||
let limits = Limits { max_calls: 2, ..Limits::default() };
|
||||
assert!(matches!(crate::run(&m, "{}", &limits, &mut host), Err(PluginError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panels_are_validated_and_keep_entered_values() {
|
||||
let p = Panel::parse(
|
||||
r##"{"title": "Swatches", "controls": [
|
||||
{"type": "list", "name": "palette", "label": "Palette", "options": ["Warm", "Cool"], "value": "Cool"},
|
||||
{"type": "note", "label": "Pick a palette"},
|
||||
{"type": "color", "name": "accent", "value": "#f0a"},
|
||||
{"type": "number", "name": "n", "min": 1, "max": 4, "value": 9},
|
||||
{"type": "text", "name": "t"},
|
||||
{"type": "button", "name": "go", "label": "Go"}
|
||||
]}"##,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.control("accent").unwrap().default, Some(json!("#FF00AA")));
|
||||
assert_eq!(p.control("n").unwrap().default, Some(json!(4.0)), "values are clamped");
|
||||
assert_eq!(p.control("go").unwrap().kind, ParamKind::Button);
|
||||
let mut old = Map::new();
|
||||
old.insert("t".into(), json!("typed"));
|
||||
old.insert("palette".into(), json!("Warm"));
|
||||
assert_eq!(
|
||||
json!(p.values(&old)),
|
||||
json!({"palette": "Cool", "accent": "#FF00AA", "n": 4.0, "t": "typed"}),
|
||||
"the spec's values win, then the entered ones"
|
||||
);
|
||||
let deep = format!("{{\"controls\": {}{}}}", "[".repeat(5000), "]".repeat(5000));
|
||||
let many = json!({"controls": (0..65).map(|i| json!({"type": "button", "name": format!("b{i}")})).collect::<Vec<_>>()}).to_string();
|
||||
let junk = [
|
||||
"",
|
||||
"null",
|
||||
r#"{"controls": 5}"#,
|
||||
r#"{"controls": [{"type": "rocket", "name": "x"}]}"#,
|
||||
r#"{"controls": [{"type": "button"}]}"#,
|
||||
r#"{"controls": [{"type": "list", "name": "x"}]}"#,
|
||||
r#"{"controls": [{"type": "button", "name": "a"}, {"type": "text", "name": "a"}]}"#,
|
||||
r#"{"controls": [{"type": "number", "name": "a", "min": 3, "max": 1}]}"#,
|
||||
r#"{"title": "line\nbreak"}"#,
|
||||
&deep,
|
||||
&many,
|
||||
];
|
||||
for j in junk {
|
||||
assert!(Panel::parse(j).is_err(), "accepted {}", j.chars().take(60).collect::<String>());
|
||||
}
|
||||
let huge = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(70_000));
|
||||
assert!(Panel::parse(&huge).unwrap_err().contains("larger"));
|
||||
let long_note = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(1001));
|
||||
assert!(Panel::parse(&long_note).is_err());
|
||||
// Junk values are dropped, not kept.
|
||||
let p = Panel::parse(
|
||||
r#"{"controls": [{"type": "color", "name": "c", "value": {"deep": [1, 2]}}, {"type": "boolean", "name": "b", "value": "yes"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(json!(p.values(&Map::new())), json!({"c": "#000000", "b": false}));
|
||||
// Buttons and notes don't go in manifests; colours and lists do.
|
||||
let manifest = |kind: &str| {
|
||||
let params = json!([{"name": "x", "type": kind, "options": ["a"]}]);
|
||||
Manifest::from_value(&json!({"id": "a.b", "name": "A", "main": "a.wasm", "commands": [{"id": "c", "label": "C", "params": params}]}))
|
||||
};
|
||||
assert!(manifest("button").is_err() && manifest("note").is_err());
|
||||
assert!(manifest("color").is_ok() && manifest("list").is_ok());
|
||||
}
|
||||
|
|
@ -28,3 +28,6 @@ egui_extras = { workspace = true }
|
|||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
toml = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -13,12 +13,17 @@ use crate::theme::{ACCENT, Tokens, regular, semibold};
|
|||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// A dialog window, centred.
|
||||
pub(crate) fn window<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> {
|
||||
floating(title, ctx).anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
||||
}
|
||||
|
||||
/// A window styled like the dialogs that the user can move.
|
||||
pub(crate) fn floating<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> {
|
||||
let t = Tokens::get(ctx);
|
||||
egui::Window::new(title)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@ use crate::{ControlRequest, FigApp, NoServices};
|
|||
/// here edits text).
|
||||
const IDLE: Duration = Duration::from_secs(1);
|
||||
|
||||
struct Harness {
|
||||
pub(crate) struct Harness {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new(s: Session) -> Self {
|
||||
pub(crate) fn new(s: Session) -> Self {
|
||||
Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 }
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ impl Harness {
|
|||
}
|
||||
|
||||
/// Settles, then stays idle: more frames with no input ask for nothing sooner.
|
||||
fn assert_idle(&mut self, what: &str) {
|
||||
pub(crate) fn assert_idle(&mut self, what: &str) {
|
||||
self.settle();
|
||||
for _ in 0..10 {
|
||||
let delay = self.frame(vec![]);
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ mod panel_reorder_tests;
|
|||
#[cfg(test)]
|
||||
mod perf_tests;
|
||||
#[cfg(test)]
|
||||
mod plugin_panel_tests;
|
||||
#[cfg(test)]
|
||||
mod popup_width_tests;
|
||||
#[cfg(test)]
|
||||
mod present_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
//! A plugin's panel in the UI, driven headless: its controls are drawn with names a screen reader
|
||||
//! reads, pressing its button calls the plugin back, closing it ends the plugin's session, and an
|
||||
//! open panel draws no frames while nothing changes.
|
||||
|
||||
use base64::Engine as _;
|
||||
use egui::Pos2;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::idle_tests::Harness;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
/// A session with a plugin whose first call shows a panel with every kind of control and whose
|
||||
/// later calls (the panel's events) make a rectangle.
|
||||
fn session() -> Session {
|
||||
let spec = json!({"title": "Test panel", "controls": [
|
||||
{"type": "note", "label": "Hello from the plugin"},
|
||||
{"type": "number", "name": "size", "label": "Size", "value": 10},
|
||||
{"type": "boolean", "name": "labels", "label": "Labels"},
|
||||
{"type": "list", "name": "palette", "label": "Palette", "options": ["Warm", "Cool"]},
|
||||
{"type": "color", "name": "accent", "label": "Accent", "value": "#FF5A5F"},
|
||||
{"type": "select", "name": "shape", "label": "Shape", "options": ["Square", "Circle"]},
|
||||
{"type": "text", "name": "title", "label": "Title"},
|
||||
{"type": "button", "name": "go", "label": "Go"}
|
||||
]})
|
||||
.to_string();
|
||||
let rect = r#"{"x":0,"y":0,"width":10,"height":10}"#;
|
||||
let wat = format!(
|
||||
r#"(module
|
||||
(import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32)))
|
||||
(import "figcraft" "ui_show" (func $ui_show (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(data (i32.const 1000) "{}") (data (i32.const 100) "shape.rectangle") (data (i32.const 200) "{}")
|
||||
(func (export "figcraft_run") (param $n i32) (result i32)
|
||||
(i32.store (i32.const 0) (i32.add (i32.load (i32.const 0)) (i32.const 1)))
|
||||
(if (i32.eq (i32.load (i32.const 0)) (i32.const 1))
|
||||
(then (return (call $ui_show (i32.const 1000) (i32.const {})))))
|
||||
(call $call (i32.const 100) (i32.const 15) (i32.const 200) (i32.const {}))))"#,
|
||||
spec.replace('"', "\\\""),
|
||||
rect.replace('"', "\\\""),
|
||||
spec.len(),
|
||||
rect.len()
|
||||
);
|
||||
let wasm = wat::parse_str(&wat).unwrap();
|
||||
let manifest = json!({"id": "org.test.panel", "name": "Panel test", "main": "p.wasm", "api": 2, "permissions": {"access": "write"}});
|
||||
let mut s = Session::new();
|
||||
s.execute("plugin.import", &json!({"manifest": manifest, "wasm": base64::engine::general_purpose::STANDARD.encode(wasm)})).unwrap();
|
||||
s.execute("plugin.run", &json!({"id": "org.test.panel"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn layers(u: &Ui) -> usize {
|
||||
let ds = u.app.session.ds().unwrap();
|
||||
ds.doc.node(ds.page).unwrap().children.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_names_its_controls_and_its_button_calls_the_plugin_back() {
|
||||
let mut u = Ui::new(session());
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes;
|
||||
let named = |label: &str| tree.iter().map(|(_, n)| n).find(|n| n.label() == Some(label)).cloned();
|
||||
for label in ["Size", "Labels", "Warm", "Cool", "Accent", "Shape", "Title", "Go"] {
|
||||
assert!(named(label).is_some(), "no control named {label:?}");
|
||||
}
|
||||
let go = named("Go").and_then(|n| n.bounds()).unwrap();
|
||||
let at = Pos2::new(((go.x0 + go.x1) / 2.0) as f32, ((go.y0 + go.y1) / 2.0) as f32);
|
||||
let before = layers(&u);
|
||||
u.button(at, true);
|
||||
u.button(at, false);
|
||||
assert_eq!(layers(&u), before + 1, "Go made the plugin draw a rectangle");
|
||||
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), 1);
|
||||
assert!(u.app.session.plugins.panel.is_some(), "the panel stays open");
|
||||
// Its close button ends the session.
|
||||
u.app.run("plugin.close", json!({"id": "org.test.panel"}));
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.execute("plugin.ui", &json!({})).unwrap(), Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_plugin_panel_draws_no_frames_while_idle() {
|
||||
Harness::new(session()).assert_idle("a plugin panel");
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
//! Plugins in the UI: the installed plugins in the Plugins menu, the parameters form a command
|
||||
//! with parameters asks for before it runs, and the Manage plugins window. Everything runs through
|
||||
//! the engine's `plugin.*` commands.
|
||||
//! with parameters asks for before it runs, the panel a running plugin shows, and the Manage
|
||||
//! plugins window. Everything runs through the engine's `plugin.*` commands.
|
||||
|
||||
use egui::{Context, Id, RichText, Ui};
|
||||
use egui::{Align2, Context, Id, RichText, Ui};
|
||||
use figcraft_engine::plugins::{Access, InstalledPlugin, Param, ParamKind};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::dialogs::window;
|
||||
use crate::dialogs::{floating, window};
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
use crate::widgets::{self, FieldEdit, Lead};
|
||||
|
||||
|
|
@ -30,14 +30,15 @@ pub fn start(app: &mut FigApp, id: &str, command: &str) {
|
|||
let values = c.params.iter().map(|p| (p.name.clone(), p.default_value())).collect();
|
||||
app.ui.plugin_form = Some(PluginForm { id: id.to_string(), command: c.id.clone(), values });
|
||||
}
|
||||
_ => run(app, id, command, Value::Object(Map::new())),
|
||||
_ => run(app, "plugin.run", json!({"id": id, "command": command})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a plugin command; a text output is shown as a toast unless the plugin showed a message.
|
||||
fn run(app: &mut FigApp, id: &str, command: &str, params: Value) {
|
||||
/// 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) {
|
||||
let toast = app.ui.toast.clone();
|
||||
let Some(out) = app.run("plugin.run", json!({"id": id, "command": command, "params": params})) else { return };
|
||||
let Some(out) = app.run(cmd, params) else { return };
|
||||
if app.ui.toast == toast
|
||||
&& let Some(text) = out["output"].as_str().filter(|t| !t.trim().is_empty())
|
||||
{
|
||||
|
|
@ -86,7 +87,7 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
/// The plugin windows (Manage plugins, a command's parameters).
|
||||
/// The plugin windows (Manage plugins, a command's parameters, a plugin's panel).
|
||||
pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
if app.ui.plugins_window {
|
||||
manage_window(app, ctx);
|
||||
|
|
@ -94,6 +95,31 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
if app.ui.plugin_form.is_some() {
|
||||
form_window(app, ctx);
|
||||
}
|
||||
if app.session.plugins.panel.is_some() {
|
||||
panel_window(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel a running plugin shows, at first beside the properties panel (it can be moved): a
|
||||
/// pressed button or a changed value calls the plugin back (`plugin.event`); closing it ends the
|
||||
/// plugin's session.
|
||||
fn panel_window(app: &mut FigApp, ctx: &Context) {
|
||||
let Some(open) = app.session.plugins.panel.as_ref() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let mut keep = true;
|
||||
let mut changed = None;
|
||||
let id = open.manifest.id.as_str();
|
||||
let at = egui::pos2(ctx.content_rect().right() - 252.0, 52.0);
|
||||
floating(open.title(), ctx).id(Id::new(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| {
|
||||
ui.set_width(320.0);
|
||||
changed = controls(ui, id, &open.panel.controls, &open.values, &t);
|
||||
});
|
||||
let id = id.to_string();
|
||||
if !keep {
|
||||
app.run("plugin.close", json!({"id": id}));
|
||||
} else if let Some((control, value)) = changed {
|
||||
run(app, "plugin.event", json!({"id": id, "control": control, "value": value}));
|
||||
}
|
||||
}
|
||||
|
||||
fn manage_window(app: &mut FigApp, ctx: &Context) {
|
||||
|
|
@ -176,16 +202,9 @@ fn form_window(app: &mut FigApp, ctx: &Context) {
|
|||
let title = if label.contains(name.as_str()) { label.to_string() } else { format!("{name}: {label}") };
|
||||
window(&title, ctx).id(Id::new("plugin_form")).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(320.0);
|
||||
egui::Grid::new("plugin_form_grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
for p in &command.params {
|
||||
ui.label(RichText::new(p.title()).font(regular(11.0)).color(t.text_secondary));
|
||||
let current = form.values.get(&p.name).cloned().unwrap_or_else(|| p.default_value());
|
||||
if let Some(v) = field(ui, &form, p, ¤t) {
|
||||
form.values.insert(p.name.clone(), v);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
if let Some((name, v)) = controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) {
|
||||
form.values.insert(name, v);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal(|ui| {
|
||||
submit = widgets::button(ui, "Run", true, true).clicked();
|
||||
|
|
@ -194,24 +213,73 @@ fn form_window(app: &mut FigApp, ctx: &Context) {
|
|||
});
|
||||
app.ui.plugin_form = (open && !submit && !cancel).then_some(form.clone());
|
||||
if submit {
|
||||
run(app, &form.id, &form.command, Value::Object(form.values));
|
||||
run(app, "plugin.run", json!({"id": form.id, "command": form.command, "params": form.values}));
|
||||
}
|
||||
}
|
||||
|
||||
/// One parameter's field; the new value when it changed.
|
||||
fn field(ui: &mut Ui, form: &PluginForm, p: &Param, current: &Value) -> Option<Value> {
|
||||
let id = Id::new(("plugin_param", &form.id, &form.command, &p.name));
|
||||
/// Parameters or panel controls in two columns (label, field); buttons in a row share one line,
|
||||
/// the first button filled. Returns the control the user changed or pressed: its name and new
|
||||
/// value (null for a button).
|
||||
fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Value>, t: &Tokens) -> Option<(String, Value)> {
|
||||
let mut changed = None;
|
||||
let mut first_button = true;
|
||||
egui::Grid::new(("plugin_controls", salt)).num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
for row in params.chunk_by(|a, b| a.kind == ParamKind::Button && b.kind == ParamKind::Button) {
|
||||
let Some(p) = row.first() else { continue };
|
||||
// Checkboxes and buttons carry their own label; a list's sits by its first item.
|
||||
let caption = if matches!(p.kind, ParamKind::Boolean | ParamKind::Button | ParamKind::Note) { "" } else { p.title() };
|
||||
let caption = RichText::new(caption).font(regular(11.0)).color(t.text_secondary);
|
||||
if p.kind == ParamKind::List {
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| ui.label(caption));
|
||||
} else {
|
||||
ui.label(caption);
|
||||
}
|
||||
let hit = match p.kind {
|
||||
ParamKind::Note => {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_max_width(FIELD_W);
|
||||
ui.add(egui::Label::new(RichText::new(&p.label).font(regular(11.0)).color(t.text)).wrap());
|
||||
});
|
||||
None
|
||||
}
|
||||
ParamKind::Button => {
|
||||
ui.horizontal(|ui| {
|
||||
let pressed = row.iter().filter(|b| widgets::button(ui, b.title(), std::mem::take(&mut first_button), true).clicked());
|
||||
pressed.last().map(|b| (b.name.clone(), Value::Null))
|
||||
})
|
||||
.inner
|
||||
}
|
||||
_ => {
|
||||
let current = values.get(&p.name).cloned().unwrap_or_else(|| p.default_value());
|
||||
field(ui, Id::new(("plugin_control", salt, &p.name)), p, ¤t).map(|v| (p.name.clone(), v))
|
||||
}
|
||||
};
|
||||
if hit.is_some() {
|
||||
changed = hit;
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
/// One parameter's field, named for screen readers; the new value when it changed.
|
||||
fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option<Value> {
|
||||
let name = p.title();
|
||||
match p.kind {
|
||||
ParamKind::Text => widgets::text_field(ui, id, current.as_str().unwrap_or_default(), FIELD_W, "").map(Value::String),
|
||||
ParamKind::Number => match widgets::number_field(ui, id, Lead::None, current.as_f64(), "", FIELD_W, 1.0)? {
|
||||
FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => p.coerce(&json!(v)).ok(),
|
||||
ParamKind::Text => widgets::text_field_named(ui, id, current.as_str().unwrap_or_default(), FIELD_W, name).map(Value::String),
|
||||
ParamKind::Number => match widgets::number_field(ui, id, Lead::Named(name), current.as_f64(), "", FIELD_W, 1.0)? {
|
||||
FieldEdit::Set(v) | FieldEdit::Scrub(v, true) => p.coerce(&json!(v)).ok(),
|
||||
FieldEdit::Scrub(_, false) => None,
|
||||
},
|
||||
ParamKind::Boolean => {
|
||||
let on = current.as_bool().unwrap_or(false);
|
||||
widgets::checkbox(ui, on, "").then_some(Value::Bool(!on))
|
||||
widgets::checkbox(ui, on, name).then_some(Value::Bool(!on))
|
||||
}
|
||||
ParamKind::Select => {
|
||||
let resp = widgets::dropdown(ui, current.as_str().unwrap_or_default(), FIELD_W, None);
|
||||
let value = current.as_str().unwrap_or_default();
|
||||
let resp = widgets::dropdown(ui, value, FIELD_W, None);
|
||||
crate::a11y::combo(&resp, name, value, crate::a11y::menu_open(&resp));
|
||||
let mut picked = None;
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
for o in &p.options {
|
||||
|
|
@ -223,5 +291,28 @@ fn field(ui: &mut Ui, form: &PluginForm, p: &Param, current: &Value) -> Option<V
|
|||
});
|
||||
picked
|
||||
}
|
||||
ParamKind::List => {
|
||||
let value = current.as_str();
|
||||
ui.vertical(|ui| {
|
||||
p.options
|
||||
.iter()
|
||||
.filter(|o| widgets::radio(ui, value == Some(o.as_str()), o) && value != Some(o.as_str()))
|
||||
.last()
|
||||
.map(|o| Value::String(o.clone()))
|
||||
})
|
||||
.inner
|
||||
}
|
||||
// shortcut: a swatch and a hex field, no colour picker popover; hook the picker up when
|
||||
// plugins need more than hex.
|
||||
ParamKind::Color => {
|
||||
let hex = current.as_str().unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
let color = figcraft_color::Color::from_hex(hex).unwrap_or(figcraft_color::Color::BLACK);
|
||||
widgets::swatch(ui, &figcraft_color::Paint::solid(color), widgets::FIELD_H);
|
||||
widgets::text_field_named(ui, id, hex, FIELD_W - widgets::FIELD_H - 8.0, name).and_then(|s| p.coerce(&json!(s)).ok())
|
||||
})
|
||||
.inner
|
||||
}
|
||||
ParamKind::Button | ParamKind::Note => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ pub enum Lead<'a> {
|
|||
Text(&'a str),
|
||||
Icon(Icon, &'a str),
|
||||
None,
|
||||
/// No lead, only a name for screen readers (the label is beside the field).
|
||||
Named(&'a str),
|
||||
}
|
||||
|
||||
impl Lead<'_> {
|
||||
|
|
@ -27,7 +29,7 @@ impl Lead<'_> {
|
|||
Lead::Text("W") => "Width",
|
||||
Lead::Text("H") => "Height",
|
||||
Lead::Text(s) => s,
|
||||
Lead::Icon(_, name) => name,
|
||||
Lead::Icon(_, name) | Lead::Named(name) => name,
|
||||
Lead::None => "",
|
||||
}
|
||||
}
|
||||
|
|
@ -101,7 +103,7 @@ fn number_field_impl(
|
|||
return (None, bound_field(ui, id, lead, rect, name));
|
||||
}
|
||||
let lead_w = match lead {
|
||||
Lead::None => 6.0,
|
||||
Lead::None | Lead::Named(_) => 6.0,
|
||||
Lead::Text(s) => (12.0 + s.chars().count() as f32 * 7.0).max(24.0),
|
||||
Lead::Icon(..) => 24.0,
|
||||
};
|
||||
|
|
@ -128,7 +130,7 @@ fn number_field_impl(
|
|||
Lead::Icon(i, _) => {
|
||||
icons::paint(ui.painter(), Rect::from_center_size(lead_rect.center() + vec2(1.0, 0.0), vec2(14.0, 14.0)), i, t.icon_secondary)
|
||||
}
|
||||
Lead::None => {}
|
||||
Lead::None | Lead::Named(_) => {}
|
||||
}
|
||||
if lead_resp.hovered() || lead_resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
|
|
@ -249,7 +251,7 @@ pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> O
|
|||
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside);
|
||||
}
|
||||
let lead_w = match lead {
|
||||
Lead::None => 4.0,
|
||||
Lead::None | Lead::Named(_) => 4.0,
|
||||
Lead::Text(s) => {
|
||||
ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary);
|
||||
(12.0 + s.chars().count() as f32 * 7.0).max(24.0)
|
||||
|
|
@ -325,6 +327,12 @@ pub fn text_field(ui: &mut Ui, id: Id, value: &str, width: f32, placeholder: &st
|
|||
text_field_in(ui, id, value, rect, placeholder, placeholder)
|
||||
}
|
||||
|
||||
/// [`text_field`] without a placeholder, named `name` for screen readers (its label is beside it).
|
||||
pub fn text_field_named(ui: &mut Ui, id: Id, value: &str, width: f32, name: &str) -> Option<String> {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
text_field_in(ui, id, value, rect, "", name)
|
||||
}
|
||||
|
||||
/// A multi-line text field across the panel (descriptions, notes) that commits when it loses
|
||||
/// focus: returns the new text then, when it changed. The draft lives only while it has focus.
|
||||
pub fn multiline_field(ui: &mut Ui, id: Id, value: &str, hint: &str, rows: usize) -> Option<String> {
|
||||
|
|
|
|||
20
docs/gaps.md
20
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
|
|
@ -19,7 +19,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) |
|
||||
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) |
|
||||
| 5 | No localization layer; English only | Localization | 60–100 | [localization-parity](localization-parity.md) |
|
||||
| 6 | Plugins: no run-time UI, distribution or widgets; Figma's plugins don't run | Ecosystem | 25–55 | [target-app-parity](target-app-parity.md) |
|
||||
| 6 | Plugins: no distribution or widgets; Figma's plugins don't run | Ecosystem | 15–40 | [target-app-parity](target-app-parity.md) |
|
||||
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) |
|
||||
| 8 | Platform QA is mostly macOS | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
|
||||
| 9 | Figma Draw: multi-edit, pen pressure on macOS/Linux tablets (brushes, pressure, text on a path and pattern fills landed) | Feature | 2–5 | [target-app-parity](target-app-parity.md) |
|
||||
|
|
@ -105,18 +105,21 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
- **Estimate:** 15–25 h for the layer and string extraction, then 3–5 h per language plus native
|
||||
review; 60–100 h for the 11 key languages. Detail in [`localization-parity.md`](localization-parity.md).
|
||||
|
||||
### 6. Plugins: no run-time UI, distribution or widgets; Figma's plugins don't run
|
||||
### 6. Plugins: no distribution or widgets; Figma's plugins don't run
|
||||
- **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in
|
||||
the browser) acting through the command registry, one undo step per run, manifest permissions,
|
||||
Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples
|
||||
([`plugins.md`](plugins.md)).
|
||||
- **Missing:** plugin-defined UI at run time (forms or panels a plugin returns), settings storage,
|
||||
distribution (a plugin directory), widgets, Community files. Figma's own plugins are JavaScript
|
||||
against Figma's plugin API and don't run.
|
||||
([`plugins.md`](plugins.md)). Plugin panels and settings (ABI v2, 2026-10-11): a plugin shows a
|
||||
panel of fields and buttons drawn with FigCraft's widgets and is called back for each button or
|
||||
changed value (one undo step per callback, same sandbox limits), driven headless with
|
||||
`plugin.ui` / `plugin.event` / `plugin.close`; per-plugin settings kept with the preferences.
|
||||
- **Missing:** distribution (a plugin directory), widgets, Community files; panels are one at a
|
||||
time with no colour picker or images. Figma's own plugins are JavaScript against Figma's plugin
|
||||
API and don't run.
|
||||
- **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks)
|
||||
can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better
|
||||
than Figma does.
|
||||
- **Estimate:** 25–55 h for the missing pieces of FigCraft's own API. Running Figma's plugins
|
||||
- **Estimate:** 15–40 h for the missing pieces of FigCraft's own API. Running Figma's plugins
|
||||
needs a JS engine and an API shim: **owner decision needed**.
|
||||
|
||||
### 7. CPU canvas where Figma uses the GPU
|
||||
|
|
@ -275,6 +278,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
|
||||
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
|
||||
| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h |
|
||||
|
|
|
|||
14
docs/mcp.md
14
docs/mcp.md
|
|
@ -699,12 +699,24 @@ manifest, the ABI and the Rust SDK). Driven with `run_command`: `plugin.import {
|
|||
`figcraft-plugin.json` or its folder) or `plugin.import {manifest, wasm}` (the manifest as an
|
||||
object or JSON text and the module as base64, for this session only), `plugin.list`,
|
||||
`plugin.remove {id}`, and `plugin.run {id, command?, params?, fuel?}`, which returns
|
||||
`{plugin, command, output, logs, calls, fuel}`. A run is one undo step and is rolled back when it
|
||||
`{plugin, command, output, logs, calls, fuel, panel}`. A run is one undo step and is rolled back when it
|
||||
fails; a plugin only reads the document unless its manifest asks for `write`, may be limited to
|
||||
some commands, and can never touch files, other documents, the undo history, dialogs or other
|
||||
plugins. A headless MCP session starts with no plugins: import them first. `plugins.runLast` runs
|
||||
the last plugin command again.
|
||||
|
||||
A plugin that shows a panel (API 2) can be used headless too: `plugin.ui {id?}` returns the open
|
||||
panel (`{plugin, command, title, controls, values}`, or null), `plugin.event {id, control, value?}`
|
||||
presses a button or sets a field as a click would (the plugin is called back; one undo step;
|
||||
returns like `plugin.run`, with the updated `panel`), and `plugin.close {id}` ends the session.
|
||||
Plugin settings are kept per plugin with the preferences (in a headless session, for the session).
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.figcraft.examples.swatch-grid","command":"panel"}}}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.event","params":{"id":"org.figcraft.examples.swatch-grid","control":"palette","value":"Forest"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.event","params":{"id":"org.figcraft.examples.swatch-grid","control":"generate"}}}}
|
||||
```
|
||||
|
||||
### Dev Mode
|
||||
|
||||
- **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a
|
||||
|
|
|
|||
116
docs/plugins.md
116
docs/plugins.md
|
|
@ -9,9 +9,13 @@ permissions its manifest asks for, and nothing more.
|
|||
WebAssembly interpreter (no JIT), in the desktop app and in the browser build alike. There is no
|
||||
WASI: no file system, network, clock or randomness. A run has an instruction budget (fuel) and a
|
||||
memory cap, so an endless loop or a runaway allocation fails that run, not the app.
|
||||
- **One undo step.** Everything a run does is one step in the undo history, named after the plugin.
|
||||
When a run fails (the plugin returns an error, traps, panics or runs out of fuel or memory), the
|
||||
document, selection and page are put back as they were.
|
||||
- **One undo step.** Everything a run does is one step in the undo history, named after the plugin;
|
||||
so is everything one callback from its panel does. When a run or a callback fails (the plugin
|
||||
returns an error, traps, panics or runs out of fuel or memory), the document, selection and page
|
||||
are put back as they were.
|
||||
- **Panels and settings (API 2).** A plugin can show a small panel of its own while it runs: FigCraft
|
||||
draws its fields and buttons with its own widgets and calls the plugin back when one is used. A
|
||||
plugin can also keep settings from one run to the next.
|
||||
- **Permissions.** The manifest says whether the plugin only reads the document or may edit it, and
|
||||
can narrow the commands it may call. FigCraft enforces both.
|
||||
|
||||
|
|
@ -30,12 +34,30 @@ permissions its manifest asks for, and nothing more.
|
|||
|
||||
A message the plugin shows (`notify`) appears as a toast; so does a text result.
|
||||
|
||||
From scripts, the CLI and MCP the same commands apply:
|
||||
**Plugin panels.** A plugin written for API 2 can show a panel while it runs: a floating window,
|
||||
first placed beside the properties panel (drag its title bar to move it), with the plugin's notes,
|
||||
fields (text, number, checkbox, menu, list, colour) and buttons, drawn with FigCraft's own widgets
|
||||
and named for screen readers. Pressing a button or changing a field calls the plugin back with every
|
||||
field's value; what that callback does is one undo step (none when it changes nothing), and the
|
||||
plugin can update the panel or close it. The module stays loaded while its panel is open, so the
|
||||
plugin keeps its state between callbacks. Closing the window ends the plugin's session. One plugin
|
||||
panel is open at a time: running another plugin that shows one replaces it. An open panel costs
|
||||
nothing while nobody uses it (no frames are drawn).
|
||||
|
||||
If a callback fails, it is rolled back and its message shown; a plugin that returned an error
|
||||
keeps its panel, one that crashed or ran out of fuel or memory loses it.
|
||||
|
||||
From scripts, the CLI and MCP the same commands apply, panels included:
|
||||
|
||||
```jsonl
|
||||
{"command": "plugin.import", "params": {"path": "plugins/tidy-names"}}
|
||||
{"command": "select.all", "params": {}}
|
||||
{"command": "plugin.run", "params": {"id": "org.figcraft.examples.tidy-names", "command": "tidy", "params": {"case": "Title Case"}}}
|
||||
{"command": "plugin.import", "params": {"path": "target/plugins/dist/swatch-grid"}}
|
||||
{"command": "plugin.run", "params": {"id": "org.figcraft.examples.swatch-grid", "command": "panel"}}
|
||||
{"command": "plugin.event", "params": {"id": "org.figcraft.examples.swatch-grid", "control": "palette", "value": "Ocean"}}
|
||||
{"command": "plugin.event", "params": {"id": "org.figcraft.examples.swatch-grid", "control": "generate"}}
|
||||
{"command": "plugin.close", "params": {"id": "org.figcraft.examples.swatch-grid"}}
|
||||
```
|
||||
|
||||
`figcraft-cli run script.jsonl --in design.figcraft --out tidy.figcraft` runs that headless; over
|
||||
|
|
@ -47,7 +69,10 @@ plugins: import them in the script.
|
|||
| `plugin.list` | `{}` | `{plugins: [{id, name, version, description, main, api, permissions, commands, path, loaded}], last: {id, command}}` |
|
||||
| `plugin.import` | `{path}` or `{manifest, wasm}` | `path` is a `figcraft-plugin.json` or its folder. Without files (the browser, tests), pass the manifest (an object or JSON text) and the module as base64 `wasm`; such a plugin lasts for the session only. A plugin with the same id is replaced. The module is checked first. |
|
||||
| `plugin.remove` | `{id}` | Uninstalls it. |
|
||||
| `plugin.run` | `{id, command?, params?, fuel?}` | Runs a command (default: the first) with its parameters checked against the manifest and the missing ones filled with their defaults; `fuel` lowers the instruction budget. Returns `{plugin, command, output, logs, calls, fuel}`; `output` is the plugin's result, parsed as JSON when it is JSON. |
|
||||
| `plugin.run` | `{id, command?, params?, fuel?}` | Runs a command (default: the first) with its parameters checked against the manifest and the missing ones filled with their defaults; `fuel` lowers the instruction budget (for the panel's callbacks too). Returns `{plugin, command, output, logs, calls, fuel, panel}`; `output` is the plugin's result, parsed as JSON when it is JSON; `panel` is the plugin's open panel (as `plugin.ui` returns it) or null. |
|
||||
| `plugin.ui` | `{id?}` | The open plugin panel: `{plugin, command, title, controls, values}` (`values`: every field's current value by name), or null. With `id`, only that plugin's. |
|
||||
| `plugin.event` | `{id, control, value?}` | Uses a control of plugin `id`'s open panel, as a click would: a button (no `value`), or a field set to `value` (checked like a parameter: a number is clamped, a list or menu value must be one of its options, a colour must be hex). The plugin is called back; returns like `plugin.run`. |
|
||||
| `plugin.close` | `{id}` | Closes plugin `id`'s panel and ends its session. `{closed}` says whether one was open. |
|
||||
| `plugins.runLast` | `{}` | Runs the last plugin command again. |
|
||||
| `plugins.manage` | `{}` | Opens the Plugins window; returns the list like `plugin.list`. |
|
||||
|
||||
|
|
@ -85,11 +110,11 @@ plugins: import them in the script.
|
|||
| `name` | Required. Shown in the Plugins menu (64 characters at most). |
|
||||
| `version`, `description` | Optional text. |
|
||||
| `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. |
|
||||
| `api` | The plugin API version, `1` (the default). A manifest asking for a newer one is refused. |
|
||||
| `api` | The plugin API version the plugin was written for: `1` (the default) or `2` (panels and settings). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels fails clearly on an older FigCraft. |
|
||||
| `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. |
|
||||
| `commands` | The menu commands, each `{id, label, params?}`. Without any, the plugin has one, `run`. |
|
||||
| `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean` or `select` (one of `options`). |
|
||||
| `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean`, `select` (one of `options`, a menu), `list` (one of `options`, shown as a list) or `color` (hex, `#RRGGBB` or `#RRGGBBAA`). A default that doesn't fit is dropped. |
|
||||
|
||||
## What a plugin can't do
|
||||
|
||||
|
|
@ -107,13 +132,17 @@ it can handle):
|
|||
- for a `read` plugin, any command that changes the document (checked before the call for commands
|
||||
that make an undo step, and after it for the rest: a change is undone and refused).
|
||||
|
||||
Limits per run: 1,000,000,000 fuel (about a second or two of plain WebAssembly in a debug build,
|
||||
far less in a release build), 64 MB of memory, 100,000 host calls (each also costs 10,000 fuel),
|
||||
16 MB per string either way, 1,000 log lines, and modules up to 16 MB.
|
||||
Limits per run, and again per panel callback: 1,000,000,000 fuel (about a second or two of plain
|
||||
WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100,000 host calls
|
||||
(each also costs 10,000 fuel), 16 MB per string either way, 1,000 log lines, and modules up to
|
||||
16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys
|
||||
and JSON values), keys 1–128 characters.
|
||||
|
||||
## ABI (version 1)
|
||||
## ABI (version 2)
|
||||
|
||||
Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values are `i32`.
|
||||
Version 2 adds panels and settings to version 1; a version 1 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`.
|
||||
|
||||
The module **exports** `memory` and `figcraft_run(input_len) -> status`. When `figcraft_run` starts,
|
||||
the reply buffer holds the input JSON, `input_len` bytes:
|
||||
|
|
@ -122,6 +151,13 @@ the reply buffer holds the input JSON, `input_len` bytes:
|
|||
{"api": 1, "command": "grid", "params": {"palette": "#FF5A5F", "columns": 3, "labels": true}, "selection": [12, 15]}
|
||||
```
|
||||
|
||||
`api` is the manifest's. When the plugin's panel calls it back, `figcraft_run` runs again in the
|
||||
same instance (its memory as the last call left it) with the event and every field's value added:
|
||||
|
||||
```json
|
||||
{"api": 2, "command": "panel", "params": {}, "selection": [], "event": {"control": "generate", "value": null}, "values": {"palette": "Ocean", "columns": 3, "labels": true}}
|
||||
```
|
||||
|
||||
It returns `0` for success; anything else fails the run (and rolls it back), with the text passed
|
||||
to `set_output` as the error message.
|
||||
|
||||
|
|
@ -137,8 +173,37 @@ included, is refused when the plugin is imported):
|
|||
| `log` | `(ptr, len)` | A line of the run's log (returned in `logs`, and written to FigCraft's log). |
|
||||
| `notify` | `(ptr, len)` | A short message for the user (a toast in the app). |
|
||||
| `set_output` | `(ptr, len)` | The run's result (JSON or text), or its error message when it fails. |
|
||||
| `ui_show` | `(ptr, len) -> status` | API 2. Shows the plugin's panel, or replaces the one showing (the panel spec below, as JSON). `1`: the spec is invalid, the reply says why. |
|
||||
| `ui_close` | `()` | API 2. Closes the panel when the call returns. |
|
||||
| `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. |
|
||||
|
||||
A pointer or length outside the plugin's memory traps, which fails the run.
|
||||
A pointer or length outside the plugin's memory traps, which fails the run. Host functions can only
|
||||
be called while `figcraft_run` runs (not from a start function). Settings are forgotten when the
|
||||
plugin is removed.
|
||||
|
||||
### Panel spec
|
||||
|
||||
```json
|
||||
{"title": "Swatch grid", "controls": [
|
||||
{"type": "list", "name": "palette", "label": "Palette", "options": ["Sunset", "Ocean"], "value": "Ocean"},
|
||||
{"type": "note", "label": "#0A2463, #1E5AA8, #3E92CC"},
|
||||
{"type": "number", "name": "columns", "label": "Columns", "min": 1, "max": 24, "value": 3},
|
||||
{"type": "boolean", "name": "labels", "label": "Label each swatch", "value": true},
|
||||
{"type": "color", "name": "accent", "label": "Accent", "value": "#FF5A5F"},
|
||||
{"type": "button", "name": "generate", "label": "Generate"},
|
||||
{"type": "button", "name": "close", "label": "Close"}
|
||||
]}
|
||||
```
|
||||
|
||||
`title` defaults to the plugin's name. Controls are the manifest's parameter types (`text`,
|
||||
`number`, `boolean`, `select`, `list`, `color`) plus `button` and `note` (text in `label`, up to
|
||||
1,000 characters, no `name` needed), in order, each `name` unique. A field shows its `value`; a
|
||||
field without one keeps what the user entered before the panel was updated, so a plugin can update
|
||||
a note without resetting the other fields. Buttons next to each other share a row; the first button
|
||||
is the primary one. FigCraft checks the whole spec (sizes, kinds, options, bounds) and drops values
|
||||
that don't fit their field.
|
||||
|
||||
## Writing a plugin in Rust
|
||||
|
||||
|
|
@ -149,8 +214,11 @@ A pointer or length outside the plugin's memory traps, which fails the run.
|
|||
- **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
|
||||
with a form (case, include layers inside), and a quick one without.
|
||||
- **Swatch grid** (`swatch-grid`): a grid of colour swatches from a palette of hex colours, centred in
|
||||
the view, named after their colours and optionally labelled.
|
||||
- **Swatch grid** (`swatch-grid`, API 2): a grid of colour swatches, centred in the view, named after
|
||||
their colours and optionally labelled. **Palettes…** opens a panel with a list of built-in
|
||||
palettes (picking one shows its colours), columns, labels and a Generate button; the palette
|
||||
picked last is remembered as a setting for next time. **From hex colours…** takes a palette of
|
||||
hex colours in the parameters form.
|
||||
|
||||
`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
|
||||
|
|
@ -202,8 +270,10 @@ entry!(run);
|
|||
Build it with `cargo build --release --target wasm32-unknown-unknown`, put
|
||||
`target/wasm32-unknown-unknown/release/my_plugin.wasm` next to a `figcraft-plugin.json` whose `main`
|
||||
names it, and import the manifest. The SDK gives `call`, `selection`, `log`, `notify` and the
|
||||
`Input` (`command`, `params` with `str`/`f64`/`bool` helpers, `selection`); returning `Err` fails
|
||||
the run and rolls it back. Outside WebAssembly every host call fails, so the plugin's own logic can
|
||||
`Input` (`command`, `params` with `str`/`f64`/`bool` helpers, `selection`, and `event` when the
|
||||
panel calls back: `control`, `value` and every field's `values`); returning `Err` fails the run and
|
||||
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. 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`,
|
||||
|
|
@ -211,9 +281,11 @@ import what you need from `figcraft`.
|
|||
|
||||
## Not yet
|
||||
|
||||
- Plugin-defined UI: a plugin can't yet return a form or panel of its own at run time; parameters
|
||||
come from the manifest.
|
||||
- Storage of the plugin's own: plugins can keep data on layers with `node.set {props: {pluginData:
|
||||
{key: value}}}`, but have no settings of their own between runs.
|
||||
- Distribution: no plugin browser, signing or automatic updates; plugins are imported from a
|
||||
- Distribution: no plugin directory, signing or automatic updates; plugins are imported from a
|
||||
manifest.
|
||||
- Widgets (objects on the canvas drawn by a plugin) and plugins written for other apps (JavaScript
|
||||
plugins): neither runs.
|
||||
- Panels: one at a time; a colour field is a swatch with a hex field (no colour picker); no images,
|
||||
sliders or free layout inside a panel.
|
||||
- Data in the document: plugins keep data on layers (pages included) with `node.set {props:
|
||||
{pluginData: {key: value}}}`; there is no separate per-document store.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
|
|
@ -57,7 +57,7 @@ blockers.
|
|||
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
|
||||
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library | accounts, teams, TLS in the desktop app, packaging the server | 45–90 |
|
||||
| M10 Localization (new) | not started | layer + 11 languages | 60–100 |
|
||||
| M11 Plugins | runtime, API, menu, manager and SDK done (#101) | run-time plugin UI, settings storage, distribution, widgets; Figma plugin compatibility is an owner decision | 25–55 |
|
||||
| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2) | distribution, widgets; Figma plugin compatibility is an owner decision | 15–40 |
|
||||
| M12 AI features (new, owner decision) | not started | local models | 30–60 |
|
||||
|
||||
## Next, after the current focus
|
||||
|
|
@ -72,6 +72,7 @@ blockers.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
|
||||
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
|
||||
| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
|
|
@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
|
|||
| Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) |
|
||||
| Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.8% × discounts 0.9 × 0.9 × 0.8) |
|
||||
| Remaining to beta | **~200–365 h** one Opus 5.5 agent (~40–80 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~373–712 h** one agent (~95–165 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~363–697 h** one agent (~95–165 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); `.fig` import and multiplayer are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
|
||||
|
||||
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were
|
||||
|
|
@ -35,7 +35,7 @@ tool; presence was being graded as behaviour in places.
|
|||
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~47% | ~355–675 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
|
||||
| Full target (ready for real work) | ~47% | ~345–660 h | multiplayer (45–90), localization (60–100), plugins (15–40), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
|
||||
| Mainstream practitioner | ~43% | ~165–310 h | core editor depth incl. `.fig` import (83–163), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
|
||||
| Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
|
||||
|
||||
|
|
@ -111,11 +111,11 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 15 | Dev Mode / inspect | 2 | 90% | 64% | Dev resources links, import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (code connect and plugins in Dev Mode landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–3 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 85% | 60% | Comments are local only; a branch's own version history (branches with review and merge landed) | 2–5 |
|
||||
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 |
|
||||
| 18 | Plugins & widgets | 4 | 35% | 20% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples). Left: plugin UI at run time, settings storage, distribution, widgets; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 25–55 |
|
||||
| 18 | Plugins & widgets | 4 | 45% | 28% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples), then plugin panels called back per button or changed value and per-plugin settings (ABI v2). Left: distribution, widgets; panels one at a time without a colour picker; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 15–40 |
|
||||
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
|
||||
| 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 |
|
||||
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
|
||||
| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **200–401** |
|
||||
| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **190–386** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 85–167 |
|
||||
|
||||
## By dimension (ready for real work)
|
||||
|
|
@ -131,9 +131,9 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| Hardware | 3 | 55% | 13–27 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); no GPU canvas, no P3 on screen, one window | [hardware-parity](hardware-parity.md) |
|
||||
| Localization | 3 | 10% | 60–100 | English only; Figma ships 7 more UI languages | [localization-parity](localization-parity.md) |
|
||||
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
|
||||
| Ecosystem/plugins | 3 | 20% | 25–55 | Own WASM plugins with a Rust SDK and two examples (#101); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
|
||||
| Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
|
||||
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
|
||||
| **Ready for real work** | 100 | **~47%** | **~373–712** | | |
|
||||
| **Ready for real work** | 100 | **~47%** | **~363–697** | | |
|
||||
|
||||
Dimension hours add up to the full-parity total without double counting: the feature-area rows
|
||||
for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are
|
||||
|
|
@ -463,9 +463,11 @@ Hours in this narrative are the builders' estimates; the tables above supersede
|
|||
manifest… (kept in the preferences, the module re-read on every run), the installed plugins in
|
||||
the Plugins menu with a parameters form drawn from the manifest, Run last plugin (⌥⌘P), Manage
|
||||
plugins, and `plugin.*` commands for scripts, the CLI and MCP; a Rust SDK and two example
|
||||
plugins (Tidy names, Swatch grid) in `plugins/`, built by `cargo xtask plugins`. *Left:*
|
||||
plugin-defined UI at run time (forms or panels a plugin returns), plugin settings storage,
|
||||
distribution; for collaboration, the browser client and a standalone server. 18–45 h.
|
||||
plugins (Tidy names, Swatch grid) in `plugins/`, built by `cargo xtask plugins`. Then plugin
|
||||
panels (ABI v2: fields and buttons drawn with FigCraft's widgets, the plugin called back for
|
||||
each button or changed value as one undo step, `plugin.ui` / `plugin.event` / `plugin.close`
|
||||
for scripts and MCP) and per-plugin settings kept with the preferences. *Left:* plugin
|
||||
distribution; for collaboration, the browser client and a standalone server. 15–40 h.
|
||||
9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer
|
||||
pages: engine, render and headless UI-frame timings). The canvas keeps its last image and
|
||||
re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders
|
||||
|
|
@ -578,6 +580,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
|
||||
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
|
||||
| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h |
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
{
|
||||
"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.",
|
||||
"version": "1.1.0",
|
||||
"description": "Makes a grid of colour swatches from a built-in palette (in a panel) or from hex colours.",
|
||||
"main": "swatch_grid.wasm",
|
||||
"api": 1,
|
||||
"api": 2,
|
||||
"permissions": {
|
||||
"access": "write",
|
||||
"commands": ["view.get", "shape.rectangle", "text.create", "select.set"]
|
||||
},
|
||||
"commands": [
|
||||
{ "id": "panel", "label": "Palettes…" },
|
||||
{
|
||||
"id": "grid",
|
||||
"label": "Swatch grid…",
|
||||
"label": "From hex colours…",
|
||||
"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 },
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
//! Swatch grid: a grid of colour swatches from a palette (hex colours), centred in the view, each
|
||||
//! named after its colour and optionally labelled.
|
||||
//! Swatch grid: a grid of colour swatches, centred in the view, each named after its colour and
|
||||
//! optionally labelled. Two commands: a panel to pick one of the built-in palettes and Generate
|
||||
//! (the palette picked last is remembered for next time), and a form taking hex colours.
|
||||
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, log, notify};
|
||||
use figcraft_plugin_sdk::{Event, Input, Result, Value, call, close_ui, entry, json, log, notify, set_setting, setting, show_ui};
|
||||
|
||||
/// Swatches made in one run, at most.
|
||||
const MAX_SWATCHES: usize = 256;
|
||||
|
||||
/// The panel's palettes: (name, colours).
|
||||
const PALETTES: &[(&str, &str)] = &[
|
||||
("Sunset", "#FF5A5F, #FFB400, #00A699, #007A87, #7B0051, #3D3D3D"),
|
||||
("Ocean", "#0A2463, #1E5AA8, #3E92CC, #8CC7E8, #D8F1FF"),
|
||||
("Forest", "#183A2B, #2F6B4F, #4E9F6E, #8CCB9B, #D3EED8"),
|
||||
("Greys", "#111111, #444444, #777777, #AAAAAA, #DDDDDD"),
|
||||
];
|
||||
|
||||
/// `#RGB` or `#RRGGBB` (the `#` optional) as `#RRGGBB`.
|
||||
fn hex(s: &str) -> Option<String> {
|
||||
let h = s.trim().trim_start_matches('#');
|
||||
|
|
@ -35,15 +44,18 @@ fn palette(text: &str) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
let colors = palette(input.str("palette").unwrap_or_default());
|
||||
/// The colours of the built-in palette `name` (empty when there's none).
|
||||
fn named(name: &str) -> &'static str {
|
||||
PALETTES.iter().find(|(n, _)| *n == name).map_or("", |(_, c)| c)
|
||||
}
|
||||
|
||||
/// Swatches of `colors` in a grid centred in the view (labelled when `labels`), then selected.
|
||||
fn grid(colors: &[String], columns: f64, size: f64, gap: f64, labels: bool) -> Result<Value> {
|
||||
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 columns = columns.clamp(1.0, 24.0) as usize;
|
||||
let (size, gap) = (size.clamp(8.0, 512.0), gap.clamp(0.0, 256.0));
|
||||
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);
|
||||
|
|
@ -65,11 +77,64 @@ fn run(input: Input) -> Result<Value> {
|
|||
Ok(json!({"swatches": colors.len(), "ids": made}))
|
||||
}
|
||||
|
||||
/// Show the panel with palette `name` picked and the other fields as in `values` (null: their
|
||||
/// defaults).
|
||||
fn panel(name: &str, values: &Value) -> Result<Value> {
|
||||
let names: Vec<&str> = PALETTES.iter().map(|(n, _)| *n).collect();
|
||||
show_ui(&json!({"title": "Swatch grid", "controls": [
|
||||
{"type": "list", "name": "palette", "label": "Palette", "options": names, "value": name},
|
||||
{"type": "note", "label": named(name)},
|
||||
{"type": "number", "name": "columns", "label": "Columns", "min": 1, "max": 24, "value": values["columns"].as_f64().unwrap_or(3.0)},
|
||||
{"type": "boolean", "name": "labels", "label": "Label each swatch", "value": values["labels"].as_bool().unwrap_or(true)},
|
||||
{"type": "button", "name": "generate", "label": "Generate"},
|
||||
{"type": "button", "name": "close", "label": "Close"}
|
||||
]}))?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// A button or a changed value in the panel.
|
||||
fn on_event(e: Event) -> Result<Value> {
|
||||
let name = e.values["palette"].as_str().unwrap_or("Sunset");
|
||||
match e.control.as_str() {
|
||||
// Show the picked palette's colours, and remember it for next time.
|
||||
"palette" => {
|
||||
set_setting("palette", &json!(name))?;
|
||||
panel(name, &e.values)
|
||||
}
|
||||
"generate" => {
|
||||
grid(&palette(named(name)), e.values["columns"].as_f64().unwrap_or(3.0), 64.0, 16.0, e.values["labels"].as_bool().unwrap_or(true))
|
||||
}
|
||||
"close" => {
|
||||
close_ui();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
_ => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
if let Some(e) = input.event {
|
||||
return on_event(e);
|
||||
}
|
||||
if input.command == "panel" {
|
||||
let last = setting("palette").and_then(|v| v.as_str().map(str::to_string)).filter(|n| !named(n).is_empty());
|
||||
return panel(last.as_deref().unwrap_or("Sunset"), &Value::Null);
|
||||
}
|
||||
let colors = palette(input.str("palette").unwrap_or_default());
|
||||
grid(
|
||||
&colors,
|
||||
input.f64("columns").unwrap_or(3.0),
|
||||
input.f64("size").unwrap_or(64.0),
|
||||
input.f64("gap").unwrap_or(16.0),
|
||||
input.bool("labels").unwrap_or(true),
|
||||
)
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{hex, palette};
|
||||
use super::{PALETTES, hex, palette};
|
||||
|
||||
#[test]
|
||||
fn palettes_parse() {
|
||||
|
|
@ -78,5 +143,8 @@ mod tests {
|
|||
assert_eq!(hex("#12345"), None);
|
||||
assert_eq!(hex("red"), None);
|
||||
assert_eq!(palette("#FF5A5F, nope;#0f0\n00A699"), vec!["#FF5A5F", "#00FF00", "#00A699"]);
|
||||
for (name, colors) in PALETTES {
|
||||
assert_eq!(palette(colors).len(), colors.split(',').count(), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@
|
|||
//! entry!(run);
|
||||
//! ```
|
||||
//!
|
||||
//! A plugin can also show a panel ([`show_ui`]): FigCraft draws its controls and calls the plugin
|
||||
//! again for each button press or changed value, with [`Input::event`] set. The module stays
|
||||
//! loaded while the panel is open, so statics keep their values between those calls. Settings
|
||||
//! ([`setting`], [`set_setting`]) last from one run to the next.
|
||||
//!
|
||||
//! 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)]
|
||||
|
|
@ -43,6 +48,19 @@ pub struct Input {
|
|||
pub params: Value,
|
||||
/// The selected layer ids.
|
||||
pub selection: Vec<u64>,
|
||||
/// Set when the plugin's panel calls it back.
|
||||
pub event: Option<Event>,
|
||||
}
|
||||
|
||||
/// A button pressed or a value changed in the plugin's panel.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Event {
|
||||
/// The control's name.
|
||||
pub control: String,
|
||||
/// A field's new value (null for a button).
|
||||
pub value: Value,
|
||||
/// Every field's value, by name.
|
||||
pub values: Value,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
|
|
@ -53,6 +71,11 @@ impl 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")),
|
||||
event: v.get("event").map(|e| Event {
|
||||
control: e.get("control").and_then(Value::as_str).unwrap_or_default().to_string(),
|
||||
value: e.get("value").cloned().unwrap_or(Value::Null),
|
||||
values: v.get("values").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +125,37 @@ pub fn notify(message: &str) {
|
|||
host::notify(message.as_bytes());
|
||||
}
|
||||
|
||||
/// Show the plugin's panel, or replace the one showing: `{"title", "controls": [...]}` (see
|
||||
/// `docs/plugins.md`). An invalid panel is an error.
|
||||
pub fn show_ui(panel: &Value) -> Result<()> {
|
||||
let spec = serde_json::to_string(panel).map_err(|e| e.to_string())?;
|
||||
if host::ui_show(spec.as_bytes()) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) }
|
||||
}
|
||||
|
||||
/// Close the plugin's panel (the plugin isn't called again until it's run again).
|
||||
pub fn close_ui() {
|
||||
host::ui_close();
|
||||
}
|
||||
|
||||
/// The plugin's setting `key`, as stored by [`set_setting`].
|
||||
pub fn setting(key: &str) -> Option<Value> {
|
||||
if host::setting_get(key.as_bytes()) != 0 {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_slice(&host::reply()).ok()
|
||||
}
|
||||
|
||||
/// Remember `value` under `key` for later runs (64 KB of settings per plugin).
|
||||
pub fn set_setting(key: &str, value: &Value) -> Result<()> {
|
||||
let v = serde_json::to_string(value).map_err(|e| e.to_string())?;
|
||||
if host::setting_set(key.as_bytes(), v.as_bytes()) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) }
|
||||
}
|
||||
|
||||
/// Forget setting `key`.
|
||||
pub fn delete_setting(key: &str) {
|
||||
host::setting_delete(key.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();
|
||||
|
|
@ -147,6 +201,11 @@ mod host {
|
|||
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);
|
||||
pub fn ui_show(ptr: *const u8, len: usize) -> i32;
|
||||
pub fn ui_close();
|
||||
pub fn setting_get(key: *const u8, key_len: usize) -> i32;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +233,21 @@ mod host {
|
|||
pub fn set_output(s: &[u8]) {
|
||||
unsafe { sys::set_output(s.as_ptr(), s.len()) }
|
||||
}
|
||||
pub fn ui_show(s: &[u8]) -> i32 {
|
||||
unsafe { sys::ui_show(s.as_ptr(), s.len()) }
|
||||
}
|
||||
pub fn ui_close() {
|
||||
unsafe { sys::ui_close() }
|
||||
}
|
||||
pub fn setting_get(key: &[u8]) -> i32 {
|
||||
unsafe { sys::setting_get(key.as_ptr(), key.len()) }
|
||||
}
|
||||
pub fn setting_set(key: &[u8], value: &[u8]) -> i32 {
|
||||
unsafe { sys::setting_set(key.as_ptr(), key.len(), value.as_ptr(), value.len()) }
|
||||
}
|
||||
pub fn setting_delete(key: &[u8]) {
|
||||
unsafe { sys::setting_delete(key.as_ptr(), key.len()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Outside WebAssembly (a plugin's own unit tests) every host call fails.
|
||||
|
|
@ -193,6 +267,17 @@ mod host {
|
|||
pub fn log(_s: &[u8]) {}
|
||||
pub fn notify(_s: &[u8]) {}
|
||||
pub fn set_output(_s: &[u8]) {}
|
||||
pub fn ui_show(_s: &[u8]) -> i32 {
|
||||
1
|
||||
}
|
||||
pub fn ui_close() {}
|
||||
pub fn setting_get(_key: &[u8]) -> i32 {
|
||||
1
|
||||
}
|
||||
pub fn setting_set(_key: &[u8], _value: &[u8]) -> i32 {
|
||||
1
|
||||
}
|
||||
pub fn setting_delete(_key: &[u8]) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -208,5 +293,12 @@ mod tests {
|
|||
assert!(Input::parse("nope").is_err());
|
||||
assert!(call("node.get", &json!({"id": 1})).is_err());
|
||||
assert!(selection().is_empty());
|
||||
assert!(i.event.is_none());
|
||||
let e = Input::parse(r#"{"command":"panel","event":{"control":"go","value":null},"values":{"n":2}}"#)
|
||||
.unwrap_or_default()
|
||||
.event
|
||||
.unwrap_or_default();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue