mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 19:47:12 -06:00
- crates/automation (L5, no egui): hand-rolled JSON-RPC 2.0 MCP server over stdio (initialize, ping, tools/list, tools/call); headless backend (in-process Session) and bridge backend (desktop control channel). Tools: list_commands, execute_command, get_project/comp/layer, get/set_property, add_keyframe, render_frame (inline PNG), open/save_project, undo/redo; bridge-only screenshot, ui_* and raw control passthrough. - effectcraft-cli: info, commands, exec, run, props, get, set, render-frame, mcp [--bridge PORT]; --json, --project/--demo/--empty, --save/--save-as. - engine: prop.get, layer.tree node paths + time, Session::resolve_comp, render_rgba8, execute_checked (rejects unknown params, lists accepted keys). - control channel: render.frame; engine.execute runs every engine command directly (no dialogs, validated params). - docs: control-protocol.md, agents.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
4.5 KiB
Rust
100 lines
4.5 KiB
Rust
//! Blocking client for the desktop app's JSON-lines control channel (`docs/control-protocol.md`).
|
|
//! One persistent loopback connection, reconnected once if the app went away.
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::{TcpStream, ToSocketAddrs};
|
|
use std::time::Duration;
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::{Error, Result};
|
|
|
|
/// Slightly longer than the app's own 60 s per-request timeout, so its error reply wins.
|
|
const READ_TIMEOUT: Duration = Duration::from_secs(65);
|
|
|
|
pub struct BridgeClient {
|
|
addr: String,
|
|
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
|
next_id: u64,
|
|
}
|
|
|
|
impl BridgeClient {
|
|
/// `addr`: a port (`9877`) or `host:port` on loopback (`127.0.0.1`, `localhost`, `[::1]`).
|
|
pub fn new(addr: &str) -> Result<Self> {
|
|
let addr = if addr.parse::<u16>().is_ok() { format!("127.0.0.1:{addr}") } else { addr.to_string() };
|
|
let host = addr.rsplit_once(':').map(|(h, _)| h).unwrap_or(&addr);
|
|
if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
|
|
return Err(Error::BadArgs(format!("bridge address must be loopback (PORT or 127.0.0.1:PORT), got `{addr}`")));
|
|
}
|
|
Ok(Self { addr, conn: None, next_id: 1 })
|
|
}
|
|
|
|
pub fn addr(&self) -> &str {
|
|
&self.addr
|
|
}
|
|
|
|
fn connect(&self) -> Result<(BufReader<TcpStream>, TcpStream)> {
|
|
let sa = self
|
|
.addr
|
|
.to_socket_addrs()
|
|
.map_err(|e| Error::Bridge(format!("{}: {e}", self.addr)))?
|
|
.next()
|
|
.ok_or_else(|| Error::Bridge(format!("cannot resolve {}", self.addr)))?;
|
|
let s = TcpStream::connect_timeout(&sa, Duration::from_secs(5))
|
|
.map_err(|e| Error::Bridge(format!("cannot connect to {} ({e}); start the app with `effectcraft --control <port>`", self.addr)))?;
|
|
s.set_read_timeout(Some(READ_TIMEOUT)).ok();
|
|
s.set_nodelay(true).ok();
|
|
let r = s.try_clone().map_err(|e| Error::Bridge(e.to_string()))?;
|
|
Ok((BufReader::new(r), s))
|
|
}
|
|
|
|
/// Call a control method; returns the reply's `result`, or the app's `error` as [`Error::App`].
|
|
pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
|
|
let mut last = Error::Bridge("not attempted".into());
|
|
for attempt in 0..2 {
|
|
if self.conn.is_none() {
|
|
self.conn = Some(self.connect()?);
|
|
}
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
let Some((r, w)) = self.conn.as_mut() else { continue };
|
|
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
|
|
// `Ok(Err(_))`: the connection is dead before a reply (safe to resend once);
|
|
// `Err(_)`: a timeout or bad reply (never resent: the app may have run the request).
|
|
let res = (|| -> Result<std::result::Result<Value, Error>> {
|
|
if let Err(e) = w.write_all(line.as_bytes()) {
|
|
return Ok(Err(Error::Bridge(e.to_string())));
|
|
}
|
|
let mut buf = String::new();
|
|
match r.read_line(&mut buf) {
|
|
Ok(0) => return Ok(Err(Error::Bridge("connection closed by the app".into()))),
|
|
Ok(_) => {}
|
|
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
|
|
return Err(Error::Bridge(format!("no reply to `{method}` within {}s (is the app window running?)", READ_TIMEOUT.as_secs())));
|
|
}
|
|
Err(e) => return Ok(Err(Error::Bridge(e.to_string()))),
|
|
}
|
|
serde_json::from_str(&buf).map(Ok).map_err(|e| Error::Bridge(format!("bad reply: {e}")))
|
|
})();
|
|
match res {
|
|
Ok(Ok(v)) if v.get("ok").and_then(Value::as_bool) == Some(true) => return Ok(v.get("result").cloned().unwrap_or(Value::Null)),
|
|
Ok(Ok(v)) => return Err(Error::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())),
|
|
Ok(Err(e)) => {
|
|
// Stale connection (app restarted): reconnect and resend once.
|
|
self.conn = None;
|
|
last = e;
|
|
if attempt == 1 {
|
|
break;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
// The stream may still deliver the late reply: start fresh next call.
|
|
self.conn = None;
|
|
return Err(e);
|
|
}
|
|
}
|
|
}
|
|
Err(last)
|
|
}
|
|
}
|