mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
The MCP server read one line, ran it and answered it before reading the next, so notifications/cancelled was never seen during a call and every request (even ping) waited behind a running export. - vectorcraft_doc::cancel: a thread-local cancel token and progress sink that long work polls. Checkpoints: PDF pages, PDF flattening, geometry bake, raster-effect flattening, drawing objects, raster artboards, and before any file is written. A cancelled command returns EngineError::Cancelled and changes nothing. - Server::serve reads on its own thread: notifications/cancelled drops a queued request or stops the running one (no reply, as the MCP spec says), engine-free requests (ping, the list methods, prompts/get) are answered at once, a request that would wait behind a call running for 2 s is refused with -32003 naming that call, and _meta.progressToken gets notifications/progress (page n of m). Queue and output are bounded. - The remote backend stops waiting for the app when the call is cancelled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
344 lines
16 KiB
Rust
344 lines
16 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",
|
|
"{…as document.save} = document.save {path?, format?, options?, svg?: {…SVG options}}: .vectorcraft, .ai (a PDF that reopens editable), .pdf, .svg or .svgz (default: the document's own path and format)",
|
|
),
|
|
("file.export", "Export…", "{…as document.export} = document.export {path?, format?, artboard?, range?, scale?, …} (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 && k.bytes().all(|b| b.is_ascii_lowercase()) => ToolKey::Letter(k.chars().next()?),
|
|
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> {
|
|
// A key the command's description doesn't name is an error, not a default (#1442).
|
|
match HOST_COMMANDS.iter().find(|c| c.0 == id) {
|
|
Some((_, _, doc)) => vectorcraft_engine::check_keys(id, doc, params),
|
|
None => self.session.check_params(id, params),
|
|
}
|
|
.map_err(|e| e.to_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() };
|
|
// A letter reaches the tool only when the tool claims it without Cmd, Ctrl or Alt (E while
|
|
// a selection marquee is dragged); otherwise it is a shortcut.
|
|
let mut tk = tool_key(key);
|
|
if let Some(k @ ToolKey::Letter(_)) = tk
|
|
&& (mods.cmd || mods.ctrl || mods.alt || !self.session.tool_claims_key(k, self.view))
|
|
{
|
|
tk = None;
|
|
}
|
|
// 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);
|
|
// A cancelled render stops drawing: the picture is not whole and is not written.
|
|
vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?;
|
|
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, ¶ms))
|
|
}
|
|
|
|
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, ¶ms)
|
|
}
|
|
"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}`")),
|
|
}
|
|
}
|
|
}
|