openpencil/crates/op-cli/src/app_control_cli.rs
2026-06-05 22:19:08 +08:00

562 lines
21 KiB
Rust

use serde_json::json;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const PID_FILE_NAME: &str = "openpencil-mcp-server.pid";
const PORT_FILE_NAME: &str = "openpencil-mcp-server.port";
const TOKEN_FILE_NAME: &str = "openpencil-mcp-server.token";
const MINIMAL_DOCUMENT: &str = "{\n \"version\": \"0.8.0\",\n \"name\": \"OpenPencil CLI Session\",\n \"children\": []\n}\n";
/// Rust live-canvas discovery file (`~/.openpencil/.op-mcp-port`), written
/// by the running editor when its `McpLiveServer` binds. Deliberately
/// distinct from the TS app's `~/.openpencil/.port`: the Rust editor
/// serves JSON-RPC at `/mcp`, while the TS app serves REST at
/// `/api/mcp/*` behind an app URL — sharing one file would let either
/// stack mis-route to the other. Discovery here always confirms identity
/// with a JSON-RPC `ping` before trusting the advertised port.
const LIVE_PORT_FILE_DIR: &str = ".openpencil";
const LIVE_PORT_FILE_NAME: &str = ".op-mcp-port";
#[derive(Debug, Clone)]
struct RunningMcp {
pid: u32,
port: u16,
/// Per-instance identity token the server echoes in `ping` (empty for
/// a manager file written before tokens existed).
token: String,
}
/// `op start` — by default launch the live-rendering editor GUI and wait
/// for it to publish `~/.openpencil/.op-mcp-port`, so subsequent `op <tool>`
/// calls drive the on-screen canvas in real time (TS parity). Pass
/// `headless` to instead run the windowless file-backed MCP server
/// (`--mcp-http`) — useful for CI / display-less agents.
pub(crate) fn run_start(
port: u16,
document_path: Option<&str>,
headless: bool,
) -> Result<String, String> {
if headless {
// Reuse an already-running headless server; otherwise launch one.
if let Some(existing) = reachable_headless_server() {
return Ok(start_json(existing.pid, existing.port, None));
}
run_start_headless(port, document_path)
} else {
// Reuse a live editor only — a headless file server is NOT a live
// canvas, so `op start` must open the real GUI even if one runs.
if let Some((live_port, live_pid)) = reachable_live_port_file() {
return Ok(start_json(live_pid, live_port, None));
}
run_start_live(port, document_path)
}
}
/// A CLI-managed headless server whose advertised port still answers our
/// JSON-RPC `ping` with the token in our manager file — proving the pid we
/// recorded owns the port (filters stale / pid-recycled manager files).
fn reachable_headless_server() -> Option<RunningMcp> {
let info = running_mcp_from_pid_file()?;
crate::mcp_http_cli::mcp_ping_headless(info.port, &info.token).then_some(info)
}
/// Whether the live editor will actually open `path` on launch — mirrors
/// the desktop's `initial_file_from_argv` gate: a supported `.op` / `.pen` /
/// `.fig` extension AND an existing file. The editor silently ignores
/// anything else (blank canvas), so `op start --file` must not claim such a
/// path in its `documentPath`.
fn editor_will_open(path: &str) -> bool {
let p = Path::new(path);
let supported = p
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
ext.eq_ignore_ascii_case("op")
|| ext.eq_ignore_ascii_case("pen")
|| ext.eq_ignore_ascii_case("fig")
});
supported && p.is_file()
}
/// Launch the visible editor with `--live-mcp <port>` and wait for it to
/// publish the discovery port file. No `$TMPDIR` manager files: the
/// editor owns `~/.openpencil/.op-mcp-port` and removes it on exit.
fn run_start_live(port: u16, document_path: Option<&str>) -> Result<String, String> {
let binary = find_desktop_binary()?;
let mut command = Command::new(&binary);
command.arg("--live-mcp").arg(port.to_string());
// An optional document path is opened in the live editor via the
// file-association argv path (`initial_file_from_argv`).
if let Some(path) = document_path {
command.arg(path);
}
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("spawn {} --live-mcp: {e}", binary.display()))?;
// Only report a `documentPath` the editor will ACTUALLY open: its
// `initial_file_from_argv` gate ignores a missing / unsupported path
// (the canvas comes up blank), so reporting the raw `--file` arg would
// claim a document the editor never opened.
let opened = document_path.filter(|p| editor_will_open(p));
// Wait up to ~15s for the editor to bind + publish its port.
for _ in 0..150 {
if let Some((live_port, live_pid)) = reachable_live_port_file() {
return Ok(start_json(live_pid, live_port, opened.map(Path::new)));
}
if let Ok(Some(status)) = child.try_wait() {
return Err(format!(
"OpenPencil editor exited before serving the live MCP server: {status}"
));
}
thread::sleep(Duration::from_millis(100));
}
// Timed out without a verified live server. Report failure honestly
// rather than a fabricated success on the requested port — the editor
// process is left running and may still come up, but we cannot
// confirm the canvas is live yet.
Err(format!(
"OpenPencil editor did not publish a live MCP server within 15s \
(expected ~/.openpencil/{LIVE_PORT_FILE_NAME}); it may still be starting"
))
}
/// Legacy windowless mode: spawn `--mcp-http` against a `.op` file and
/// track it via the `$TMPDIR` pid/port manager files.
fn run_start_headless(port: u16, document_path: Option<&str>) -> Result<String, String> {
let document = match document_path {
Some(path) => PathBuf::from(path),
None => default_document_path()?,
};
ensure_document_file(&document)?;
let binary = find_desktop_binary()?;
// Per-instance token passed to the server (echoed in its `ping`) so a
// later `op stop` can prove the pid in our manager file owns the port.
let token = make_token();
let mut child = Command::new(&binary)
.arg("--mcp-http")
.arg(port.to_string())
.arg(&document)
.env("OPENPENCIL_MCP_TOKEN", &token)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("spawn {} --mcp-http: {e}", binary.display()))?;
let pid = child.id();
write_manager_files(pid, port, &token)?;
for _ in 0..30 {
// Confirm the MCP server actually answers WITH our token (not just
// an open port), so we never report success for a child that failed
// to bind or for an unrelated service on the port.
if crate::mcp_http_cli::mcp_ping_headless(port, &token) {
return Ok(start_json(pid, port, Some(&document)));
}
if let Ok(Some(status)) = child.try_wait() {
remove_manager_files();
return Err(format!(
"OpenPencil MCP server exited before accepting connections: {status}"
));
}
thread::sleep(Duration::from_millis(100));
}
Err(format!(
"OpenPencil MCP server did not respond on 127.0.0.1:{port} within 3s"
))
}
pub(crate) fn run_stop() -> Result<String, String> {
// `op stop` asks the server to quit ITSELF via a token-authed
// `openpencil/shutdown`. This NEVER signals a pid, so there is no
// recycled-pid / wrong-process race anywhere — and the live editor
// exits cleanly (saving state + removing its own discovery file).
// Safe by construction: only the server holding this exact token acts,
// so a stale file, a recycled pid, or an unrelated service on the port
// is a no-op (we never touch it). A `.token` is always present for a
// server we started; an empty token can't authenticate, so it is left
// alone.
if let Some((live_port, live_pid, live_token)) = read_live_port_file() {
if !live_token.is_empty() && crate::mcp_http_cli::request_shutdown(live_port, &live_token) {
wait_until(|| crate::mcp_http_cli::mcp_ping_live(live_port, &live_token));
remove_live_port_file();
return Ok(stop_message("OpenPencil editor stopped"));
}
// No live server answered our token. Tidy an orphaned file only when
// the recorded process is definitively gone; a live-but-unresponsive
// editor is left intact (discovery re-pings it).
if live_pid != 0 && !is_pid_alive(live_pid) {
remove_live_port_file();
}
}
if let Some(info) = running_mcp_from_pid_file() {
if !info.token.is_empty() && crate::mcp_http_cli::request_shutdown(info.port, &info.token) {
wait_until(|| crate::mcp_http_cli::mcp_ping_headless(info.port, &info.token));
remove_manager_files();
return Ok(stop_message("OpenPencil MCP server stopped"));
}
// Didn't answer our token (recycled pid / reused port, or transiently
// busy). Don't signal anything; only clean up files when the recorded
// process is gone, so a live server is never disturbed.
if !is_pid_alive(info.pid) {
remove_manager_files();
}
return Ok(stop_message("No running MCP server found"));
}
remove_manager_files();
Ok(stop_message("No running MCP server found"))
}
fn stop_message(message: &str) -> String {
json!({ "ok": true, "running": false, "message": message }).to_string()
}
fn remove_live_port_file() {
if let Some(path) = live_port_file_path() {
let _ = fs::remove_file(path);
}
}
/// Poll `still_up` every 100ms for up to ~3s, returning once it reports
/// false (the server stopped responding to its token) or the budget
/// elapses — used to confirm a graceful shutdown actually took effect.
fn wait_until<F: Fn() -> bool>(still_up: F) {
for _ in 0..30 {
if !still_up() {
return;
}
thread::sleep(Duration::from_millis(100));
}
}
/// Path to the Rust live discovery file `~/.openpencil/.op-mcp-port`.
fn live_port_file_path() -> Option<PathBuf> {
home_dir()
.ok()
.map(|home| home.join(LIVE_PORT_FILE_DIR).join(LIVE_PORT_FILE_NAME))
}
/// Read `~/.openpencil/.op-mcp-port` → `(port, pid, token)`. `pid` is 0
/// when absent or out of `u32` range (so callers never act on a truncated
/// pid); `token` is empty when absent.
fn read_live_port_file() -> Option<(u16, u32, String)> {
let raw = fs::read_to_string(live_port_file_path()?).ok()?;
let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
let port = u16::try_from(value.get("port")?.as_u64()?).ok()?;
let pid = value
.get("pid")
.and_then(serde_json::Value::as_u64)
.and_then(|n| u32::try_from(n).ok())
.unwrap_or(0);
let token = value
.get("token")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
Some((port, pid, token))
}
/// Read `(port, pid, timestamp_ms)` from the live discovery file for
/// `op status`. The host writes `timestamp` at startup (see
/// `mcp_port_file::write`), so the CLI can report pid + uptime exactly like
/// the TS `op status` (`uptime = floor((now - timestamp) / 1000)`). The
/// caller matches `port` against the server it's reporting before trusting
/// pid/timestamp. `None` when the file is absent or lacks the fields.
pub(crate) fn live_status_fields() -> Option<(u16, u32, u64)> {
let raw = fs::read_to_string(live_port_file_path()?).ok()?;
let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
let port = u16::try_from(value.get("port")?.as_u64()?).ok()?;
let pid = value
.get("pid")
.and_then(serde_json::Value::as_u64)
.and_then(|n| u32::try_from(n).ok())?;
let timestamp = value.get("timestamp").and_then(serde_json::Value::as_u64)?;
Some((port, pid, timestamp))
}
/// Like [`read_live_port_file`] but only returns it when the advertised
/// port is genuinely serving the *live* editor that published this exact
/// token — rejecting stale files, unrelated services, and a headless
/// server squatting on a reused port.
fn reachable_live_port_file() -> Option<(u16, u32)> {
let (port, pid, token) = read_live_port_file()?;
crate::mcp_http_cli::mcp_ping_live(port, &token).then_some((port, pid))
}
/// Discover the port of a running OpenPencil MCP server for `op <tool>`
/// calls, preferring the live editor over a CLI-managed headless server.
/// Both candidates are confirmed with a JSON-RPC `ping`. Returns `None`
/// when nothing is reachable.
pub(crate) fn discover_running_port() -> Option<u16> {
if let Some((port, _)) = reachable_live_port_file() {
return Some(port);
}
reachable_headless_server().map(|info| info.port)
}
pub(crate) fn ensure_document_file(path: &Path) -> Result<(), String> {
if path.exists() {
if path.is_file() {
return Ok(());
}
return Err(format!("{} exists but is not a file", path.display()));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
fs::write(path, MINIMAL_DOCUMENT).map_err(|e| format!("write {}: {e}", path.display()))
}
fn start_json(pid: u32, port: u16, document_path: Option<&Path>) -> String {
let mut value = json!({
"ok": true,
"running": true,
"pid": pid,
"port": port,
"url": format!("http://127.0.0.1:{port}"),
"mcpUrl": format!("http://127.0.0.1:{port}/mcp"),
});
if let Some(path) = document_path {
value["documentPath"] = json!(path.display().to_string());
}
value.to_string()
}
fn running_mcp_from_pid_file() -> Option<RunningMcp> {
let (pid_file, port_file, token_file) = manager_files();
let pid = fs::read_to_string(&pid_file)
.ok()?
.trim()
.parse::<u32>()
.ok()?;
if !is_pid_alive(pid) {
remove_manager_files();
return None;
}
let port = fs::read_to_string(&port_file)
.ok()
.and_then(|text| text.trim().parse::<u16>().ok())
.unwrap_or(3100);
let token = fs::read_to_string(&token_file)
.map(|text| text.trim().to_string())
.unwrap_or_default();
Some(RunningMcp { pid, port, token })
}
fn write_manager_files(pid: u32, port: u16, token: &str) -> Result<(), String> {
let (pid_file, port_file, token_file) = manager_files();
fs::write(&pid_file, pid.to_string())
.map_err(|e| format!("write {}: {e}", pid_file.display()))?;
fs::write(&port_file, port.to_string())
.map_err(|e| format!("write {}: {e}", port_file.display()))?;
fs::write(&token_file, token).map_err(|e| format!("write {}: {e}", token_file.display()))
}
fn remove_manager_files() {
let (pid_file, port_file, token_file) = manager_files();
let _ = fs::remove_file(pid_file);
let _ = fs::remove_file(port_file);
let _ = fs::remove_file(token_file);
}
fn manager_files() -> (PathBuf, PathBuf, PathBuf) {
let dir = env::temp_dir();
(
dir.join(PID_FILE_NAME),
dir.join(PORT_FILE_NAME),
dir.join(TOKEN_FILE_NAME),
)
}
/// Per-instance identity token (pid + nanos, hex). Not security-sensitive —
/// it only needs to be distinct per spawned server so a later `op stop`
/// can confirm the recorded pid still owns the port.
fn make_token() -> String {
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{pid:x}-{nanos:x}")
}
fn default_document_path() -> Result<PathBuf, String> {
home_dir().map(|home| home.join(".openpencil").join("cli-session.op"))
}
fn home_dir() -> Result<PathBuf, String> {
env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
.ok_or_else(|| "home directory not available; pass --file <path.op>".to_string())
}
fn find_desktop_binary() -> Result<PathBuf, String> {
if let Some(path) = env::var_os("OPENPENCIL_DESKTOP_BIN").map(PathBuf::from) {
if path.is_file() {
return Ok(path);
}
return Err(format!(
"OPENPENCIL_DESKTOP_BIN points to a missing file: {}",
path.display()
));
}
for path in desktop_binary_candidates() {
if path.is_file() {
return Ok(path);
}
}
Err("OpenPencil desktop binary not found; set OPENPENCIL_DESKTOP_BIN or build openpencil-desktop".into())
}
fn desktop_binary_candidates() -> Vec<PathBuf> {
let mut candidates = Vec::new();
let exe_dir = env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(Path::to_path_buf));
if let Some(dir) = exe_dir {
candidates.push(dir.join(desktop_binary_name()));
candidates.push(dir.join("OpenPencil"));
}
if let Ok(cwd) = env::current_dir() {
candidates.push(cwd.join("target").join("debug").join(desktop_binary_name()));
candidates.push(
cwd.join("target")
.join("release")
.join(desktop_binary_name()),
);
}
if cfg!(target_os = "macos") {
if let Ok(home) = home_dir() {
candidates.push(
home.join("Applications")
.join("OpenPencil.app")
.join("Contents")
.join("MacOS")
.join("OpenPencil"),
);
}
candidates.push(
PathBuf::from("/Applications")
.join("OpenPencil.app")
.join("Contents")
.join("MacOS")
.join("OpenPencil"),
);
} else if cfg!(target_os = "windows") {
if let Some(local_app_data) = env::var_os("LOCALAPPDATA").map(PathBuf::from) {
candidates.push(
local_app_data
.join("Programs")
.join("openpencil")
.join("OpenPencil.exe"),
);
}
candidates.push(PathBuf::from(r"C:\Program Files\OpenPencil\OpenPencil.exe"));
candidates.push(PathBuf::from(
r"C:\Program Files (x86)\OpenPencil\OpenPencil.exe",
));
} else {
candidates.push(PathBuf::from("/usr/bin/openpencil"));
candidates.push(PathBuf::from("/usr/local/bin/openpencil"));
if let Ok(home) = home_dir() {
candidates.push(home.join(".local").join("bin").join("openpencil"));
collect_app_images(&home.join("Applications"), &mut candidates);
collect_app_images(&home.join("Downloads"), &mut candidates);
}
}
candidates
}
fn desktop_binary_name() -> &'static str {
if cfg!(target_os = "windows") {
"openpencil-desktop.exe"
} else {
"openpencil-desktop"
}
}
fn collect_app_images(dir: &Path, candidates: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with("OpenPencil") && name.ends_with(".AppImage") {
candidates.push(path);
}
}
}
#[cfg(unix)]
fn is_pid_alive(pid: u32) -> bool {
Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
#[cfg(windows)]
fn is_pid_alive(pid: u32) -> bool {
let filter = format!("PID eq {pid}");
Command::new("tasklist")
.arg("/FI")
.arg(filter)
.stderr(Stdio::null())
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|stdout| stdout.contains(&pid.to_string()))
.unwrap_or(false)
}
#[cfg(test)]
mod editor_will_open_tests {
use super::editor_will_open;
use std::fs;
#[test]
fn reports_only_files_the_editor_actually_opens() {
// Mirrors the desktop `initial_file_from_argv` gate so `op start
// --file` never claims a documentPath the editor silently ignored.
let dir = std::env::temp_dir().join(format!("op-start-file-{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
let op = dir.join("doc.op");
fs::write(&op, "{}").expect("write .op");
let txt = dir.join("note.txt");
fs::write(&txt, "x").expect("write .txt");
// Existing supported document → editor opens it.
assert!(editor_will_open(op.to_str().unwrap()));
// Existing but unsupported extension → editor ignores it.
assert!(!editor_will_open(txt.to_str().unwrap()));
// Supported extension but missing file → editor ignores it.
assert!(!editor_will_open(dir.join("missing.op").to_str().unwrap()));
// A directory is not a file.
assert!(!editor_will_open(dir.to_str().unwrap()));
let _ = fs::remove_dir_all(&dir);
}
}