mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
- `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, `--flag=DIR` works too). When either is given, every file an agent's tool call reads must lie inside a read root or a write root, and every file it writes inside a write root. Anything else is an ordinary tool error (`automation path rejected: … is outside the folders that may be written: …`); nothing is written and the server keeps serving. Without the flags nothing changes.
- The desktop app takes the same flags (`figcraft --control PORT --automation-read-root DIR …`) and confines every control request and every frame of every window to them, so `figcraft-cli mcp --attach` drives a confined app. `--attach` together with the flags is a usage error, because the app runs the commands and only the app can confine them.
- New engine module `crates/engine/src/file_access.rs`: `Roots` (resolution and comparison), a thread-scoped `confined(roots, f)`, and the file primitives (`read`, `read_capped`, `open`, `metadata`, `read_dir`, `write` (atomic), `create_dir_all`, `exists`, `is_dir`). Every engine file access now goes through it. `crates/engine/clippy.toml` denies `std::fs::*`, `File::open/create`, `OpenOptions::open`, `Path::exists/is_file/is_dir/metadata/read_dir/canonicalize` and `figcraft_format::{write_file_atomic, save_file, load_file}` everywhere else in the engine, so a command added later is confined without a check of its own.
- `ui.screenshot` and `ui.render` return the PNG as base64 `data` when no `path` is given (before, `ui.screenshot` wrote `figcraft-screenshot.png` in the working directory and `ui.render` required a path). The attached MCP backend uses that, so it no longer writes and deletes temporary PNGs, and a confined app needs no exemption for them.
- `initialize`'s `instructions` name the folders the agent may read and write.
- `figcraft-cli` options may be given more than once (`Args::values`); single-valued options keep the last value, as before.
- Docs: `docs/mcp.md` › Confining file access (options, semantics, coverage, resolution, limits), `docs/control-protocol.md` › Confining file access and the optional `path` of `ui.screenshot` / `ui.render`, `docs/cli.md`, `figcraft-cli --help`, and the Automation line plus a revision row in `docs/target-app-parity.md`.
Fixes #715, fixes #481.
85 lines
4.6 KiB
Rust
85 lines
4.6 KiB
Rust
//! The component playground: a component tried with other property values without placing it or
|
|
//! touching the document. [`build`] makes a copy of the document holding one extra instance with
|
|
//! the values; what shows it (the Inspect panel, `component.playground`) renders that instance and
|
|
//! generates its code.
|
|
|
|
use figcraft_doc::{Document, ExportFormat, NodeId, NodeKind};
|
|
use serde_json::{Map, Value, json};
|
|
|
|
use crate::cmd::CommandSpec;
|
|
use crate::codegen::CodeLang;
|
|
use crate::params::*;
|
|
use crate::{EngineError, Result, Session, bad, spec};
|
|
|
|
/// The component a playground for `target` shows: an instance's component, a component or a
|
|
/// component set itself. None for other layers.
|
|
pub fn component_of(doc: &Document, target: NodeId) -> Option<NodeId> {
|
|
match &doc.node(target)?.kind {
|
|
NodeKind::Instance { component, .. } => Some(*component),
|
|
NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => Some(target),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// A copy of `doc` with a new instance of `target`'s component (an instance starts from its own
|
|
/// property values) set to `values` (property name → value, as `instance.setProperties` takes
|
|
/// them), and that instance's id. `doc` itself is untouched.
|
|
pub fn build(doc: &Document, target: NodeId, values: &Map<String, Value>) -> Result<(Document, NodeId)> {
|
|
let cmd = "component.playground";
|
|
let comp = component_of(doc, target).ok_or_else(|| bad(cmd, "select an instance, a component or a component set"))?;
|
|
let mut s = Session::with_document(doc.clone());
|
|
// Off to the side of everything (the copy is never shown as a page).
|
|
let made = s.execute("instance.create", &json!({"component": comp, "x": 1e6, "y": 1e6}))?;
|
|
let inst = id_of(&made["id"]).ok_or_else(|| EngineError::Other("the playground instance wasn't made".into()))?;
|
|
let mut all = match &doc.node(target).map(|n| &n.kind) {
|
|
Some(NodeKind::Instance { props, .. }) => props.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
|
|
_ => Map::new(),
|
|
};
|
|
all.extend(values.clone());
|
|
if !all.is_empty() {
|
|
s.execute("instance.setProperties", &json!({"ids": [inst], "properties": all})).map_err(|e| bad(cmd, e.to_string()))?;
|
|
}
|
|
Ok((s.doc()?.clone(), inst))
|
|
}
|
|
|
|
/// The properties of the playground's instance `inst` in `copy` (as `component.properties` lists
|
|
/// an instance's): `{component, set, properties: [{name, type, options?, value}]}`.
|
|
pub fn properties(copy: &Document, inst: NodeId) -> Value {
|
|
crate::cmd::component::describe_props(copy, inst)
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![spec!(
|
|
"component.playground",
|
|
"Component playground",
|
|
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|swiftui|compose (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
|
|
false,
|
|
|s, p| {
|
|
let cmd = "component.playground";
|
|
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance, a component or a component set"))?;
|
|
let values = match get(p, "properties") {
|
|
None | Some(Value::Null) => Map::new(),
|
|
Some(Value::Object(m)) => m.clone(),
|
|
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
|
|
};
|
|
let lang = match string(p, "language") {
|
|
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose"))?,
|
|
None => CodeLang::React,
|
|
};
|
|
let (copy, inst) = build(s.doc()?, target, &values)?;
|
|
let n = copy.get(inst)?;
|
|
let mut out = properties(©, inst);
|
|
out["language"] = json!(lang.id());
|
|
out["code"] = json!(crate::codegen::code_for(©, inst, lang));
|
|
(out["width"], out["height"]) = (json!(n.width), json!(n.height));
|
|
if let Some(path) = string(p, "path") {
|
|
let scale = num(p, "scale").filter(|k| k.is_finite()).map_or(2.0, |k| k.clamp(0.01, 16.0));
|
|
let png = crate::cmd::file::export_bytes(©, &[inst], ExportFormat::Png, scale)?;
|
|
crate::file_access::write(&path, &png)?;
|
|
out["path"] = json!(path);
|
|
}
|
|
Ok(out)
|
|
}
|
|
)]
|
|
}
|