Files
vectorcraft/crates/mcp/src/headless.rs
T
dotCooCoo 845aa6848f MCP: select.key checks its id, and agents can read the key object
Agents could set and clear the key object but not read it:
document.inspect, the control channel's ui.pointer reply and the
headless pointer_gesture reply left it out, and select.key returned
null. select.key with an id that was not selected, or with a value
that is not an id, such as "x", cleared the key without an error. A
compound-path member or an object inside a selected group could become
the key.

select.key now checks its id as select.set, select.add and
select.toggle do (#784). It returns an error for an id that is not a
selected top-level object and leaves the key as it was. Layers,
compound-path members and objects inside another selected object are
not top-level objects. select.key {id: null} used to clear the key and
is now an error; select.key {} still clears it. select.key returns
{key}, and document.inspect and both pointer replies include the key
object as "key".

In isolation mode a Selection-tool click can pick an object that
select.key does not accept as the key: a sublayer inside an isolated
layer, a member inside an isolated compound path, or an object in an
isolated group that is also selected. The tool no longer sends
select.key {id} for such an object. When the object is already the
key, the tool still sends select.key {}, which clears the key.

A recorded action whose select.key step names an object that is not a
selected top-level object when the action plays now fails and is
rolled back, as an action with a select.set step naming a deleted
object has failed since #784.

Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md.
2026-10-12 00:16:23 +07:00

328 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();
let key = self.session.active().and_then(|d| d.selection.key).map(|k| k.0);
Ok(json!({"selection": sel, "key": key, "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}`")),
}
}
}