mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
A plug-in whose manifest gives "api": 2 and lists the new `progress` permission may import three host functions from module `env`: vc_progress(done, total), vc_cancelled() -> i32 and vc_log(level, ptr, len) -> i32. Installing checks every import and refuses, by name, one the host doesn't offer, one with another type and one the manifest doesn't allow. - Progress is clamped and passed on once per tenth of a percent (past 2,000 reports, once per 1,000 changes). A heavy filter's progress window and the export's progress window show it as a percentage bar, and their Cancel makes vc_cancelled return 1. A live effect's progress is not shown. - A run cancelled before it returns ends as cancelled, and one that returns after its time budget ran out ends at that budget, so what a plug-in returns after it sees vc_cancelled changes nothing. This also applies to a plug-in of API level 1 that finishes after a Cancel or past its time budget. - Log text must lie in the module's memory and be UTF-8. Lines go to the app's log as "<id>: <text>" with hidden characters escaped, at most 1,024 characters a line, 200 lines and 32 KiB a run, and the host reads at most 1 MiB of log text a run. - plugin.list and plugin.info give the manifest's `api`; the install prompt names the new permission in plain words. Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md.
886 lines
53 KiB
Rust
886 lines
53 KiB
Rust
//! WebAssembly plug-ins (#121): object filters through `plugin.run` (one undo step,
|
||
//! transactional), live-effect plug-ins in appearance stacks, and the `plugin.*` management
|
||
//! commands.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_geom::{PathData, Point, SubPath};
|
||
use vectorcraft_plugins::wat as tpl;
|
||
|
||
use crate::*;
|
||
|
||
/// The example Desaturate plug-in (built from `crates/plugins/example`).
|
||
const DESATURATE: &[u8] = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm");
|
||
const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate";
|
||
|
||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||
}
|
||
|
||
fn install(s: &mut Session, wasm: &[u8]) -> Value {
|
||
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm)}))
|
||
}
|
||
|
||
fn install_wat(s: &mut Session, src: &str) -> Value {
|
||
install(s, &wat::parse_str(src).unwrap())
|
||
}
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
|
||
s
|
||
}
|
||
|
||
/// A 10 × 10 rectangle at `x` filled with `fill` and no stroke.
|
||
fn rect(s: &mut Session, x: f64, fill: &str) -> NodeId {
|
||
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 10, "height": 10}))["id"].as_u64().unwrap());
|
||
run(s, "paint.setFill", json!({"ids": [id.0], "color": fill}));
|
||
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
|
||
id
|
||
}
|
||
|
||
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||
}
|
||
|
||
fn triangle() -> PathData {
|
||
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 0.0), Point::new(15.0, 20.0)], true))
|
||
}
|
||
|
||
#[test]
|
||
fn object_filter_recolours_the_selection_as_one_undo_step() {
|
||
let mut s = session();
|
||
let info = install(&mut s, DESATURATE);
|
||
assert_eq!((info["id"].as_str(), info["kind"].as_str()), (Some(DESATURATE_ID), Some("filter")));
|
||
let a = rect(&mut s, 10.0, "#ff0000");
|
||
let b = rect(&mut s, 40.0, "#0000ff");
|
||
run(&mut s, "select.all", json!({}));
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let r = run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "params": {"amount": 100}}));
|
||
assert_eq!(r["ids"], json!([a.0, b.0]));
|
||
for id in [a, b] {
|
||
let [r, g, b] = node(&s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated();
|
||
assert!((r - g).abs() < 1e-6 && (g - b).abs() < 1e-6, "grey: {r} {g} {b}");
|
||
}
|
||
let st = s.doc().unwrap();
|
||
assert_eq!(st.history.undo.len(), undo + 1);
|
||
assert_eq!(st.history.undo.last().unwrap().label, "Desaturate");
|
||
assert_eq!(st.selection.objects, [a, b]);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||
// Plug-in parameters may also be top-level keys; wrong types are refused before it runs.
|
||
run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "amount": 0}));
|
||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||
assert!(matches!(s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": "all"})), Err(EngineError::BadParams { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn object_filters_add_replace_and_generate_objects() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0, "#ff0000");
|
||
let b = rect(&mut s, 40.0, "#00ff00");
|
||
run(&mut s, "select.all", json!({}));
|
||
// Keeps A, drops B and adds a triangle above A.
|
||
let out =
|
||
json!({"objects": [{"id": a.0}, {"path": triangle(), "fills": [{"type": "solid", "color": {"model": "gray", "k": 1}}], "name": "Tri"}]});
|
||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.replace", "filter", "{}"), &out.to_string()));
|
||
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.replace"}));
|
||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||
assert_eq!(ids[0], a);
|
||
let st = s.doc().unwrap();
|
||
assert!(st.doc.node(b).is_none());
|
||
let layer: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
|
||
assert_eq!(layer, [a, ids[1]]);
|
||
assert_eq!(st.doc.node(ids[1]).unwrap().name.as_deref(), Some("Tri"));
|
||
assert_eq!(st.selection.objects, ids);
|
||
// A generator with nothing selected adds to the current layer.
|
||
let generated = json!({"objects": [{"path": triangle()}]});
|
||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.generate", "filter", "{}"), &generated.to_string()));
|
||
run(&mut s, "select.none", json!({}));
|
||
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.generate"}));
|
||
let made = NodeId(r["ids"][0].as_u64().unwrap());
|
||
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap().id, made);
|
||
}
|
||
|
||
/// A generator can make guides and lock what it adds, in its one undo step; those objects are left
|
||
/// out of the selection that follows, as Make Guides and Object › Lock leave them.
|
||
#[test]
|
||
fn filters_make_guides_and_locked_objects_left_unselected() {
|
||
let mut s = session();
|
||
let out = json!({"objects": [
|
||
{"path": triangle(), "name": "Guide", "guide": true},
|
||
{"path": triangle(), "name": "Locked", "locked": true},
|
||
{"path": triangle(), "name": "Plain"}]});
|
||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.guides", "filter", "{}"), &out.to_string()));
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.guides"}));
|
||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||
assert!(matches!(node(&s, ids[0]).kind, vectorcraft_doc::NodeKind::Path { guide: true, .. }));
|
||
assert!(node(&s, ids[1]).locked);
|
||
let st = s.doc().unwrap();
|
||
assert_eq!(st.selection.objects, [ids[2]]);
|
||
assert_eq!(st.history.undo.len(), undo + 1);
|
||
}
|
||
|
||
#[test]
|
||
fn failing_plugins_leave_the_document_and_history_alone() {
|
||
let mut s = session();
|
||
rect(&mut s, 10.0, "#ff0000");
|
||
run(&mut s, "select.all", json!({}));
|
||
let man = |id: &str| tpl::manifest(id, "filter", "{}");
|
||
install_wat(&mut s, &tpl::module(&man("test.engine.trap"), "unreachable", "", 1));
|
||
install_wat(&mut s, &tpl::returning(&man("test.engine.far"), r#"{"objects":[{"path":{"subpaths":[{"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#));
|
||
install_wat(&mut s, &tpl::returning(&man("test.engine.declines"), r#"{"error":"Select two paths."}"#));
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
for id in ["test.engine.trap", "test.engine.far", "test.engine.declines", "test.engine.missing"] {
|
||
let e = s.execute("plugin.run", &json!({"id": id})).unwrap_err();
|
||
if id == "test.engine.declines" {
|
||
assert!(e.to_string().contains("Select two paths."), "{e}");
|
||
}
|
||
assert_eq!(s.doc().unwrap().doc, before, "{id}");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{id}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn live_effect_plugins_reshape_editable_art_and_expand() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0, "#ff0000");
|
||
// An effect that draws a triangle, and one that hands the geometry back.
|
||
let tri = json!({"objects": [{"path": triangle()}]});
|
||
install_wat(
|
||
&mut s,
|
||
&tpl::returning(&tpl::manifest("test.engine.tri", "effect", r#"{"size":{"type":"number","min":0,"max":10,"default":3}}"#), &tri.to_string()),
|
||
);
|
||
install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.echo", "effect", "{}"), tpl::ECHO, "", 1));
|
||
let catalog = run(&mut s, "effect.list", json!({}))["catalog"].clone();
|
||
let entry = catalog.as_array().unwrap().iter().find(|e| e["id"] == "plugin.test.engine.tri").expect("listed");
|
||
assert_eq!((entry["label"].as_str(), entry["menu"].clone()), (Some("Test test.engine.tri…"), json!(["Effect", "Plug-ins"])));
|
||
assert_eq!(entry["defaults"], json!({"size": 3.0}));
|
||
// A filter can't be applied as an effect, nor an effect run as a filter.
|
||
install(&mut s, DESATURATE);
|
||
assert!(s.execute("effect.apply", &json!({"ids": [a.0], "effect": format!("plugin.{DESATURATE_ID}")})).is_err());
|
||
assert!(s.execute("plugin.run", &json!({"id": "test.engine.tri"})).is_err());
|
||
|
||
let effected = |s: &Session| {
|
||
let n = node(s, a);
|
||
let path = n.path_data().unwrap().clone();
|
||
let b = path.bounds().unwrap();
|
||
vectorcraft_render::effects::apply_geometry(&n.appearance.effects, &path, b)
|
||
};
|
||
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.echo"}));
|
||
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap(), "identity");
|
||
// Re-evaluated when the object changes (non-destructive).
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "object.move", json!({"dx": 50, "dy": 0}));
|
||
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
|
||
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}}));
|
||
assert_eq!(effected(&s), triangle());
|
||
assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4}));
|
||
// Uninstalled: the geometry is drawn as it is.
|
||
run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"}));
|
||
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
|
||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.tri", "effect", "{}"), &tri.to_string()));
|
||
// Expand Appearance bakes it.
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let mut baked = None;
|
||
s.doc().unwrap().doc.walk(|n| {
|
||
if let Some(p) = n.path_data() {
|
||
baked = Some(p.clone());
|
||
}
|
||
});
|
||
assert_eq!(baked.unwrap().bounds(), triangle().bounds());
|
||
}
|
||
|
||
/// A live effect is given its object's fills and strokes, read only, and which of them it sits on;
|
||
/// a change of paint runs it again.
|
||
#[test]
|
||
fn live_effects_are_given_their_object_s_paints() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 10.0, "#ff0000");
|
||
run(&mut s, "paint.setStroke", json!({"ids": [id.0], "color": "#000000"}));
|
||
run(&mut s, "stroke.set", json!({"ids": [id.0], "weight": 5}));
|
||
// The red fill as the host writes paints: a JSON value, its keys sorted.
|
||
let red = serde_json::to_value(&node(&s, id).appearance.fill().unwrap().paint).unwrap().to_string();
|
||
let tri = json!({"objects": [{"path": triangle()}]}).to_string();
|
||
// Each draws the triangle while it is given what it looks for, else hands the geometry back.
|
||
let effect = |id: &str, given, needle: &str| tpl::when_given(&tpl::manifest(id, "effect", "{}"), given, needle, &tri);
|
||
install_wat(&mut s, &effect("test.engine.wide", tpl::Given::Input, r#""width":5.0"#));
|
||
install_wat(&mut s, &effect("test.engine.red", tpl::Given::Input, &format!(r#""fills":[{red}]"#)));
|
||
install_wat(&mut s, &effect("test.engine.second", tpl::Given::Params, r#""item":{"stroke":1}"#));
|
||
let effected = |s: &Session, item: Option<usize>| {
|
||
let n = node(s, id);
|
||
let path = n.path_data().unwrap().clone();
|
||
let ctx = vectorcraft_render::effects::GeomContext::of(&n);
|
||
let (effects, ctx) = match item {
|
||
Some(i) => match &n.appearance.items[i] {
|
||
vectorcraft_doc::AppearanceItem::Stroke(st) => (st.effects.clone(), ctx.item(&n.appearance.items[i])),
|
||
other => panic!("not a stroke: {other:?}"),
|
||
},
|
||
None => (n.appearance.effects.clone(), ctx),
|
||
};
|
||
vectorcraft_render::effects::apply_geometry_with(&effects, &path, path.bounds().unwrap(), &ctx)
|
||
};
|
||
let own = node(&s, id).path_data().unwrap().clone();
|
||
let remove = |s: &mut Session| run(s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": null}));
|
||
for effect in ["plugin.test.engine.wide", "plugin.test.engine.red"] {
|
||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": null}));
|
||
assert_eq!(effected(&s, None), triangle(), "{effect}: given the 5 pt stroke and the red fill");
|
||
remove(&mut s);
|
||
}
|
||
// Run again once the stroke is thinner.
|
||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.wide", "item": null}));
|
||
run(&mut s, "stroke.set", json!({"ids": [id.0], "weight": 3, "item": 1}));
|
||
assert_eq!(effected(&s, None), own, "a 3 pt stroke");
|
||
remove(&mut s);
|
||
// On the second of two strokes, and not on the first.
|
||
run(&mut s, "appearance.addStroke", json!({"ids": [id.0]}));
|
||
let top = node(&s, id).appearance.items.len() - 1;
|
||
for item in [1, top] {
|
||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.second", "item": item}));
|
||
}
|
||
assert_eq!(effected(&s, Some(1)), own, "the first stroke");
|
||
assert_eq!(effected(&s, Some(top)), triangle(), "the second stroke");
|
||
}
|
||
|
||
/// Expand gives a plug-in effect on a fill the context drawing gives it (`_context.item`), so a
|
||
/// gradient fill is expanded into the shape the canvas fills.
|
||
#[test]
|
||
fn expanding_a_fill_gives_its_plug_in_effect_the_fill_it_sits_on() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 10.0, "#ff0000");
|
||
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear"}}));
|
||
let tri = json!({"objects": [{"path": triangle()}]}).to_string();
|
||
let on_fill = tpl::when_given(&tpl::manifest("test.engine.onfill", "effect", "{}"), tpl::Given::Params, r#""item":{"fill":0}"#, &tri);
|
||
install_wat(&mut s, &on_fill);
|
||
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
|
||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onfill", "item": fill}));
|
||
// Drawn, the gradient fills the triangle: a point of it outside the rectangle is painted.
|
||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||
let drawn = vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 40, 40, vectorcraft_geom::Affine::IDENTITY, &opts);
|
||
assert_ne!(drawn.pixel(15, 1), [255, 255, 255, 255], "drawn as a triangle");
|
||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||
run(&mut s, "object.expand", json!({"steps": 2}));
|
||
// The gradient's strips sit in a clip group whose clipping path is the shape filled.
|
||
let group = s.doc().unwrap().selection.objects[0];
|
||
let clip = node(&s, group).children().unwrap()[0].clone();
|
||
assert_eq!(clip.path_data().unwrap().bounds(), triangle().bounds(), "the triangle the canvas fills");
|
||
}
|
||
|
||
/// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app
|
||
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
|
||
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
|
||
#[test]
|
||
fn stroke_width_test_effect() {
|
||
let Some(dir) = std::env::var_os("CRAFT_PLUGIN_PAINTS_WASM") else { return };
|
||
let triangle = PathData::single(SubPath::polyline(&[Point::new(100.0, 230.0), Point::new(300.0, 230.0), Point::new(200.0, 80.0)], true));
|
||
let manifest = r#"{"id":"org.example.strokewidth","name":"Stroke Width (test plug-in)","version":"1","kind":"effect",
|
||
"description":"Draws a triangle while the object's stroke is 5 pt wide.","params":{}}"#;
|
||
let src = tpl::when_given(manifest, tpl::Given::Input, r#""width":5.0"#, &json!({"objects": [{"path": triangle}]}).to_string());
|
||
std::fs::write(std::path::Path::new(&dir).join("stroke-width-test-effect.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||
}
|
||
|
||
/// A filter is given its objects' effect records and their strokes' join, cap and miter limit, and
|
||
/// may return them. The records are admitted as effect.apply would admit them; one that isn't
|
||
/// refuses the whole output, and nothing changes.
|
||
#[test]
|
||
fn filters_set_effects_and_stroke_styles() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 10.0, "#ff0000");
|
||
install_wat(
|
||
&mut s,
|
||
&tpl::module(&tpl::manifest("test.engine.fx", "effect", r#"{"n":{"type":"int","min":0,"max":3,"default":1}}"#), tpl::ECHO, "", 1),
|
||
);
|
||
let black = json!({"type": "solid", "color": {"model": "gray", "k": 1.0}});
|
||
let out = |fx: Value| json!({"objects": [{"id": id.0, "strokes": [{"paint": black, "width": 4, "join": "round", "cap": "square", "miterLimit": 4}], "effects": fx}]});
|
||
let filter = |s: &mut Session, name: &str, fx: Value| {
|
||
install_wat(s, &tpl::returning(&tpl::manifest(name, "filter", "{}"), &out(fx).to_string()));
|
||
s.execute("plugin.run", &json!({"id": name, "ids": [id.0]}))
|
||
};
|
||
let fx = json!([{"id": "distort.roughen", "params": {"size": 3}}, {"id": "plugin.test.engine.fx", "params": {"n": 2}, "visible": false}]);
|
||
filter(&mut s, "test.engine.setfx", fx).unwrap();
|
||
let n = node(&s, id);
|
||
let st = n.appearance.stroke().unwrap();
|
||
assert_eq!((st.width, st.join, st.cap, st.miter_limit), (4.0, vectorcraft_doc::LineJoin::Round, vectorcraft_doc::LineCap::Square, 4.0));
|
||
let effects = &n.appearance.effects;
|
||
assert_eq!(effects.iter().map(|e| (e.id.as_str(), e.visible)).collect::<Vec<_>>(), [("distort.roughen", true), ("plugin.test.engine.fx", false)]);
|
||
assert_eq!(effects[0].params, vectorcraft_render::effects::merged_params("distort.roughen", &json!({"size": 3})), "filled in");
|
||
assert_eq!(effects[1].params, json!({"n": 2}));
|
||
// Handed back unchanged (an echo), nothing changes, not even the records' parameters.
|
||
install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.same", "filter", "{}"), tpl::ECHO, "", 1));
|
||
let before = s.doc().unwrap().doc.clone();
|
||
run(&mut s, "plugin.run", json!({"id": "test.engine.same", "ids": [id.0]}));
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
// Refused: what effect.apply refuses, a live effect that isn't installed, parameters its
|
||
// manifest refuses, a texture to read from a file, two 3D effects.
|
||
for (name, fx, why) in [
|
||
("test.engine.unknown", json!([{"id": "distort.nothing"}]), "no such effect"),
|
||
("test.engine.key", json!([{"id": "distort.roughen", "params": {"bogus": 1}}]), "bogus"),
|
||
("test.engine.gone", json!([{"id": "plugin.test.engine.gone"}]), "no such effect"),
|
||
("test.engine.badn", json!([{"id": "plugin.test.engine.fx", "params": {"n": "two"}}]), "`n`"),
|
||
("test.engine.file", json!([{"id": "distort.glass", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
|
||
("test.engine.file2", json!([{"id": "texture.texturizer", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
|
||
("test.engine.two3d", json!([{"id": "threeD.extrude"}, {"id": "threeD.extrude"}]), "one 3D effect"),
|
||
] {
|
||
let e = filter(&mut s, name, fx).unwrap_err().to_string();
|
||
assert!(e.contains("objects[0].effects") && e.contains(why), "{name}: {e}");
|
||
assert_eq!(s.doc().unwrap().doc, before, "{name}");
|
||
}
|
||
}
|
||
|
||
/// `CRAFT_PLUGIN_FRAME_WASM=<folder>` saves a test filter to try in the app
|
||
/// (`rough-frame-test-filter.wasm`): it adds a frame around 60,40–340,260 with a 6 pt stroke with
|
||
/// round joins and caps, roughened by the Roughen effect.
|
||
#[test]
|
||
fn rough_frame_test_filter() {
|
||
let Some(dir) = std::env::var_os("CRAFT_PLUGIN_FRAME_WASM") else { return };
|
||
let frame = PathData::single(SubPath::polyline(
|
||
&[Point::new(60.0, 40.0), Point::new(340.0, 40.0), Point::new(340.0, 260.0), Point::new(60.0, 260.0)],
|
||
true,
|
||
));
|
||
let ink = json!({"type": "solid", "color": {"model": "rgb", "r": 0.15, "g": 0.2, "b": 0.45}});
|
||
let out = json!({"objects": [{"name": "Frame", "path": frame, "fills": [],
|
||
"strokes": [{"paint": ink, "width": 6, "join": "round", "cap": "round"}],
|
||
"effects": [{"id": "distort.roughen", "params": {"size": 2, "detail": 8, "points": "smooth"}}]}]});
|
||
let manifest = r#"{"id":"org.example.frame","name":"Rough Frame (test plug-in)","version":"1","kind":"filter",
|
||
"description":"Adds a frame whose stroke is roughened by the Roughen effect.","params":{}}"#;
|
||
let src = tpl::returning(manifest, &out.to_string());
|
||
std::fs::write(std::path::Path::new(&dir).join("rough-frame-test-filter.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||
}
|
||
|
||
/// A filter keeps data with the document: it writes it with `documentData` in the same undo step as
|
||
/// its objects (`null` removes it) and reads what it last wrote in `_context.documentData`, which
|
||
/// no other filter is given. It is the document's foreign data, so the native file keeps it with
|
||
/// no section of its own. More than the limit is refused.
|
||
#[test]
|
||
fn filters_keep_their_data_with_the_document() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 10.0, "#ff0000");
|
||
let pid = "test.engine.data";
|
||
let returning = |out: Value| tpl::returning(&tpl::manifest(pid, "filter", "{}"), &out.to_string());
|
||
let keep = |data: Value| json!({"objects": [{"id": id.0}], "documentData": data});
|
||
let data = |s: &Session| s.doc().unwrap().doc.plugin_data(pid).cloned();
|
||
let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]}));
|
||
install_wat(&mut s, &returning(keep(json!({"pen": 2}))));
|
||
filter(&mut s, pid).unwrap();
|
||
assert_eq!(data(&s), Some(json!({"pen": 2})));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(data(&s), None, "one undo step with the objects");
|
||
run(&mut s, "edit.redo", json!({}));
|
||
// Read back: these name the object only when they are given what they look for.
|
||
let named = |name: &str| json!({"objects": [{"id": id.0, "name": name}]}).to_string();
|
||
let when_given =
|
||
|pid: &str, needle: &str, name: &str| tpl::when_given(&tpl::manifest(pid, "filter", "{}"), tpl::Given::Params, needle, &named(name));
|
||
install_wat(&mut s, &when_given(pid, r#""documentData":{"pen":2}"#, "Pen 2"));
|
||
filter(&mut s, pid).unwrap();
|
||
assert_eq!(node(&s, id).name.as_deref(), Some("Pen 2"));
|
||
// Another filter is given nothing: what a filter keeps is its own.
|
||
let other = "test.engine.other";
|
||
install_wat(&mut s, &when_given(other, r#""documentData":null"#, "Nothing kept"));
|
||
filter(&mut s, other).unwrap();
|
||
assert_eq!(node(&s, id).name.as_deref(), Some("Nothing kept"));
|
||
// Saved with the document, among its foreign data.
|
||
let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
assert_eq!(saved.plugin_data(pid), Some(&json!({"pen": 2})));
|
||
assert_eq!(saved.unknown.get(vectorcraft_doc::plugin_data::PLUGIN_DATA), Some(&json!({pid: {"pen": 2}})));
|
||
// Too much is refused, and the document stays as it was; null removes the data.
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let big = "x".repeat(vectorcraft_plugins::objects::MAX_DOCUMENT_DATA);
|
||
install_wat(&mut s, &returning(keep(json!(big))));
|
||
let e = filter(&mut s, pid).unwrap_err().to_string();
|
||
assert!(e.contains("documentData"), "{e}");
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
install_wat(&mut s, &returning(keep(Value::Null)));
|
||
filter(&mut s, pid).unwrap();
|
||
assert_eq!(data(&s), None);
|
||
}
|
||
|
||
#[test]
|
||
fn plugin_management_commands() {
|
||
let mut s = session();
|
||
install(&mut s, DESATURATE);
|
||
let list = run(&mut s, "plugin.list", json!({}));
|
||
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == DESATURATE_ID));
|
||
let info = run(&mut s, "plugin.info", json!({"id": DESATURATE_ID}));
|
||
assert_eq!(info["defaults"], json!({"amount": 100.0}));
|
||
assert_eq!(info["params"], "{amount: 0..100 (100)}");
|
||
assert_eq!(info["manifest"]["kind"], "filter");
|
||
// Refused installs.
|
||
for bad in [
|
||
json!({}),
|
||
json!({"dataBase64": "!!"}),
|
||
json!({"dataBase64": "AGFzbQ=="}),
|
||
json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE), "replace": false}),
|
||
] {
|
||
assert!(s.execute("plugin.install", &bad).is_err(), "{bad}");
|
||
}
|
||
assert!(s.execute("plugin.info", &json!({"id": "test.engine.nothing"})).is_err());
|
||
assert!(s.execute("plugin.remove", &json!({"id": "test.engine.nothing"})).is_err());
|
||
assert!(s.execute("plugin.reload", &json!({})).is_err(), "no folder set");
|
||
|
||
// The Additional Plug-ins Folder preference installs what's in it.
|
||
let dir = std::env::temp_dir().join(format!("vectorcraft-engine-plugins-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let src = tpl::returning(&tpl::manifest("test.engine.folder", "filter", "{}"), r#"{"objects":[]}"#);
|
||
std::fs::write(dir.join("folder.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||
std::fs::write(dir.join("broken.wasm"), b"\0asm?").unwrap();
|
||
run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": dir.display().to_string()}));
|
||
assert!(vectorcraft_plugins::registry::get("test.engine.folder").is_some());
|
||
run(&mut s, "plugin.remove", json!({"id": "test.engine.folder"}));
|
||
let r = run(&mut s, "plugin.reload", json!({}));
|
||
assert_eq!(r["loaded"], json!(["test.engine.folder"]));
|
||
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
|
||
assert_eq!(run(&mut s, "plugin.list", json!({}))["folder"]["loaded"], json!(["test.engine.folder"]));
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
/// #2541: a plug-in installed in a session with a plug-in store is installed again in the next
|
||
/// session that attaches the store, and Remove Plug-in takes it out of the store.
|
||
#[test]
|
||
fn installed_plugins_are_kept_in_the_store() {
|
||
let folder = crate::tests_links::Folder::new("plugin-store");
|
||
let id = "test.engine.stored";
|
||
let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#);
|
||
let mut s = session();
|
||
s.attach_plugin_store(folder.0.clone());
|
||
install_wat(&mut s, &src);
|
||
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "one file in the store");
|
||
vectorcraft_plugins::registry::remove(id);
|
||
|
||
let mut next = session();
|
||
next.attach_plugin_store(folder.0.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store");
|
||
run(&mut next, "plugin.remove", json!({"id": id}));
|
||
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 0, "removed from the store");
|
||
|
||
// A file that isn't a plug-in is skipped; the store still attaches.
|
||
std::fs::write(folder.0.join("broken.wasm"), b"\0asm?").unwrap();
|
||
let mut third = session();
|
||
third.attach_plugin_store(folder.0.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||
}
|
||
|
||
/// A heavy test filter `id` that counts `steps` down, then adds a triangle named "Plug-in".
|
||
fn heavy(id: &str, steps: u64) -> Vec<u8> {
|
||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#);
|
||
let output = json!({"objects": [{"path": triangle(), "name": "Plug-in"}]});
|
||
wat::parse_str(tpl::spinning(&manifest, steps, &output.to_string())).unwrap()
|
||
}
|
||
|
||
/// A filter that asks for a heavy budget installs only once the user agrees (`allowHeavy`);
|
||
/// `plugin.inspect` tells whoever installs it so beforehand, and installs nothing.
|
||
#[test]
|
||
fn a_heavy_filter_installs_once_the_user_agrees() {
|
||
let mut s = session();
|
||
let id = "test.engine.heavy";
|
||
let data = vectorcraft_format::base64_encode(&heavy(id, 1));
|
||
// With the file's name, as the app passes it.
|
||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data, "name": "route.wasm"}));
|
||
assert_eq!((inspected["id"].as_str(), inspected["budget"].as_str()), (Some(id), Some("heavy")));
|
||
assert_eq!(inspected["source"], "route.wasm");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing");
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
|
||
assert!(e.contains("heavy budget") && e.contains("5 minutes") && e.contains("allowHeavy"), "{e}");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||
let info = run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
|
||
assert_eq!(info["budget"], "heavy");
|
||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL);
|
||
let listed = run(&mut s, "plugin.list", json!({}));
|
||
assert!(listed["plugins"].as_array().unwrap().iter().any(|p| p["id"] == id && p["budget"] == "heavy"));
|
||
// A light plug-in's entry says nothing of budgets.
|
||
assert!(install(&mut s, DESATURATE).get("budget").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_install_flags_are_refused() {
|
||
let mut s = session();
|
||
let id = "test.engine.heavy.flags";
|
||
let data = vectorcraft_format::base64_encode(&heavy(id, 1));
|
||
for allow in [json!("true"), json!(1)] {
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": allow})).unwrap_err().to_string();
|
||
assert!(e.contains("`allowHeavy` must be true or false"), "allowHeavy {allow}: {e}");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with allowHeavy {allow}");
|
||
}
|
||
// Null, as for every command, is the same as leaving the flag out.
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": null})).unwrap_err().to_string();
|
||
assert!(e.contains("heavy budget"), "{e}");
|
||
run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
|
||
for replace in [json!("false"), json!(0)] {
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": true, "replace": replace})).unwrap_err().to_string();
|
||
assert!(e.contains("`replace` must be true or false"), "replace {replace}: {e}");
|
||
}
|
||
run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true, "replace": null}));
|
||
assert!(vectorcraft_plugins::registry::get(id).is_some());
|
||
// A plug-in with the usual budget is refused too.
|
||
let light = "test.engine.light.flags";
|
||
let manifest = format!(r#"{{"id":"{light}","name":"Test {light}","version":"1","kind":"filter"}}"#);
|
||
let output = json!({"objects": [{"path": triangle(), "name": "Plug-in"}]});
|
||
let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::spinning(&manifest, 1, &output.to_string())).unwrap());
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": "false"})).unwrap_err().to_string();
|
||
assert!(e.contains("`allowHeavy` must be true or false"), "{e}");
|
||
assert!(vectorcraft_plugins::registry::get(light).is_none());
|
||
}
|
||
|
||
/// A test filter `id` (named "Test `id`") whose manifest lists `permissions` (a JSON list) and that
|
||
/// returns `output`.
|
||
fn asking(id: &str, permissions: &str, output: &Value) -> Vec<u8> {
|
||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"2.1","kind":"filter","permissions":{permissions}}}"#);
|
||
wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap()
|
||
}
|
||
|
||
/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`),
|
||
/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's
|
||
/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same
|
||
/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the
|
||
/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies.
|
||
#[test]
|
||
fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
|
||
let mut s = session();
|
||
let id = "test.engine.permissions.install";
|
||
let empty = json!({"objects": []});
|
||
let data = |perms: &str| vectorcraft_format::base64_encode(&asking(id, perms, &empty));
|
||
let reads = data(r#"["doc.read"]"#);
|
||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": reads}));
|
||
assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read"])));
|
||
let hash = vectorcraft_plugins::permissions::sha256_hex(&asking(id, r#"["doc.read"]"#, &empty));
|
||
assert_eq!(inspected["hash"], hash);
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing");
|
||
// Refused without a grant that holds every permission it lists, naming them.
|
||
for (grant, says) in [
|
||
(Value::Null, "asks for the permissions doc.read"),
|
||
(json!([]), "asks for the permissions doc.read"),
|
||
(json!(["doc.write"]), "asks for the permissions doc.read"),
|
||
(json!(["doc.read", "net.fetch"]), "unknown permission `net.fetch`"),
|
||
(json!("doc.read"), "`grant` must be a list"),
|
||
(json!([1]), "`grant` must be a list"),
|
||
] {
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": grant})).unwrap_err().to_string();
|
||
assert!(e.contains(says), "grant {grant}: {e}");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with grant {grant}");
|
||
}
|
||
// `hash` pins the module the user was shown.
|
||
for wrong in [json!("0".repeat(64)), json!(1)] {
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": ["doc.read"], "hash": wrong})).unwrap_err().to_string();
|
||
assert!(e.contains("SHA-256 `hash`"), "hash {wrong}: {e}");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with hash {wrong}");
|
||
}
|
||
let installed = run(&mut s, "plugin.install", json!({"dataBase64": reads, "grant": ["doc.read"], "hash": hash}));
|
||
assert_eq!(installed["permissions"], json!(["doc.read"]));
|
||
let g = vectorcraft_plugins::registry::granted(id).unwrap();
|
||
assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..]));
|
||
// Replaced with another module that lists the same permissions: no new grant needed, and the
|
||
// grant now records the new module's hash.
|
||
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false);
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}));
|
||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other));
|
||
// One that asks for more is refused until it is granted all of them.
|
||
let more = data(r#"["doc.read","doc.write"]"#);
|
||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": more}))["needsApproval"], true);
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": more})).unwrap_err().to_string();
|
||
assert!(e.contains("doc.read, doc.write") && e.contains("`grant`"), "{e}");
|
||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().manifest().permissions().len(), 1, "the one installed stays");
|
||
run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"]}));
|
||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().permissions.len(), 2, "only what it lists is recorded");
|
||
// Removed, it is asked again.
|
||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||
assert!(vectorcraft_plugins::registry::granted(id).is_none());
|
||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsApproval"], true);
|
||
// A plug-in without `permissions` installs as before, with its kind's.
|
||
let plain = install(&mut s, DESATURATE);
|
||
assert_eq!(plain["permissions"], json!(["doc.read", "doc.write", "state"]));
|
||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false);
|
||
}
|
||
|
||
/// The host enforces what a filter was granted: without `doc.write` an output that changes the
|
||
/// document is refused and nothing changes, without `doc.read` it is given no objects, and without
|
||
/// `state` it is given no data kept with the document and may not write any.
|
||
#[test]
|
||
fn a_filter_does_only_what_its_permissions_allow() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 10.0, "#ff0000");
|
||
let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]}));
|
||
let grant = |s: &mut Session, wasm: &[u8], perms: Value| {
|
||
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms}));
|
||
};
|
||
// Denied `doc.write`: renaming the object is refused, and the document and history stay.
|
||
let reader = "test.engine.permissions.reader";
|
||
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0, "name": "Changed"}]})), json!(["doc.read"]));
|
||
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||
let e = filter(&mut s, reader).unwrap_err().to_string();
|
||
assert!(e.contains("wasn't granted `doc.write`"), "{e}");
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||
// Deleting it (leaving it out of the output) and adding objects are changes too.
|
||
for out in [json!({"objects": []}), json!({"objects": [{"id": id.0}, {"path": triangle()}]})] {
|
||
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &out), json!(["doc.read"]));
|
||
assert!(filter(&mut s, reader).is_err(), "{out}");
|
||
assert_eq!(s.doc().unwrap().doc, before, "{out}");
|
||
}
|
||
// Returning the object unchanged changes nothing, so it may.
|
||
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0}]})), json!(["doc.read"]));
|
||
filter(&mut s, reader).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.layers, before.layers);
|
||
// Granted `doc.write` alone: it is given no objects, so its triangle is added beside the
|
||
// rectangle rather than replacing it.
|
||
let writer = "test.engine.permissions.writer";
|
||
let manifest = format!(r#"{{"id":"{writer}","name":"W","kind":"filter","permissions":["doc.write"]}}"#);
|
||
let added = json!({"objects": [{"path": triangle(), "name": "Given nothing"}]});
|
||
let src = tpl::when_given(&manifest, tpl::Given::Input, r#"{"objects":[]}"#, &added.to_string());
|
||
grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.write"]));
|
||
let made = filter(&mut s, writer).unwrap();
|
||
let new = NodeId(made["ids"][0].as_u64().unwrap());
|
||
assert_eq!(node(&s, new).name.as_deref(), Some("Given nothing"));
|
||
assert!(s.doc().unwrap().doc.node(id).is_some(), "the rectangle it wasn't given stays");
|
||
// Without `state`: kept data is not handed over, and writing some is refused.
|
||
run(
|
||
&mut s,
|
||
"plugin.install",
|
||
json!({"dataBase64": vectorcraft_format::base64_encode(&asking("test.engine.permissions.keeper", "null", &json!({"objects": [{"id": id.0}], "documentData": {"pen": 1}})))}),
|
||
);
|
||
filter(&mut s, "test.engine.permissions.keeper").unwrap();
|
||
let stateless = "test.engine.permissions.keeper";
|
||
let manifest = format!(r#"{{"id":"{stateless}","name":"K","kind":"filter","permissions":["doc.read","doc.write"]}}"#);
|
||
let src =
|
||
tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":null"#, &json!({"objects": [{"id": id.0, "name": "No data"}]}).to_string());
|
||
grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.read", "doc.write"]));
|
||
filter(&mut s, stateless).unwrap();
|
||
assert_eq!(node(&s, id).name.as_deref(), Some("No data"), "given no kept data");
|
||
let before = s.doc().unwrap().doc.clone();
|
||
grant(
|
||
&mut s,
|
||
&asking(stateless, r#"["doc.read","doc.write"]"#, &json!({"objects": [{"id": id.0}], "documentData": {"pen": 2}})),
|
||
json!(["doc.read", "doc.write"]),
|
||
);
|
||
let e = filter(&mut s, stateless).unwrap_err().to_string();
|
||
assert!(e.contains("wasn't granted `state`"), "{e}");
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1})));
|
||
}
|
||
|
||
/// What the user granted is kept in the plug-in store with the module: the next session installs
|
||
/// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is
|
||
/// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder
|
||
/// until it is granted on its own.
|
||
#[test]
|
||
fn granted_permissions_are_kept_and_folders_skip_the_rest() {
|
||
let folder = crate::tests_links::Folder::new("plugin-store-grants");
|
||
let id = "test.engine.permissions.stored";
|
||
let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||
let mut s = session();
|
||
s.attach_plugin_store(folder.0.clone());
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||
assert!(folder.0.join("grants.json").is_file());
|
||
vectorcraft_plugins::registry::remove(id);
|
||
vectorcraft_plugins::registry::revoke(id);
|
||
let mut next = session();
|
||
next.attach_plugin_store(folder.0.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store, as granted");
|
||
// Only a store's own grants count: the same module in another store without a grant is
|
||
// skipped, and a grant given elsewhere is not written to that store.
|
||
let other = crate::tests_links::Folder::new("plugin-store-grants-other");
|
||
std::fs::write(other.0.join("copy.wasm"), &wasm).unwrap();
|
||
vectorcraft_plugins::registry::remove(id);
|
||
let mut elsewhere = session();
|
||
elsewhere.attach_plugin_store(other.0.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "granted in another store only");
|
||
let e = elsewhere.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap_err().to_string();
|
||
assert!(e.contains("`grant`"), "a grant kept in another store doesn't count here: {e}");
|
||
let lone = "test.engine.permissions.lone";
|
||
let lone_wasm = asking(lone, r#"["doc.read"]"#, &json!({"objects": []}));
|
||
run(&mut elsewhere, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&lone_wasm), "grant": ["doc.read"]}));
|
||
let kept = std::fs::read_to_string(other.0.join("grants.json")).unwrap();
|
||
assert!(kept.contains(lone) && !kept.contains(id), "{kept}");
|
||
run(&mut elsewhere, "plugin.remove", json!({"id": lone}));
|
||
assert!(!other.0.join("grants.json").exists(), "the last grant removed takes the file with it");
|
||
// Without the grant it is skipped, and the file stays.
|
||
vectorcraft_plugins::registry::remove(id);
|
||
vectorcraft_plugins::registry::revoke(id);
|
||
std::fs::remove_file(folder.0.join("grants.json")).unwrap();
|
||
let mut third = session();
|
||
third.attach_plugin_store(folder.0.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "nobody granted it");
|
||
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "the module stays in the store");
|
||
|
||
// The Additional Plug-ins Folder skips it with a note until it is granted.
|
||
let dir = crate::tests_links::Folder::new("plugin-folder-grants");
|
||
std::fs::write(dir.0.join("asks.wasm"), &wasm).unwrap();
|
||
let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()}));
|
||
assert_eq!(r["loaded"], json!([]));
|
||
let why = r["failed"][0]["error"].as_str().unwrap();
|
||
assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}");
|
||
run(&mut third, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||
let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()}));
|
||
assert_eq!(r["loaded"], json!([id]));
|
||
run(&mut third, "plugin.remove", json!({"id": id}));
|
||
// With a store attached, only that store's grants load it from the folder: a grant given in a
|
||
// session without a store doesn't count.
|
||
let mut storeless = session();
|
||
run(&mut storeless, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||
vectorcraft_plugins::registry::remove(id);
|
||
assert!(vectorcraft_plugins::registry::granted(id).is_some(), "granted in the process");
|
||
let empty = crate::tests_links::Folder::new("plugin-store-grants-empty");
|
||
let mut scoped = session();
|
||
scoped.attach_plugin_store(empty.0.clone());
|
||
let r = run(&mut scoped, "plugin.reload", json!({"path": dir.0.display().to_string()}));
|
||
vectorcraft_plugins::registry::revoke(id);
|
||
assert_eq!(r["loaded"], json!([]), "a grant outside this store doesn't load it");
|
||
let why = r["failed"][0]["error"].as_str().unwrap();
|
||
assert!(why.contains("needs approval"), "{why}");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "not installed");
|
||
// The preference's folder is loaded again for a session whose store holds other grants.
|
||
let pref_dir = crate::tests_links::Folder::new("plugin-folder-pref-grants");
|
||
std::fs::write(pref_dir.0.join("asks.wasm"), &wasm).unwrap();
|
||
let pref = json!({"key": "pluginsFolder", "value": pref_dir.0.display().to_string()});
|
||
run(&mut scoped, "prefs.set", pref.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "nothing granted in this store");
|
||
let granting = crate::tests_links::Folder::new("plugin-store-grants-pref");
|
||
let mut granted = session();
|
||
granted.attach_plugin_store(granting.0.clone());
|
||
run(&mut granted, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||
vectorcraft_plugins::registry::remove(id);
|
||
vectorcraft_plugins::registry::revoke(id);
|
||
run(&mut granted, "prefs.set", pref);
|
||
assert!(vectorcraft_plugins::registry::get(id).is_some(), "loaded under this store's grant");
|
||
run(&mut granted, "plugin.remove", json!({"id": id}));
|
||
}
|
||
|
||
/// The user agreed once: a heavy filter kept in the plug-in store is installed again at the next
|
||
/// start without asking, with its budget.
|
||
#[test]
|
||
fn a_stored_heavy_filter_keeps_its_budget() {
|
||
let folder = crate::tests_links::Folder::new("plugin-store-heavy");
|
||
let id = "test.engine.storedheavy";
|
||
let mut s = session();
|
||
s.attach_plugin_store(folder.0.clone());
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
|
||
vectorcraft_plugins::registry::remove(id);
|
||
let mut next = session();
|
||
next.attach_plugin_store(folder.0.clone());
|
||
let plugin = vectorcraft_plugins::registry::get(id).expect("installed again from the store");
|
||
assert_eq!(plugin.limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL);
|
||
run(&mut next, "plugin.remove", json!({"id": id}));
|
||
}
|
||
|
||
/// The app runs a heavy filter away from the session ([`cmd::plugin::FilterRun`]) and hands its
|
||
/// output in: `plugin.run` with the same params applies it, as one undo step, without running the
|
||
/// plug-in again. Only once, and only for the same run: objects that changed since, or the plug-in
|
||
/// installed again, run the plug-in instead.
|
||
#[test]
|
||
fn a_filter_run_made_elsewhere_is_applied_by_plugin_run() {
|
||
use cmd::plugin::FilterRun;
|
||
let mut s = session();
|
||
let id = "test.engine.handin";
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
|
||
let p = json!({"id": id, "ids": []});
|
||
let named = |s: &Session, ids: &Value| s.doc().unwrap().doc.node(NodeId(ids[0].as_u64().unwrap())).unwrap().name.clone();
|
||
// What it computed, as the app hands it in; here marked, so the test sees which output applied.
|
||
let r = FilterRun::prepare(&s, &p).unwrap();
|
||
assert!(r.current(&s));
|
||
let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Plug-in", "Handed in");
|
||
r.hand_in(&mut s, output.clone().into_bytes());
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let made = run(&mut s, "plugin.run", p.clone())["ids"].clone();
|
||
assert_eq!(named(&s, &made).as_deref(), Some("Handed in"));
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||
// Once only: the next run runs the plug-in.
|
||
let made = run(&mut s, "plugin.run", p.clone())["ids"].clone();
|
||
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"));
|
||
// Made for objects that changed since, it isn't theirs.
|
||
let obj = rect(&mut s, 10.0, "#ff0000");
|
||
let on = json!({"id": id, "ids": [obj.0]});
|
||
let stale = FilterRun::prepare(&s, &on).unwrap();
|
||
run(&mut s, "paint.setFill", json!({"ids": [obj.0], "color": "#00ff00"}));
|
||
assert!(!stale.current(&s), "the document changed");
|
||
stale.hand_in(&mut s, output.clone().into_bytes());
|
||
let made = run(&mut s, "plugin.run", on)["ids"].clone();
|
||
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made for objects that changed since");
|
||
// A plug-in installed again is another one.
|
||
let again = FilterRun::prepare(&s, &p).unwrap();
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
|
||
assert!(!again.current(&s));
|
||
again.hand_in(&mut s, output.into_bytes());
|
||
let made = run(&mut s, "plugin.run", p)["ids"].clone();
|
||
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made by the plug-in installed before");
|
||
}
|
||
|
||
/// A heavy filter run away from the session reads what it keeps with the document
|
||
/// (`_context.documentData`) and hands its `documentData` back with its output: `plugin.run`
|
||
/// applies both as one undo step.
|
||
#[test]
|
||
fn a_filter_run_made_elsewhere_keeps_its_document_data() {
|
||
use cmd::plugin::FilterRun;
|
||
let mut s = session();
|
||
let id = "test.engine.handindata";
|
||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#);
|
||
let install = |s: &mut Session, src: String| {
|
||
let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap());
|
||
run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
|
||
};
|
||
// It first keeps the pens it found with the document.
|
||
install(&mut s, tpl::returning(&manifest, &json!({"objects": [], "documentData": {"pens": 2}}).to_string()));
|
||
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
|
||
// Given those pens, it adds one, in the background; the output handed in is marked, so the
|
||
// test sees it is the one that applied.
|
||
let next = json!({"objects": [{"path": triangle(), "name": "Pen 3"}], "documentData": {"pens": 3}}).to_string();
|
||
install(&mut s, tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":{"pens":2}"#, &next));
|
||
let p = json!({"id": id, "ids": []});
|
||
let r = FilterRun::prepare(&s, &p).unwrap();
|
||
let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Pen 3", "Handed in");
|
||
r.hand_in(&mut s, output.into_bytes());
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let made = run(&mut s, "plugin.run", p)["ids"].clone();
|
||
let st = s.doc().unwrap();
|
||
assert_eq!(st.doc.node(NodeId(made[0].as_u64().unwrap())).unwrap().name.as_deref(), Some("Handed in"));
|
||
assert_eq!(st.doc.plugin_data(id), Some(&json!({"pens": 3})));
|
||
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(s.doc().unwrap().doc.plugin_data(id), Some(&json!({"pens": 2})), "undone with its objects");
|
||
}
|
||
|
||
/// A plug-in of API level 2 that uses the host imports lists `progress`, which the user grants like
|
||
/// any permission; a filter run made elsewhere then reports its progress, as a share, to the
|
||
/// progress the caller shows, and sees the caller's Cancel.
|
||
#[test]
|
||
fn a_level_2_filter_installs_with_progress_granted_and_reports_to_the_caller() {
|
||
use crate::cmd::fileio::progress::{self, Progress};
|
||
use crate::cmd::plugin::FilterRun;
|
||
let mut s = session();
|
||
let id = "test.engine.level2";
|
||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}}"#);
|
||
let wasm = wat::parse_str(tpl::cooperating(&manifest, 1000, r#"{"objects":[]}"#, r#"{"objects":[]}"#)).unwrap();
|
||
let data = vectorcraft_format::base64_encode(&wasm);
|
||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data}));
|
||
assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read", "doc.write", "progress"])));
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read", "doc.write"]})).unwrap_err().to_string();
|
||
assert!(e.contains("asks for the permissions doc.read, doc.write, progress"), "{e}");
|
||
run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write", "progress"]}));
|
||
let shown = Progress::new();
|
||
let r = FilterRun::prepare(&s, &json!({"id": id})).unwrap();
|
||
assert!(progress::scope(&shown, || r.run()).is_ok());
|
||
assert_eq!((shown.counts(), shown.counts_share()), ((999, 1000), true));
|
||
// Cancelled, it stops on its first step, and what it returns then is discarded.
|
||
shown.cancel();
|
||
let cancelled = progress::scope(&shown, || r.run());
|
||
assert!(matches!(cancelled, Err(EngineError::Cancelled)), "{cancelled:?}");
|
||
assert_eq!(shown.counts(), (0, 1000));
|
||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||
}
|
||
|
||
/// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between
|
||
/// two slices of its budget and changes nothing.
|
||
#[test]
|
||
fn a_cancelled_filter_run_changes_nothing() {
|
||
let mut s = session();
|
||
let id = "test.engine.cancelled";
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1 << 40)), "allowHeavy": true}));
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||
token.cancel();
|
||
let r = vectorcraft_doc::cancel::scope(&token, None, || s.execute("plugin.run", &json!({"id": id})));
|
||
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
}
|