//! Headless automation server (`OpenCADStudio --serve`). //! //! Drives the app without a GUI over a line-based JSON protocol: one request //! object per line on stdin, one response object per line on stdout. State (the //! active document) persists across requests, so an external process — a script //! or an AI agent — can act, observe, and act again. //! //! Operations: //! - `{"op":"new"}` — start an empty document //! - `{"op":"open","path":"file.dwg"}` — load a drawing //! - `{"op":"run","cmd":"LAYER Walls"}` — run a command (the same dispatcher //! the GUI command line uses) //! - `{"op":"entities"}` — summary count by entity type //! - `{"op":"save","path":"out.dwg"}` — write the document (path optional //! once opened/saved) #[cfg(not(target_arch = "wasm32"))] use crate::io::line_read::{lines_capped, MAX_LINE_BYTES}; use std::collections::{BTreeMap, HashSet}; #[cfg(not(target_arch = "wasm32"))] use std::io::Write; use std::path::PathBuf; use serde_json::{json, Value}; #[cfg(not(target_arch = "wasm32"))] use sha2::{Digest, Sha256}; use super::OpenCADStudio; /// Run the headless JSON server. Default transport is stdin/stdout; with /// `--port ` it instead listens on `127.0.0.1:` and serves one client at /// a time (the document session persists across reconnects); every socket /// request carries `"token"` (see `rest::api_token`). #[cfg(not(target_arch = "wasm32"))] pub fn serve() { let mut app = OpenCADStudio::new(); match port_arg() { Some(port) => serve_socket( &mut app, port, &std::sync::Arc::new(std::sync::atomic::AtomicU16::new(0)), ), None => serve_stdio(&mut app), } } /// Headless one-shot format conversion (`--export IN OUT`). Loads `input`, /// writes `output` (format chosen from `output`'s extension), and returns a /// process exit code (0 on success). No window is created. #[cfg(not(target_arch = "wasm32"))] pub fn export_headless( input: &std::path::Path, output: &std::path::Path, target_version: Option<&str>, ) -> i32 { let doc = match crate::io::load_file(input) { Ok(doc) => doc, Err(e) => { eprintln!("export: cannot read {}: {e}", input.display()); return 1; } }; let version = match target_version { Some(value) => match crate::io::parse_target_version(value) { Ok(version) => version, Err(e) => { eprintln!("export: {e}"); return 2; } }, None => doc.version, }; match crate::io::save_as_version(&doc, output, version) { Ok(()) => { println!("Exported {} → {}", input.display(), output.display()); 0 } Err(e) => { eprintln!("export: cannot write {}: {e}", output.display()); 1 } } } /// `--port ` if present on the command line. #[cfg(not(target_arch = "wasm32"))] fn port_arg() -> Option { let mut args = std::env::args(); while let Some(a) = args.next() { if a == "--port" { return args.next().and_then(|s| s.parse().ok()); } } None } #[cfg(not(target_arch = "wasm32"))] fn ready() -> Value { json!({ "ok": true, "ready": true, "version": env!("OCS_APP_VERSION"), "session_id": super::control::session_id(), }) } #[cfg(not(target_arch = "wasm32"))] fn serve_stdio(app: &mut OpenCADStudio) { let stdin = std::io::stdin(); let stdout = std::io::stdout(); { let mut o = stdout.lock(); let _ = writeln!(o, "{}", ready()); let _ = o.flush(); } for line in lines_capped(stdin.lock(), MAX_LINE_BYTES) { let Ok(line) = line else { break }; let line = line.trim(); if line.is_empty() { continue; } let resp = app.automation_op(line); let mut o = stdout.lock(); let _ = writeln!(o, "{resp}"); let _ = o.flush(); } } /// Idle timeout for one `--serve` socket client — the same 15 s every other /// transport in the codebase uses (`rest.rs`, `http_bridge.rs`, /// `control::transport`), and the only one this path was missing. #[cfg(not(target_arch = "wasm32"))] const SERVE_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); /// Serve one socket client at a time on `127.0.0.1:port`. An idle client is /// disconnected after [`SERVE_IDLE_TIMEOUT`] — the accept loop is /// single-threaded, so a read that never completes would wedge the port for /// every later client. The document session survives reconnects. #[cfg(not(target_arch = "wasm32"))] fn serve_socket( app: &mut OpenCADStudio, port: u16, bound_port: &std::sync::Arc, ) { serve_socket_with_idle(app, port, bound_port, SERVE_IDLE_TIMEOUT) } /// [`serve_socket`] with an explicit per-client idle timeout (a test seam; /// production traffic uses [`SERVE_IDLE_TIMEOUT`]). #[cfg(not(target_arch = "wasm32"))] fn serve_socket_with_idle( app: &mut OpenCADStudio, port: u16, bound_port: &std::sync::Arc, idle: std::time::Duration, ) { let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { eprintln!("--serve: cannot bind 127.0.0.1:{port}: {e}"); return; } }; let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port); bound_port.store(bound, std::sync::atomic::Ordering::SeqCst); eprintln!("OpenCADStudio --serve listening on 127.0.0.1:{bound}"); crate::rest::announce_token(); for stream in listener.incoming().flatten() { let _ = stream.set_read_timeout(Some(idle)); let Ok(read_half) = stream.try_clone() else { continue; }; let reader = std::io::BufReader::new(read_half); let mut writer = stream; let _ = writeln!(writer, "{}", ready()); let _ = writer.flush(); for line in lines_capped(reader, MAX_LINE_BYTES) { let Ok(line) = line else { break }; let line = line.trim(); if line.is_empty() { continue; } // Loopback is shared by every process and user on the machine: // each request carries the session token, like the GUI's own // channel, and a connection that fails it is dropped. let mut request: Value = serde_json::from_str(line).unwrap_or(Value::Null); if !crate::rest::token_matches(request["token"].as_str()) { let _ = writeln!(writer, "{}", crate::rest::unauthorized()); break; } if let Some(object) = request.as_object_mut() { object.remove("token"); } let resp = app.automation_op(&request.to_string()); if writeln!(writer, "{resp}").is_err() { break; } let _ = writer.flush(); } } } /// The entities inside block definitions: owned by (or listed in) a block /// record that is not a layout's model or paper space. fn definition_members(document: &codec::CadDocument) -> std::collections::HashSet { let mut layouts: std::collections::HashSet = document .objects .values() .filter_map(|object| match object { codec::objects::ObjectType::Layout(layout) if !layout.block_record.is_null() => { Some(layout.block_record) } _ => None, }) .collect(); layouts.extend(document.block_records.iter().filter_map(|record| { let name = record.name.to_ascii_uppercase(); (name.starts_with("*MODEL_SPACE") || name.starts_with("*PAPER_SPACE")) .then_some(record.handle) })); let definitions: std::collections::HashSet = document .block_records .iter() .filter(|record| !layouts.contains(&record.handle)) .map(|record| record.handle) .collect(); let mut members: std::collections::HashSet = document .block_records .iter() .filter(|record| definitions.contains(&record.handle)) .flat_map(|record| record.entity_handles.iter().copied()) .collect(); members.extend( document .entities() .filter(|e| definitions.contains(&e.common().owner_handle)) .map(|e| e.common().handle), ); members } fn err(msg: impl std::fmt::Display) -> Value { json!({ "ok": false, "error": msg.to_string() }) } fn v3(v: codec::types::Vector3) -> Value { json!([v.x, v.y, v.z]) } pub(crate) fn entity_type_matches(entity: &codec::EntityType, requested: &str) -> bool { if crate::entities::names::ui_name(entity).eq_ignore_ascii_case(requested) { return true; } let record_name = crate::entities::names::dxf_name(entity); record_name != "ENTITY" && record_name.eq_ignore_ascii_case(requested) } /// One entity as JSON. Summary mode carries identity only, geometry adds the /// entity's defining values, and full also includes its world bounds. /// /// `custom_label` is the plugin-declared class label for the entity's XDATA /// class identity, if a live declaring plugin provides one; it surfaces as /// `display_type` ("`