Files
vectorcraft/crates/mcp/src/headless.rs
T
bflatasticandClaude Opus 5.5 9f875050ef Fix #832: --automation-read-root and --automation-write-root confine the MCP server's files
vectorcraft-cli mcp and vectorcraft --control take the flags PhotoCraft
takes. While roots are in force, every path a command reads (open, place,
relink, SVG image links, library and profile loads, plug-ins, preset
imports, font searches) must lie inside the read root and every path it
writes (save, export, package, Export for Screens and for Web, print to
file, captures, library saves) inside the write root. Paths are made
absolute, links are followed (a missing file's nearest folder's), and the
result is compared by whole components, ignoring case on Windows; `..`
below a missing folder, dangling links and device names are refused. A
root left out grants none of its access; without the flags nothing
changes.

The checks live in one place, vectorcraft_engine::file_access, consulted
by the engine's file helpers and the app's file services. The headless
server confines its whole process; the app confines control requests,
the frames carrying input they inject and the background saves they
start, leaving the person at the keyboard and the app's own folders
alone. The screenshot tool now has the backend write its file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:27:24 +07:00

327 lines
15 KiB
Rust

//! An in-process engine session that answers the control-channel methods itself.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_engine::file_access::{self, AutomationRoots};
use vectorcraft_engine::{Session, UiRequest, ViewInfo};
use vectorcraft_geom::Point;
use vectorcraft_render::Renderer;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, TOOL_GROUPS, ToolKey};
use crate::backend::Backend;
/// Headless backend: a [`Session`] plus a CPU [`Renderer`] for screenshots and PNG export.
pub struct Headless {
pub session: Session,
pub renderer: Renderer,
/// View state handed to tools (zoom 1, smart guides on).
pub view: ViewInfo,
/// The folders its methods may read and write (`--automation-read-root`,
/// `--automation-write-root`); `None`: anywhere.
roots: Option<Arc<AutomationRoots>>,
}
impl Default for Headless {
fn default() -> Self {
Self::new()
}
}
/// Commands the desktop app adds on top of the engine; the headless backend implements them too so
/// `run_command` behaves the same in both modes.
const HOST_COMMANDS: &[(&str, &str, &str)] = &[
("file.open", "Open…", "{path} open any readable file (see document.formats) as a new document; templates open untitled"),
(
"file.save",
"Save",
"{path?, format?, options?, svg?: {…SVG options}} = document.save: .vectorcraft, .ai (a PDF that reopens editable), .pdf, .svg or .svgz (default: the document's own path and format)",
),
("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"),
(
"file.exportForScreens",
"Export for Screens…",
"{folder?, zip?, artboards? | range? | fullDocument? | assets?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)",
),
("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"),
(
"tool.setOption",
"Tool Option",
"{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them); kept options last across tool switches",
),
];
fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
p.get(k).and_then(Value::as_str)
}
pub(crate) fn pointer_kind(k: &str) -> Option<PointerKind> {
Some(match k {
"down" => PointerKind::Down,
"drag" => PointerKind::Drag,
"up" => PointerKind::Up,
"move" => PointerKind::Move,
"doubleclick" | "dblclick" => PointerKind::DoubleClick,
_ => return None,
})
}
fn tool_key(name: &str) -> Option<ToolKey> {
Some(match name.to_ascii_lowercase().as_str() {
"enter" | "return" => ToolKey::Enter,
"esc" | "escape" => ToolKey::Escape,
"backspace" => ToolKey::Backspace,
"delete" => ToolKey::Delete,
"up" | "arrowup" => ToolKey::Up,
"down" | "arrowdown" => ToolKey::Down,
"left" | "arrowleft" => ToolKey::Left,
"right" | "arrowright" => ToolKey::Right,
"[" | "bracketleft" | "openbracket" => ToolKey::BracketLeft,
"]" | "bracketright" | "closebracket" => ToolKey::BracketRight,
"tab" => ToolKey::Tab,
k if k.len() == 1 => ToolKey::Digit(k.parse().ok()?),
_ => return None,
})
}
/// Does a shortcut like `Cmd+Shift+]` match `key` + `mods`?
fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool {
let parts: Vec<&str> = shortcut.split('+').collect();
let Some((k, ms)) = parts.split_last() else { return false };
let has = |m: &str| ms.iter().any(|x| x.eq_ignore_ascii_case(m));
k.eq_ignore_ascii_case(key)
&& has("cmd") == mods.cmd
&& has("shift") == mods.shift
&& (has("alt") || has("option")) == mods.alt
&& has("ctrl") == mods.ctrl
}
impl Headless {
/// A session with no document (the first `file.new` / `app.open` creates one).
pub fn new() -> Self {
Self { session: Session::new(), renderer: Renderer::new(), view: ViewInfo::default(), roots: None }
}
/// Confine the files every method reads and writes to `roots` ([`file_access`]).
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
self.roots = roots;
self
}
/// A session with a fresh default (Letter) document, ready to draw into.
pub fn with_document() -> Self {
let mut h = Self::new();
h.ensure_document();
h
}
/// Create a default document when none is open.
pub fn ensure_document(&mut self) {
if self.session.active().is_none()
&& let Err(e) = self.session.execute("file.new", &json!({}))
{
log::error!("file.new failed: {e}");
}
}
fn exec(&mut self, id: &str, params: &Value) -> Result<Value, String> {
match id {
"file.open" => self.open(params),
"file.save" => self.save(params),
"file.export" => self.export(params),
"file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()),
"tool.select" => self.select_tool(params),
"tool.setOption" => self.session.set_tool_option_cmd(params),
_ => self.session.execute(id, params).map_err(|e| e.to_string()),
}
}
fn commands(&self) -> Value {
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
for (id, label, params) in HOST_COMMANDS {
v.push(json!({"id": id, "label": label, "menu": [], "shortcut": null, "params": params, "enabled": true, "host": true}));
}
Value::Array(v)
}
fn select_tool(&mut self, p: &Value) -> Result<Value, String> {
let t = s(p, "tool").ok_or("missing `tool`")?;
if vectorcraft_tools::tool_info(t).is_none() {
return Err(format!("unknown tool `{t}` (see ui.tool.list)"));
}
self.session.select_tool(t, self.view).map_err(|e| e.to_string())?;
Ok(json!({"tool": self.session.tool_id()}))
}
fn apply_ui_requests(&mut self, reqs: Vec<UiRequest>, out: &mut Vec<Value>) -> Result<(), String> {
for r in reqs {
match r {
UiRequest::SwitchTool(t) => {
self.session.select_tool(&t, self.view).map_err(|e| e.to_string())?;
}
UiRequest::Dialog(k, p) => out.push(json!({"dialog": k, "params": p})),
UiRequest::Status(msg) => out.push(json!({"status": msg})),
}
}
Ok(())
}
fn pointer(&mut self, p: &Value) -> Result<Value, String> {
let events = p.get("events").and_then(Value::as_array).ok_or("missing `events`")?;
let base: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
self.ensure_document();
let mut requests = vec![];
for e in events {
let kind = s(e, "kind").unwrap_or("");
let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?;
if s(e, "space") == Some("screen") {
return Err("screen-space pointer events need the desktop app; use document coordinates".into());
}
let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?;
let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?;
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base);
let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: PointerEvent::json_pressure(e) };
let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut requests)?;
let hold = PointerEvent::json_hold(e);
if hold > 0.0 {
let reqs = self.session.tool_tick(hold, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut requests)?;
}
}
let sel = self.session.active().map(|d| d.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>()).unwrap_or_default();
Ok(json!({"selection": sel, "tool": self.session.tool_id(), "requests": requests}))
}
fn key(&mut self, p: &Value) -> Result<Value, String> {
let key = s(p, "key").ok_or("missing `key`")?;
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
let mods = Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), ..Mods::default() };
let tk = tool_key(key);
// A busy tool (pen path in progress, shape being dragged) gets its keys first, as do keys
// the tool claims (the Gradient tool's selected stop takes Delete and the arrows).
if let Some(k) = tk
&& (self.session.tool_busy() || self.session.tool_claims_key(k, self.view))
{
let mut out = vec![];
let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut out)?;
return Ok(json!({"handledBy": "tool", "requests": out}));
}
if let Some(c) = vectorcraft_engine::command_specs().iter().find(|c| c.shortcut.is_some_and(|sc| shortcut_matches(sc, key, mods))) {
let r = self.exec(c.id, &json!({}))?;
return Ok(json!({"handledBy": "command", "command": c.id, "result": r}));
}
if let Some(t) = TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.shortcut.is_some_and(|sc| shortcut_matches(sc, key, mods))) {
self.session.select_tool(t.id, self.view).map_err(|e| e.to_string())?;
return Ok(json!({"handledBy": "tool.select", "tool": t.id}));
}
// Backspace clears the selection as Delete does (as in the desktop app).
if tk == Some(ToolKey::Backspace) && mods == Mods::default() && self.session.active().is_some_and(|d| !d.selection.is_empty()) {
let r = self.exec("edit.clear", &json!({}))?;
return Ok(json!({"handledBy": "command", "command": "edit.clear", "result": r}));
}
if let Some(k) = tk {
let mut out = vec![];
let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?;
self.apply_ui_requests(reqs, &mut out)?;
return Ok(json!({"handledBy": "tool", "requests": out}));
}
Err(format!("no headless binding for key `{key}`"))
}
fn render(&mut self, p: &Value) -> Result<Value, String> {
let st = self.session.active().ok_or("no document")?;
let doc = st.doc.clone();
let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?;
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0);
vectorcraft_render::raster_size(r, scale)?;
let img = self.renderer.render_region(&doc, r, scale, true);
let png = img.to_png()?;
let mut out = json!({"width": img.width, "height": img.height});
match s(p, "path") {
Some(path) => {
file_access::check_write(path)?;
vectorcraft_format::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
out["path"] = json!(path);
// `data: true` asks for the image as well as the file.
if p.get("data").and_then(Value::as_bool) == Some(true) {
out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png));
}
}
None => out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png)),
}
Ok(out)
}
/// `app.open {path}`: any readable file as a new active document (`document.open`).
pub fn open(&mut self, p: &Value) -> Result<Value, String> {
let path = s(p, "path").ok_or("missing `path`")?;
self.session.execute("document.open", &json!({"path": path})).map_err(|e| e.to_string())
}
/// `app.save {path?, format?, options?}`: `document.save` (by default the document's own path and
/// format); remembers the path and clears the dirty flag.
pub fn save(&mut self, p: &Value) -> Result<Value, String> {
let st = self.session.active().ok_or("no document")?;
if s(p, "path").is_none() && st.path.is_none() {
return Err("missing `path` (document was never saved)".into());
}
self.session.execute("document.save", p).map_err(|e| e.to_string())
}
/// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension).
pub fn export(&mut self, p: &Value) -> Result<Value, String> {
self.session.execute("document.export", p).map_err(|e| e.to_string())
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.answer(method, &params))
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"headless".into()
}
}
impl Headless {
/// One control-channel method ([`Backend::call`]).
fn answer(&mut self, method: &str, p: &Value) -> Result<Value, String> {
match method {
"engine.execute" | "ui.menu.invoke" | "command" => {
let id = s(p, "command").or(s(p, "id")).ok_or("missing `command`")?.to_string();
let params = match p.get("params") {
None | Some(Value::Null) => json!({}),
Some(v) => v.clone(),
};
self.exec(&id, &params)
}
"engine.commands" => Ok(self.commands()),
"document.inspect" => self.exec("document.inspect", p),
"document.node" => self.exec("document.node", p),
"document.json" => self.exec("document.json", &json!({})),
"ui.tool.select" => self.select_tool(p),
"ui.tool.list" => Ok(serde_json::to_value(TOOL_GROUPS).unwrap_or_default()),
"ui.pointer" => self.pointer(p),
"ui.key" => self.key(p),
"ui.render" | "ui.screenshot" => self.render(p),
"app.open" => self.open(p),
"app.save" => self.save(p),
"app.export" => self.export(p),
"app.quit" => Ok(Value::Null),
m if m.starts_with("ui.") => {
Err(format!("`{m}` needs the desktop app (start `vectorcraft --control 7979` and use `vectorcraft-cli mcp --connect`)"))
}
other => Err(format!("unknown method `{other}`")),
}
}
}