diff --git a/packaging/OpenCADStudio.desktop b/packaging/OpenCADStudio.desktop index bd685a97..3fe25cbb 100644 --- a/packaging/OpenCADStudio.desktop +++ b/packaging/OpenCADStudio.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Name=Open CAD Studio Comment=A CAD application for 2D/3D drawing and design -Exec=OpenCADStudio %f +Exec=OpenCADStudio %F Icon=io.github.HakanSeven12.OpenCadStudio Terminal=false Type=Application diff --git a/src/app/automation.rs b/src/app/automation.rs index b1dcaefb..b5ff3f74 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -541,6 +541,94 @@ mod tests { ); } + #[test] + fn handing_over_an_already_open_drawing_switches_to_its_tab() { + // Double-clicking a drawing that is already open should land on the tab + // showing it, not load a second copy of the same file. + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + + let path = std::env::temp_dir().join("ocs_already_open.dwg"); + std::fs::write(&path, b"x").unwrap(); + let canon = std::fs::canonicalize(&path).unwrap(); + + // Two tabs, the second holding the drawing; leave the first active. + app.tabs + .push(crate::app::document::DocumentTab::new_drawing(99)); + let target = app.tabs.len() - 1; + app.tabs[target].current_path = Some(canon.clone()); + app.active_tab = 0; + + let _ = app.update(Message::OpenExternal(canon.clone())); + assert_eq!(app.active_tab, target, "should have switched to the tab"); + assert!( + app.opening.is_none(), + "an already-open drawing must not start a load" + ); + assert!(app.pending_opens.is_empty(), "and must not queue one either"); + + // The same file spelled differently (a `..` hop) is still the same file. + let indirect = canon.parent().unwrap().join("..").join( + canon + .strip_prefix(canon.parent().unwrap().parent().unwrap()) + .unwrap(), + ); + app.active_tab = 0; + let _ = app.update(Message::OpenExternal(indirect)); + assert_eq!( + app.active_tab, target, + "an unresolved spelling of the same path must still match the tab" + ); + assert!(app.opening.is_none(), "still no second load"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn a_second_handoff_queues_instead_of_displacing_the_first() { + // `opening` is one slot, and `on_file_opened` drops any result that + // arrives once it is clear — so without the queue, two drawings handed + // over at the same moment (select several files in a file manager: one + // process each, all arriving together) would leave one tab and silently + // lose the rest. + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + + // Any existing file will do: OpenRecent only stats it, and the actual + // load is an async Task this test drops. + let dir = std::env::temp_dir(); + let (a, b) = (dir.join("ocs_si_a.dwg"), dir.join("ocs_si_b.dwg")); + std::fs::write(&a, b"x").unwrap(); + std::fs::write(&b, b"x").unwrap(); + + let _ = app.update(Message::OpenExternal(a.clone())); + assert!( + app.opening.is_some(), + "first handoff should start an open, not queue" + ); + assert!(app.pending_opens.is_empty(), "nothing to queue yet"); + + let _ = app.update(Message::OpenExternal(b.clone())); + assert_eq!( + app.pending_opens.len(), + 1, + "second handoff arriving mid-open must queue, not be dropped" + ); + assert_eq!(app.pending_opens.front(), Some(&b)); + + // A drawing that fails to parse must still release the queue behind it. + let _ = app.update(Message::FileOpened(Err("boom".into()))); + assert!( + app.pending_opens.is_empty(), + "a failed open must drain the queue, not strand it" + ); + + let _ = std::fs::remove_file(&a); + let _ = std::fs::remove_file(&b); + } + #[test] fn save_then_open_round_trips() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/mod.rs b/src/app/mod.rs index e7cedeed..fa285718 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -685,6 +685,13 @@ pub(super) struct OpenCADStudio { /// `Some` while a CAD file is loading — drives the modal overlay. /// Cleared when the load finishes, errors, or the user cancels. pub(super) opening: Option, + /// Drawings handed to us by other launches while `opening` was busy. + /// `opening` is a single slot that a second `OpenPathPicked` would + /// overwrite, and `on_file_opened` drops any result arriving once it is + /// clear — so overlapping opens silently lose documents. Selecting several + /// drawings in a file manager produces exactly that (one process per file, + /// all arriving at once), which makes this queue load-bearing, not polish. + pub(super) pending_opens: std::collections::VecDeque, // ── Unsaved-changes dialog ──────────────────────────────────────────── /// Set when the user tries to close a tab or quit while there are unsaved changes. @@ -1230,6 +1237,9 @@ pub enum Message { /// Open a path from the Start tab's recent-documents list (skips the /// file picker; the path is already known). OpenRecent(PathBuf), + /// A second launch handed us a drawing to open (single instance). Queued + /// behind any open already in flight — see `pending_opens`. + OpenExternal(PathBuf), /// Open a URL in the system browser (start-page intro video, links). OpenUrl(String), /// Select which section a narrow (tabbed) Start page shows. @@ -2260,6 +2270,7 @@ impl OpenCADStudio { plot_dialog: crate::ui::window::plot::PlotDialogState::default(), plot_prev: None, opening: None, + pending_opens: std::collections::VecDeque::new(), pending_close: None, save_dialog_format: "DWG 2018".to_string(), save_dialog_filename: "drawing.dwg".to_string(), @@ -2500,16 +2511,23 @@ impl OpenCADStudio { Message::UpdateCheckResult, ); let focus_cmd = s.focus_cmd_input(); - // Startup configuration from the command line (see `cli`). A file - // argument — also how the OS file association launches us when a .dwg - // is double-clicked — opens via `OpenRecent`, which existence-checks - // the path and reports a clean error if it is bogus. `--new` opens a - // fresh drawing tab instead of the welcome screen. `--read-only` - // disables saving. `--script` queues command lines to run once up. + // Startup configuration from the command line (see `cli`). File + // arguments — also how the OS file association launches us when + // drawings are double-clicked — go through `OpenExternal`, the same + // door a second launch hands files to: it existence-checks each path, + // skips one already open, and queues the rest behind the load in + // flight. Handing them straight to `OpenRecent` would let each + // overwrite the single `opening` slot and silently drop all but one. + // `--new` opens a fresh drawing tab instead of the welcome screen. + // `--read-only` disables saving. `--script` queues command lines. let cfg = crate::cli::gui_config(); s.read_only = cfg.read_only; - let cli_open: Task = if let Some(p) = cfg.file { - Task::done(Message::OpenRecent(p)) + let cli_open: Task = if !cfg.files.is_empty() { + Task::batch( + cfg.files + .into_iter() + .map(|p| Task::done(Message::OpenExternal(p))), + ) } else if cfg.new { Task::done(Message::TabNew) } else { diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 0bba88e4..47748572 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -350,6 +350,35 @@ pub(super) fn on_open_file(&mut self) -> Task { } } + /// Index of a tab already showing `path`, or `None`. + /// + /// Compares resolved paths, so the same drawing reached through a symlink, + /// a `..` segment or a different relative spelling is recognised as the one + /// already open rather than loaded a second time. A path that cannot be + /// resolved (deleted since) matches nothing and falls through to the normal + /// open, which reports the miss. + pub(in crate::app) fn tab_showing(&self, path: &std::path::Path) -> Option { + let want = std::fs::canonicalize(path).ok()?; + self.tabs.iter().position(|t| { + t.current_path + .as_deref() + .and_then(|p| std::fs::canonicalize(p).ok()) + .is_some_and(|p| p == want) + }) + } + + /// Start the next drawing a second launch handed us, if any. + /// + /// Must be called from EVERY path that clears `opening` — completion, error + /// and cancel alike. Draining only the success path would strand the queue + /// forever the first time a file fails to parse. + pub(in crate::app) fn drain_pending_open(&mut self) -> Task { + match self.pending_opens.pop_front() { + Some(p) => Task::done(Message::OpenExternal(p)), + None => Task::none(), + } + } + pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument, caches: crate::scene::DerivedCaches) -> Task { // If the user clicked Cancel while the parser was running, the // overlay state was cleared and we silently drop the result. @@ -558,7 +587,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].dirty = false; self.tabs[i].history = crate::app::document::HistoryState::default(); self.refresh_selected_grips(); - Task::none() + self.drain_pending_open() } pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf) -> Task { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index f6204e89..50c2cc1b 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -272,6 +272,45 @@ impl OpenCADStudio { } } + Message::OpenExternal(path) => { + // A second launch forwarded this drawing. Route it through + // `OpenRecent` so the redirect and a cold start share one path: + // it stats the file and reports a missing one visibly, instead + // of a boot that appears to do nothing. + // + // Raising is best-effort and cannot be made reliable from here. + // `gain_focus` reaches winit's `focus_window`, which on Wayland + // has an empty body — it is `request_user_attention` that walks + // the xdg-activation path, and it mints its token without a seat + // serial, which a compositor may refuse to honour. So expect an + // attention mark rather than a raise on Wayland; X11 does raise. + // A real raise needs the activation token from the launching + // process, and neither iced 0.14 nor winit 0.30 can apply one to + // an existing window. + let raise = match self.main_window { + Some(id) => Task::batch([ + iced::window::gain_focus(id), + iced::window::request_user_attention( + id, + Some(iced::window::UserAttention::Critical), + ), + ]), + None => Task::none(), + }; + // Already open → go to that tab rather than load a second copy + // of the same drawing. Checked before the queue: switching is + // instant and needs no load slot. + if let Some(idx) = self.tab_showing(&path) { + return Task::batch([raise, self.update(Message::TabSwitch(idx))]); + } + if self.opening.is_some() { + self.pending_opens.push_back(path); + raise + } else { + Task::batch([raise, self.update(Message::OpenRecent(path))]) + } + } + Message::RecentRemove(path) => { self.remove_recent(&path); Task::none() @@ -318,7 +357,7 @@ impl OpenCADStudio { self.command_line .push_info(&format!("Open cancelled: \"{}\"", p.name)); } - Task::none() + self.drain_pending_open() } Message::FileOpened(Ok((name, path, doc, caches))) => { @@ -332,7 +371,9 @@ impl OpenCADStudio { if was_open && e != "Cancelled" { self.command_line.push_error(&format!("Open failed: {e}")); } - Task::none() + // A drawing that fails to parse must not strand the ones queued + // behind it. + self.drain_pending_open() } Message::ImagePick => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 474fce29..caa7925e 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1677,6 +1677,12 @@ impl OpenCADStudio { }; #[cfg(target_arch = "wasm32")] let autosave = Subscription::none(); + // Drawings handed over by a second launch (single instance). Inert in a + // process that lost the port election, so it costs nothing there. + #[cfg(not(target_arch = "wasm32"))] + let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal); + #[cfg(target_arch = "wasm32")] + let single_instance = Subscription::none(); iced::Subscription::batch([ frames, history_tick, @@ -1686,6 +1692,7 @@ impl OpenCADStudio { caret_blink, web_fonts, autosave, + single_instance, event::listen_with(|ev, status, win_id| { use iced::event::Status; match ev { diff --git a/src/cli.rs b/src/cli.rs index d45df764..b93e81a7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -24,14 +24,20 @@ use clap::Parser; long_about = None, )] pub struct Cli { - /// CAD file to open at startup (.dwg / .dxf). Also used by the OS file - /// association when a drawing is double-clicked. - pub file: Option, + /// CAD files to open at startup (.dwg / .dxf). Also how the OS file + /// association launches us when drawings are double-clicked — selecting + /// several hands them all to one launch, so this takes a list. + pub files: Vec, /// Start with a new empty drawing, ignoring any file argument. #[arg(long)] pub new: bool, + /// Always start a new editor process, even when one is already running. + /// Without this, opening a drawing hands it to the running editor as a tab. + #[arg(long)] + pub new_instance: bool, + /// Open read-only: editing is allowed but saving is disabled. #[arg(long)] pub read_only: bool, @@ -78,8 +84,8 @@ pub struct Cli { /// because the iced daemon's boot closure takes no arguments. #[derive(Debug, Default, Clone)] pub struct GuiConfig { - /// File to open on launch (`None` for a blank session). - pub file: Option, + /// Files to open on launch (empty for a blank session). + pub files: Vec, /// Open a fresh drawing tab on launch instead of the welcome screen. pub new: bool, /// Saving disabled for this session. diff --git a/src/io/file_association.rs b/src/io/file_association.rs index 8d2514e5..f2a1ee21 100644 --- a/src/io/file_association.rs +++ b/src/io/file_association.rs @@ -503,7 +503,7 @@ mod linux_impl { "[Desktop Entry]\n\ Name=Open CAD Studio\n\ Comment=A CAD application for 2D/3D drawing and design\n\ - Exec={exec} %f\n\ + Exec={exec} %F\n\ Icon={APP_ID}\n\ Terminal=false\n\ Type=Application\n\ diff --git a/src/io/mod.rs b/src/io/mod.rs index b53014a2..9db93793 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -5,6 +5,8 @@ pub mod file_association; pub mod obj; +#[cfg(not(target_arch = "wasm32"))] +pub mod single_instance; pub mod pdf_export; pub mod plot_style; pub mod print_to_printer; diff --git a/src/io/single_instance.rs b/src/io/single_instance.rs new file mode 100644 index 00000000..d55ca9ba --- /dev/null +++ b/src/io/single_instance.rs @@ -0,0 +1,432 @@ +//! Single instance: a double-clicked drawing opens as a tab in the editor that +//! is already running, instead of starting a second one. +//! +//! The election *is* the `bind`. Every GUI launch tries to bind a deterministic +//! per-user loopback port; the OS grants it to exactly one process and releases +//! it on any death, `SIGKILL` included. That deletes the whole staleness +//! category a lock file would carry — no PID to probe for liveness, no PID +//! recycling, no inode to unlink, nothing to reap after a crash. +//! +//! Whoever binds keeps the listener and serves it from an iced subscription. +//! Whoever fails to bind connects, checks it is talking to a matching editor, +//! hands the path over, and exits. Every surprise — no answer, a stranger on +//! the port, a timeout — falls back to booting a normal window, which is the +//! behaviour from before this module existed. The feature can degrade, but it +//! cannot lose the file. +//! +//! This port speaks exactly two ops, `ping` and `open`. The automation server +//! in [`crate::app::automation`] is deliberately unreachable from here: it +//! dispatches arbitrary commands (`run`) and writes arbitrary paths (`save`), +//! and its own `open` replaces the active tab's document in place — which would +//! discard the user's unsaved drawing. We reuse that server's line-delimited +//! JSON framing and nothing else. +//! +//! Upgrade path, if the squatted-port stall or the local-RPC surface ever +//! proves real: an `AF_UNIX` socket under `$XDG_RUNTIME_DIR`. Everything above +//! the transport survives that swap — build one or the other, never both. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::Duration; + +use serde_json::{json, Value}; + +/// Protocol tag. Bump the suffix on any wire-format change so a running older +/// editor is recognised as a stranger and both processes degrade cleanly +/// instead of misreading each other. +const MAGIC: &str = "OpenCADStudio/si/2"; + +/// Neither end blocks forever. Long enough to cover a busy primary's accept +/// backlog, short enough that a wedged peer costs a visible pause and not a +/// hang. +const IO_TIMEOUT: Duration = Duration::from_secs(2); + +/// The bound listener, parked between [`claim`] (which runs in `main`, before +/// iced exists) and [`subscribe`] (which runs inside the iced runtime). +/// A static is unavoidable: [`iced::Subscription::run`] takes a plain +/// `fn() -> S`, so the stream builder cannot capture anything. +static LISTENER: Mutex> = Mutex::new(None); + +/// Who we turned out to be. +pub enum Claim { + /// We own the port. Keep booting; [`subscribe`] will serve it. + Primary, + /// Someone else owns it — an editor, or a stranger. Connected stream. + Existing(TcpStream), +} + +/// What makes two launches "the same editor, for the same user, right here". +/// +/// All three parts are load-bearing: +/// * user — loopback is shared across accounts on one machine, so without it +/// one user's drawing would surface on another user's screen; +/// * session — the same user on two seats (or an SSH-forwarded display) must +/// not have files delivered to the other display; +/// * executable path — otherwise `cargo run` silently hands your test file to +/// an installed copy, which makes this feature hostile to maintain. +fn rendezvous_key() -> String { + let user = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_default(); + let session = std::env::var("XDG_SESSION_ID") + .or_else(|_| std::env::var("WAYLAND_DISPLAY")) + .or_else(|_| std::env::var("DISPLAY")) + .unwrap_or_default(); + let exe = std::env::current_exe() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + format!("{user}|{session}|{exe}") +} + +/// Deterministic port for this rendezvous key (FNV-1a, folded into a fixed +/// window). +/// +/// 29000..31000 sits below every target's ephemeral range (Linux 32768+, +/// Windows/macOS 49152+), so the OS never hands our port to a transient socket +/// while the editor is down. A stranger squatting it is still possible; that +/// costs one [`IO_TIMEOUT`] pause and then a normal boot — a considered trade, +/// not a magic number. +fn port_for_user() -> u16 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in rendezvous_key().bytes().chain(MAGIC.bytes()) { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + 29000 + (h % 2000) as u16 +} + +fn addr() -> SocketAddr { + SocketAddr::from((Ipv4Addr::LOCALHOST, port_for_user())) +} + +/// Try to become the editor that serves this user's double-clicks. +/// +/// Binds loopback only — never `0.0.0.0`, which would raise a firewall prompt +/// on Windows and expose the port to the network. +pub fn claim() -> Claim { + match TcpListener::bind(addr()) { + Ok(l) => { + *LISTENER.lock().unwrap_or_else(|e| e.into_inner()) = Some(l); + Claim::Primary + } + Err(_) => match TcpStream::connect_timeout(&addr(), IO_TIMEOUT) { + Ok(s) => Claim::Existing(s), + // Bound a moment ago, gone now: the holder exited between our bind + // and our connect. We hold no listener, so this window cannot serve + // — the next launch binds properly. Self-healing. + Err(_) => Claim::Primary, + }, + } +} + +/// Hand `paths` to the editor on the other end. `true` once it has acknowledged. +/// +/// Takes the whole selection in one message: `%F` in the desktop entry hands +/// every double-clicked drawing to a single launch, and forwarding them +/// together keeps them one unit rather than a race between connections. +/// +/// Pings first and only discloses the paths to a peer that answers with our own +/// [`MAGIC`] and rendezvous key, so a stranger — or a hash collision — learns +/// nothing about what the user is opening. +pub fn handoff(stream: TcpStream, paths: &[PathBuf]) -> bool { + let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); + let Ok(write_half) = stream.try_clone() else { + return false; + }; + let mut w = write_half; + let mut r = BufReader::new(stream); + + if writeln!(w, "{}", json!({ "op": "ping" })).is_err() || w.flush().is_err() { + return false; + } + let mut line = String::new(); + if r.read_line(&mut line).is_err() { + return false; + } + let ack: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => return false, + }; + if ack["app"].as_str() != Some(MAGIC) || ack["key"].as_str() != Some(rendezvous_key().as_str()) + { + return false; + } + + // Absolute, not canonical: the editor's working directory differs from + // ours, so a relative argument must be resolved here — but `canonicalize` + // would demand the file exist (we want the editor's own error message, not + // a silent boot) and on Windows yields a `\\?\` verbatim path that would + // land verbatim in the recents list. + let abs: Vec = paths + .iter() + .map(|p| { + std::path::absolute(p) + .unwrap_or_else(|_| p.clone()) + .to_string_lossy() + .into_owned() + }) + .collect(); + let req = json!({ "op": "open", "paths": abs }); + if writeln!(w, "{req}").is_err() || w.flush().is_err() { + return false; + } + line.clear(); + if r.read_line(&mut line).is_err() { + return false; + } + let done: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => return false, + }; + if done["ok"].as_bool() != Some(true) { + return false; + } + + // We are the foreground process — the file manager just launched us — so we + // hold the right to hand that privilege to the editor, which does not. + // Without this the drawing opens in a window that stays behind whatever the + // user was looking at, and the double-click reads as "nothing happened". + #[cfg(windows)] + if let Some(pid) = ack["pid"].as_u64() { + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::AllowSetForegroundWindow(pid as u32); + } + } + true +} + +/// Serve the claimed port: every accepted `open` yields its path. +/// +/// Inert unless [`claim`] returned [`Claim::Primary`] in this process, so a +/// window that lost the election simply never produces items. +pub fn subscribe() -> iced::Subscription { + // `worker` must stay a plain `fn` — `Subscription::run` keys the + // subscription's identity off the function pointer, so turning this into a + // closure would silently stop the listener with no error. + iced::Subscription::run(worker) +} + +type PathSender = iced::futures::channel::mpsc::Sender; + +fn worker() -> impl iced::futures::Stream { + iced::stream::channel(8, serve_claimed_port) +} + +async fn serve_claimed_port(out: PathSender) { + let listener = LISTENER.lock().unwrap_or_else(|e| e.into_inner()).take(); + let Some(listener) = listener else { + // Not the primary: park forever rather than end the stream, so iced + // does not re-run the recipe. + std::future::pending::<()>().await; + return; + }; + // `accept` blocks, so it lives on its own OS thread and only ever reaches + // the iced runtime through the non-blocking `try_send`. + std::thread::spawn(move || { + let mut out = out; + for stream in listener.incoming().flatten() { + serve_one(&mut out, stream); + } + }); + std::future::pending::<()>().await; +} + +/// One connection: a ping, then at most one open. +fn serve_one(out: &mut PathSender, stream: TcpStream) { + let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); + let Ok(write_half) = stream.try_clone() else { + return; + }; + let mut w = write_half; + let mut r = BufReader::new(stream); + let mut line = String::new(); + loop { + line.clear(); + if !r.read_line(&mut line).map(|n| n > 0).unwrap_or(false) { + return; + } + let Ok(req) = serde_json::from_str::(&line) else { + return; + }; + match req["op"].as_str() { + Some("ping") => { + let ack = json!({ + "ok": true, + "app": MAGIC, + "key": rendezvous_key(), + "pid": std::process::id(), + }); + if writeln!(w, "{ack}").is_err() || w.flush().is_err() { + return; + } + } + Some("open") => { + // All-or-nothing: a partial send would silently drop drawings + // the user selected, which is the failure this whole path + // exists to avoid. + let ok = match req["paths"].as_array() { + Some(a) => a + .iter() + .filter_map(|p| p.as_str()) + .all(|p| out.try_send(PathBuf::from(p)).is_ok()), + None => false, + }; + let _ = writeln!(w, "{}", json!({ "ok": ok })); + let _ = w.flush(); + return; + } + _ => return, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn port_is_deterministic_and_in_the_reserved_window() { + let a = port_for_user(); + let b = port_for_user(); + assert_eq!(a, b, "same process must always compute the same port"); + assert!( + (29000..31000).contains(&a), + "port {a} escaped the reserved window" + ); + } + + #[test] + fn rendezvous_key_carries_user_session_and_exe() { + // All three parts must be present, or the isolation the key exists for + // is silently gone. + let k = rendezvous_key(); + assert_eq!(k.matches('|').count(), 2, "key shape changed: {k:?}"); + let exe = std::env::current_exe().unwrap(); + assert!( + k.ends_with(&*exe.to_string_lossy()), + "key must pin the executable: {k:?}" + ); + } + + #[test] + fn claim_elects_exactly_one_owner() { + // Hold the port the way a primary would, then prove a second claim in + // this process does not also think it is primary. + let held = match TcpListener::bind(addr()) { + Ok(l) => l, + // Another OCS (or a stray test binary) already owns it — the very + // condition under test cannot be set up, so skip rather than lie. + Err(_) => return, + }; + match claim() { + Claim::Existing(_) => {} + Claim::Primary => { + panic!("bind was already held; claim() must not elect a second owner") + } + } + drop(held); + } + + #[test] + fn handoff_refuses_a_stranger_on_the_port() { + // A squatter that answers something other than our ack must never be + // told which file the user is opening. + let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) { + Ok(l) => l, + Err(_) => return, + }; + let port = listener.local_addr().unwrap().port(); + let t = std::thread::spawn(move || { + if let Ok((s, _)) = listener.accept() { + let mut w = s.try_clone().unwrap(); + let mut r = BufReader::new(s); + let mut line = String::new(); + let _ = r.read_line(&mut line); + // Wrong app tag — a different program that happens to be here. + let _ = writeln!(w, "{}", json!({ "ok": true, "app": "something-else" })); + let _ = w.flush(); + // Read anything more: if handoff leaked the path, this sees it. + line.clear(); + let _ = r.read_line(&mut line); + line + } else { + String::new() + } + }); + let s = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap(); + assert!( + !handoff(s, &[PathBuf::from("/tmp/secret.dwg")]), + "handoff must reject a peer that fails the identity check" + ); + let leaked = t.join().unwrap(); + assert!( + !leaked.contains("secret.dwg"), + "path disclosed to a stranger: {leaked:?}" + ); + } + + #[test] + fn every_one_of_three_concurrent_handoffs_arrives() { + // Selecting three drawings launches three processes at once (measured: + // `%f` + 3 files = 3 spawns, one file each). All three must land. + let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) { + Ok(l) => l, + Err(_) => return, + }; + let port = listener.local_addr().unwrap().port(); + let (tx, mut rx) = iced::futures::channel::mpsc::channel::(8); + // The real accept loop, verbatim. + std::thread::spawn(move || { + let mut out = tx; + for stream in listener.incoming().flatten() { + serve_one(&mut out, stream); + } + }); + + let senders: Vec<_> = (0..3) + .map(|i| { + std::thread::spawn(move || { + let s = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap(); + handoff(s, &[PathBuf::from(format!("/tmp/ocs_concurrent_{i}.dwg"))]) + }) + }) + .collect(); + for (i, t) in senders.into_iter().enumerate() { + assert!(t.join().unwrap(), "handoff {i} reported failure"); + } + + let mut got = Vec::new(); + while let Ok(Some(p)) = rx.try_next() { + got.push(p.to_string_lossy().into_owned()); + } + got.sort(); + assert_eq!(got.len(), 3, "expected all three paths, got {got:?}"); + } + + #[test] + fn handoff_gives_up_on_a_peer_that_never_answers() { + // A wedged peer must cost a timeout, not a hang. + let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) { + Ok(l) => l, + Err(_) => return, + }; + let port = listener.local_addr().unwrap().port(); + let t = std::thread::spawn(move || { + let _held = listener.accept(); + std::thread::sleep(IO_TIMEOUT * 3); + }); + let s = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap(); + let start = std::time::Instant::now(); + assert!(!handoff(s, &[PathBuf::from("/tmp/a.dwg")])); + assert!( + start.elapsed() < IO_TIMEOUT * 2, + "handoff blocked for {:?} — the read timeout is not applied", + start.elapsed() + ); + drop(t); + } +} diff --git a/src/main.rs b/src/main.rs index 30bda886..4d8992ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,6 +100,29 @@ fn main() -> iced::Result { std::process::exit(code); } + // Single instance: a double-clicked drawing belongs as a tab in the + // editor that is already open, not in a second copy of the app. + // + // The gate is POSITIONAL, and that is the point: every headless mode + // has already returned above — the plugin runner, which is this same + // binary re-spawning itself, most of all. A flag list here would rot + // the first time a mode is added; a position cannot. + if !args.new_instance { + if let io::single_instance::Claim::Existing(stream) = io::single_instance::claim() { + // Only bare files forward. `--read-only` / `--script` / `--new` + // configure the whole editor rather than a tab, so they always + // get a process of their own. + let plain_open = + !args.read_only && args.script.is_none() && !args.new && !args.files.is_empty(); + if plain_open && io::single_instance::handoff(stream, &args.files) { + return Ok(()); + } + // Nothing to forward, or the far end never acknowledged: fall + // through and boot our own window. We hold no listener, so the + // editor that owns the port keeps serving. + } + } + // GUI: stash the startup config for `app::boot` to pick up. let script_lines = args .script @@ -118,7 +141,7 @@ fn main() -> iced::Result { }) .unwrap_or_default(); let _ = cli::GUI_CONFIG.set(cli::GuiConfig { - file: if args.new { None } else { args.file }, + files: if args.new { Vec::new() } else { args.files }, new: args.new, read_only: args.read_only, script_lines,