openpencil/crates/op-cli/src/app_control_cli.rs
2026-08-01 16:19:26 +08:00

759 lines
29 KiB
Rust

use crate::cli_error::CliError;
use op_process_io::{spawn_null, wait_for_child_or, wait_until_false, WaitOutcome};
use serde_json::json;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
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 = concat!(
"{\n \"version\": \"",
env!("CARGO_PKG_VERSION"),
"\",\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_NAME: &str = op_config_store::well_known::LIVE_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. Pass `web` to run
/// the browser editor: the `--serve-web` daemon (static wasm bundle + REST
/// sync + `/mcp`) is spawned headless and the URL is opened in the browser
/// (TS `startWeb` parity).
pub(crate) fn run_start(
port: u16,
document_path: Option<&str>,
headless: bool,
web: bool,
host: Option<&str>,
) -> Result<String, CliError> {
if web {
return run_start_web(port, document_path, host);
}
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, CliError> {
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 = spawn_null(&mut command)
.map_err(|e| CliError::Daemon(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.
match wait_for_child_or(&mut child, 150, Duration::from_millis(100), || {
reachable_live_port_file()
})
.map_err(|e| CliError::Daemon(format!("wait for {} --live-mcp: {e}", binary.display())))?
{
WaitOutcome::Ready((live_port, live_pid)) => {
return Ok(start_json(live_pid, live_port, opened.map(Path::new)));
}
WaitOutcome::Exited(status) => {
return Err(CliError::Daemon(format!(
"OpenPencil editor exited before serving the live MCP server: {status}"
)));
}
WaitOutcome::TimedOut => {}
}
// 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(CliError::Daemon(format!(
"OpenPencil editor did not publish a live MCP server within 15s \
(expected ~/.openpencil/{LIVE_PORT_FILE_NAME}); it may still be starting"
)))
}
/// `op start --web` — spawn the `--serve-web` daemon (headless: static wasm
/// bundle at `/` + `/pkg/*`, REST sync, JSON-RPC `/mcp`), track it via the
/// same `$TMPDIR` pid/port/token manager files as the headless server (so
/// `op stop` / discovery work unchanged — the daemon honors the token-authed
/// `openpencil/shutdown`), wait until its HTTP port answers our token, then
/// print the URL and open it in the default browser.
fn run_start_web(
port: u16,
document_path: Option<&str>,
host: Option<&str>,
) -> Result<String, CliError> {
// Reuse only an already-running WEB daemon (token-verified ping +
// `mode:"web-canvas"` health). A plain `--mcp-http` server answers the
// ping too but serves no editor, so reusing it would hand the user a
// browser tab full of JSON errors.
if let Some(existing) = reachable_headless_server() {
if crate::mcp_http_cli::web_daemon_running(existing.port) {
let url = format!("http://127.0.0.1:{}", existing.port);
open_in_browser(&url);
return Ok(start_web_json(existing.pid, existing.port, None, host));
}
return Err(CliError::Daemon(format!(
"a non-web MCP server (pid {}) already owns port {}; run `op stop` first",
existing.pid, existing.port
)));
}
let binary = find_desktop_binary()?;
// An explicit document is created-if-missing (like `--headless`); without
// one the daemon starts on an empty document the web shell can sync into.
let document = match document_path {
Some(path) => {
let path = PathBuf::from(path);
ensure_document_file(&path)?;
Some(path)
}
None => None,
};
let token = make_token();
let mut command = Command::new(&binary);
command.arg("--serve-web").arg(port.to_string());
if let Some(doc) = &document {
command.arg(doc);
}
if let Some(host) = host {
command.arg("--host").arg(host);
}
command.env(op_config_store::env_vars::MCP_TOKEN, &token);
let mut child = spawn_null(&mut command)
.map_err(|e| CliError::Daemon(format!("spawn {} --serve-web: {e}", binary.display())))?;
let pid = child.id();
write_manager_files(pid, port, &token)?;
// Wait up to ~5s for the daemon's HTTP server to answer. The token-authed
// JSON-RPC ping doubles as the identity check (never trust a foreign
// listener on the port), exactly like the headless start path.
match wait_for_child_or(&mut child, 50, Duration::from_millis(100), || {
crate::mcp_http_cli::mcp_ping_headless(port, &token).then_some(())
})
.map_err(|e| CliError::Daemon(format!("wait for {} --serve-web: {e}", binary.display())))?
{
WaitOutcome::Ready(()) => {
let url = format!("http://127.0.0.1:{port}");
open_in_browser(&url);
return Ok(start_web_json(pid, port, document.as_deref(), host));
}
WaitOutcome::Exited(status) => {
remove_manager_files();
return Err(CliError::Daemon(format!(
"OpenPencil web daemon exited before accepting connections: {status}"
)));
}
WaitOutcome::TimedOut => {}
}
Err(CliError::Daemon(format!(
"OpenPencil web daemon did not respond on 127.0.0.1:{port} within 5s"
)))
}
/// Best-effort default-browser launch, per OS: `open` (macOS),
/// `cmd /C start` (Windows), `xdg-open` (Linux/BSD). Failures are silent —
/// the URL is always printed in the `op start --web` JSON, so a missing
/// opener (SSH session, container) degrades to copy/paste.
fn open_in_browser(url: &str) {
#[cfg(target_os = "macos")]
let mut command = {
let mut c = Command::new("open");
c.arg(url);
c
};
#[cfg(target_os = "windows")]
let mut command = {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut c = Command::new("cmd");
// Raw arg keeps the URL inside double quotes so cmd doesn't
// split it at `&`; the empty "" is `start`'s window-title slot —
// without it the quoted URL would be consumed as the title.
c.raw_arg(windows_start_args(url));
c.creation_flags(CREATE_NO_WINDOW);
c
};
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let mut command = {
let mut c = Command::new("xdg-open");
c.arg(url);
c
};
let _ = spawn_null(&mut command);
}
/// Args for `cmd /C start "" "<url>"` with the URL double-quoted so cmd
/// doesn't split it at `&`. `"` is illegal in URLs — stripped
/// defensively.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn windows_start_args(url: &str) -> String {
format!("/C start \"\" \"{}\"", url.replace('"', "%22"))
}
/// `op start --web` success JSON: the headless `start_json` shape plus
/// `mode:"web"` (and the non-default bind host when one was requested, so
/// scripts can derive the LAN URL).
fn start_web_json(pid: u32, port: u16, document_path: Option<&Path>, host: Option<&str>) -> String {
let mut value = json!({
"ok": true,
"running": true,
"mode": "web",
"pid": pid,
"port": port,
"url": op_editor_core::local_daemon_origin(port),
"mcpUrl": op_editor_core::local_mcp_url(port),
});
if let Some(host) = host {
value["host"] = json!(host);
}
if let Some(path) = document_path {
value["documentPath"] = json!(path.display().to_string());
}
value.to_string()
}
/// 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, CliError> {
let document = match document_path {
Some(path) => PathBuf::from(path),
None => default_document_path()?,
};
ensure_document_file(&document)?;
preflight_document(&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 command = Command::new(&binary);
command
.arg("--mcp-http")
.arg(port.to_string())
.arg(&document)
.env(op_config_store::env_vars::MCP_TOKEN, &token);
let mut child = spawn_null(&mut command)
.map_err(|e| CliError::Daemon(format!("spawn {} --mcp-http: {e}", binary.display())))?;
let pid = child.id();
write_manager_files(pid, port, &token)?;
// 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.
match wait_for_child_or(&mut child, 30, Duration::from_millis(100), || {
crate::mcp_http_cli::mcp_ping_headless(port, &token).then_some(())
})
.map_err(|e| CliError::Daemon(format!("wait for {} --mcp-http: {e}", binary.display())))?
{
WaitOutcome::Ready(()) => {
return Ok(start_json(pid, port, Some(&document)));
}
WaitOutcome::Exited(status) => {
remove_manager_files();
return Err(CliError::Daemon(format!(
"OpenPencil MCP server exited before accepting connections: {status}"
)));
}
WaitOutcome::TimedOut => {}
}
Err(CliError::Daemon(format!(
"OpenPencil MCP server did not respond on 127.0.0.1:{port} within 3s"
)))
}
pub(crate) fn run_stop() -> Result<String, CliError> {
// `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) {
let _ = wait_until_false(30, Duration::from_millis(100), still_up);
}
/// Path to the Rust live discovery file `~/.openpencil/.op-mcp-port`.
fn live_port_file_path() -> Option<PathBuf> {
op_config_store::ConfigStore::user()
.ok()
.and_then(|store| live_port_file_path_in(&store).ok())
}
fn live_port_file_path_in(store: &op_config_store::ConfigStore) -> std::io::Result<PathBuf> {
store.path(op_config_store::well_known::LIVE_MCP_PORT)
}
/// 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))
}
/// The reachable MCP endpoint and the instance token that authenticates it.
///
/// The live endpoint authenticates every stateful call, so the port alone is
/// not enough to drive it — the token has to travel with it.
pub(crate) fn discover_running_endpoint() -> Option<(u16, String)> {
if let Some((port, _, token)) = read_live_port_file() {
if crate::mcp_http_cli::mcp_ping_live(port, &token) {
return Some((port, token));
}
}
reachable_headless_server().map(|info| (info.port, info.token))
}
/// The instance token for `port`, or empty when this process cannot find one.
///
/// Used when the caller pinned `--port` explicitly: the token still has to be
/// looked up, and an empty result simply means the request goes out unauthenticated
/// and the endpoint decides.
pub(crate) fn token_for_port(port: u16) -> String {
if let Some((live_port, _, token)) = read_live_port_file() {
if live_port == port {
return token;
}
}
running_mcp_from_pid_file()
.filter(|info| info.port == port)
.map(|info| info.token)
.unwrap_or_default()
}
pub(crate) fn ensure_document_file(path: &Path) -> Result<(), CliError> {
if path.exists() {
if path.is_file() {
return Ok(());
}
return Err(CliError::Io(format!(
"{} exists but is not a file",
path.display()
)));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| CliError::Io(format!("create {}: {e}", parent.display())))?;
}
fs::write(path, MINIMAL_DOCUMENT)
.map_err(|e| CliError::Io(format!("write {}: {e}", path.display())))
}
/// Verify `path` holds a document the headless MCP server can actually load,
/// using the same loader (`op_pen_loader::load_canonical`) the server runs at
/// startup — so a pass here guarantees the server will load it, and a failure
/// is surfaced as a clear, actionable CLI error.
///
/// Without this, a malformed / wrong-format file makes the `--mcp-http` server
/// exit(1) *before* it binds the socket (it loads the document first), so the
/// CLI only ever sees "exited before accepting connections" and the caller a
/// bare "connection refused" — with no hint that the file is the cause. The
/// check never mutates the file, so a corrupt-but-valuable document is
/// preserved rather than silently replaced.
fn preflight_document(path: &Path) -> Result<(), CliError> {
let bytes =
fs::read(path).map_err(|e| CliError::Io(format!("read {}: {e}", path.display())))?;
// A `.op` document is UTF-8 JSON; a real `.fig` / `.pen` is a binary ZIP
// archive (invalid UTF-8). Reject the binary case here as a wrong file type
// rather than letting the server's `read_to_string` fail with an IO-flavored
// error that reads like the file is unreadable.
let src = std::str::from_utf8(&bytes).map_err(|_| {
CliError::Document(format!(
"{} is not a valid OpenPencil document: not UTF-8 text \
(looks like a binary archive). Only .op (JSON) documents open \
directly; legacy .pen / .fig files must be imported.",
path.display()
))
})?;
op_pen_loader::load_canonical(src).map(|_| ()).map_err(|e| {
CliError::Document(format!(
"{} is not a valid OpenPencil document: {e}\n\
Only .op (JSON) documents open directly; legacy .pen / .fig files must be imported.",
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": op_editor_core::local_daemon_origin(port),
"mcpUrl": op_editor_core::local_mcp_url(port),
});
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(op_editor_core::DEFAULT_MCP_PORT);
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<(), CliError> {
let (pid_file, port_file, token_file) = manager_files();
fs::write(&pid_file, pid.to_string())
.map_err(|e| CliError::Io(format!("write {}: {e}", pid_file.display())))?;
fs::write(&port_file, port.to_string())
.map_err(|e| CliError::Io(format!("write {}: {e}", port_file.display())))?;
fs::write(&token_file, token)
.map_err(|e| CliError::Io(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, CliError> {
let store = op_config_store::ConfigStore::user().map_err(|e| CliError::Io(e.to_string()))?;
default_document_path_in(&store).map_err(|e| CliError::Io(e.to_string()))
}
fn default_document_path_in(store: &op_config_store::ConfigStore) -> std::io::Result<PathBuf> {
store.path(op_config_store::well_known::CLI_SESSION)
}
fn home_dir() -> Result<PathBuf, CliError> {
op_config_store::home_dir()
.map_err(|_| CliError::Io("home directory not available; pass --file <path.op>".into()))
}
fn find_desktop_binary() -> Result<PathBuf, CliError> {
if let Some(path) = env::var_os("OPENPENCIL_DESKTOP_BIN").map(PathBuf::from) {
if path.is_file() {
return Ok(path);
}
return Err(CliError::Daemon(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(CliError::Daemon(
"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)]
#[path = "app_control_cli_tests.rs"]
mod tests;