From 0641cb3f3eb10f7f1812ccbe77743c78231b46e2 Mon Sep 17 00:00:00 2001 From: Fini Date: Fri, 31 Jul 2026 00:32:56 +0800 Subject: [PATCH] fix(desktop): reject conflicting headless modes --- crates/op-host-desktop/src/main.rs | 33 ++++++++++++++++++++++++ crates/op-host-desktop/src/main_tests.rs | 32 +++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index 55742b720..b38997dbe 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -98,6 +98,7 @@ mod window_resize; mod window_state; use op_host_native::{NativeBackend, SharedSkiaContext, SharedSkiaError, WidgetHostNative}; +use std::ffi::OsStr; use std::path::PathBuf; use std::time::Instant; use winit::event_loop::{ControlFlow, EventLoop, EventLoopProxy}; @@ -538,6 +539,31 @@ fn init_tracing() { .try_init(); } +fn conflicting_headless_modes(args: I) -> Option> +where + I: IntoIterator, + S: AsRef, +{ + const MODE_FLAGS: [&str; 6] = [ + "--mcp", + "--mcp-http", + "--serve-web", + "--tcc-selftest", + "--render-shots", + "--enrich-images", + ]; + let mut requested = Vec::new(); + for arg in args { + let arg = arg.as_ref(); + for mode in MODE_FLAGS { + if arg == OsStr::new(mode) && !requested.contains(&mode) { + requested.push(mode); + } + } + } + (requested.len() > 1).then_some(requested) +} + fn main() { // FIRST, before any thread exists: graft the login-shell PATH and proxy // exports onto this process. A Dock/Finder launch inherits launchd's @@ -566,6 +592,13 @@ fn main() { Err(err) => eprintln!("[fonts] skipping imported-font rescan: {err}"), } init_tracing(); + if let Some(modes) = conflicting_headless_modes(std::env::args_os().skip(1)) { + eprintln!( + "openpencil-desktop: headless modes are mutually exclusive: {}", + modes.join(", ") + ); + std::process::exit(2); + } // `--mcp` / `--mcp-http` swap the GUI for an MCP server mode; // when one of those ran, exit instead of opening a window. if mcp_serve::run_cli_if_requested() { diff --git a/crates/op-host-desktop/src/main_tests.rs b/crates/op-host-desktop/src/main_tests.rs index c7a01c470..385c7a2a6 100644 --- a/crates/op-host-desktop/src/main_tests.rs +++ b/crates/op-host-desktop/src/main_tests.rs @@ -4,6 +4,38 @@ use super::*; use winit::keyboard::{Key, NamedKey}; +#[test] +fn distinct_headless_modes_are_rejected_before_dispatch() { + assert_eq!( + conflicting_headless_modes([ + "--render-shots", + "input.op", + "shots", + "--enrich-images", + "input.op", + "output.op", + ]), + Some(vec!["--render-shots", "--enrich-images"]) + ); + assert_eq!( + conflicting_headless_modes(["--mcp-http", "3100", "input.op", "--tcc-selftest"]), + Some(vec!["--mcp-http", "--tcc-selftest"]) + ); +} + +#[test] +fn one_headless_mode_or_repeated_same_flag_is_not_a_conflict() { + assert_eq!( + conflicting_headless_modes(["--render-shots", "input.op", "shots"]), + None + ); + assert_eq!( + conflicting_headless_modes(["--enrich-images", "input.op", "--enrich-images"]), + None + ); + assert_eq!(conflicting_headless_modes(["document.op"]), None); +} + #[test] fn cursor_only_redraw_without_visible_state_change_skips_present() { let mut app = DesktopApp::new(None);