feat(open): open drawings in the running editor

Double-clicking a drawing started a whole second copy of the app. Now it
lands as a tab in the editor already open, and that window comes forward.

The election is the bind itself: every GUI launch tries a deterministic
per-user loopback port, and the OS grants it to exactly one process and
releases it on any death, SIGKILL included. Nothing to reap, no PID to
probe, no stale lock after a crash. The winner serves the port from an
iced subscription; the loser checks it is talking to a matching editor,
hands the paths over and exits. Every surprise — no answer, a stranger on
the port, a timeout — boots a normal window, i.e. the old behaviour: the
feature can degrade, but it cannot lose the file.

The gate in main is positional, after every headless mode has returned.
The plugin runner is this same binary re-spawning itself, so a flag list
would rot the first time a mode is added; a position cannot.

Handing a drawing over that is already open switches to its tab instead
of loading a second copy, comparing resolved paths so a symlink or a
`..` hop is still recognised as the same file.

`opening` is a single slot that a second open would overwrite, and
on_file_opened drops any result arriving once it is clear — so drawings
handed over at once would silently vanish. They queue in pending_opens,
drained from every path that clears the slot, error and cancel included.

Exec becomes %F so one launch takes the whole selection; the CLI takes a
list and boot fans it out through the same door. --new-instance forces a
separate process; --read-only / --script / --new always get their own.

Not reused: the automation server. It dispatches arbitrary commands and
writes arbitrary paths, and its open replaces the active tab's document
in place — publishing that on a port every local process can reach would
be unforced privilege escalation. Only its JSON-lines framing is shared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-15 15:18:16 +03:00
commit b3bd93d8c5
11 changed files with 665 additions and 19 deletions

View file

@ -1,7 +1,7 @@
[Desktop Entry] [Desktop Entry]
Name=Open CAD Studio Name=Open CAD Studio
Comment=A CAD application for 2D/3D drawing and design Comment=A CAD application for 2D/3D drawing and design
Exec=OpenCADStudio %f Exec=OpenCADStudio %F
Icon=io.github.HakanSeven12.OpenCadStudio Icon=io.github.HakanSeven12.OpenCadStudio
Terminal=false Terminal=false
Type=Application Type=Application

View file

@ -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] #[test]
fn save_then_open_round_trips() { fn save_then_open_round_trips() {
let mut app = OpenCADStudio::new_for_test(); let mut app = OpenCADStudio::new_for_test();

View file

@ -685,6 +685,13 @@ pub(super) struct OpenCADStudio {
/// `Some` while a CAD file is loading — drives the modal overlay. /// `Some` while a CAD file is loading — drives the modal overlay.
/// Cleared when the load finishes, errors, or the user cancels. /// Cleared when the load finishes, errors, or the user cancels.
pub(super) opening: Option<OpenProgress>, pub(super) opening: Option<OpenProgress>,
/// 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<PathBuf>,
// ── Unsaved-changes dialog ──────────────────────────────────────────── // ── Unsaved-changes dialog ────────────────────────────────────────────
/// Set when the user tries to close a tab or quit while there are unsaved changes. /// 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 /// Open a path from the Start tab's recent-documents list (skips the
/// file picker; the path is already known). /// file picker; the path is already known).
OpenRecent(PathBuf), 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). /// Open a URL in the system browser (start-page intro video, links).
OpenUrl(String), OpenUrl(String),
/// Select which section a narrow (tabbed) Start page shows. /// Select which section a narrow (tabbed) Start page shows.
@ -2260,6 +2270,7 @@ impl OpenCADStudio {
plot_dialog: crate::ui::window::plot::PlotDialogState::default(), plot_dialog: crate::ui::window::plot::PlotDialogState::default(),
plot_prev: None, plot_prev: None,
opening: None, opening: None,
pending_opens: std::collections::VecDeque::new(),
pending_close: None, pending_close: None,
save_dialog_format: "DWG 2018".to_string(), save_dialog_format: "DWG 2018".to_string(),
save_dialog_filename: "drawing.dwg".to_string(), save_dialog_filename: "drawing.dwg".to_string(),
@ -2500,16 +2511,23 @@ impl OpenCADStudio {
Message::UpdateCheckResult, Message::UpdateCheckResult,
); );
let focus_cmd = s.focus_cmd_input(); let focus_cmd = s.focus_cmd_input();
// Startup configuration from the command line (see `cli`). A file // Startup configuration from the command line (see `cli`). File
// argument — also how the OS file association launches us when a .dwg // arguments — also how the OS file association launches us when
// is double-clicked — opens via `OpenRecent`, which existence-checks // drawings are double-clicked — go through `OpenExternal`, the same
// the path and reports a clean error if it is bogus. `--new` opens a // door a second launch hands files to: it existence-checks each path,
// fresh drawing tab instead of the welcome screen. `--read-only` // skips one already open, and queues the rest behind the load in
// disables saving. `--script` queues command lines to run once up. // 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(); let cfg = crate::cli::gui_config();
s.read_only = cfg.read_only; s.read_only = cfg.read_only;
let cli_open: Task<Message> = if let Some(p) = cfg.file { let cli_open: Task<Message> = if !cfg.files.is_empty() {
Task::done(Message::OpenRecent(p)) Task::batch(
cfg.files
.into_iter()
.map(|p| Task::done(Message::OpenExternal(p))),
)
} else if cfg.new { } else if cfg.new {
Task::done(Message::TabNew) Task::done(Message::TabNew)
} else { } else {

View file

@ -350,6 +350,35 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
} }
} }
/// 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<usize> {
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<Message> {
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<Message> { pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument, caches: crate::scene::DerivedCaches) -> Task<Message> {
// If the user clicked Cancel while the parser was running, the // If the user clicked Cancel while the parser was running, the
// overlay state was cleared and we silently drop the result. // overlay state was cleared and we silently drop the result.
@ -558,7 +587,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.tabs[i].dirty = false; self.tabs[i].dirty = false;
self.tabs[i].history = crate::app::document::HistoryState::default(); self.tabs[i].history = crate::app::document::HistoryState::default();
self.refresh_selected_grips(); 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<Message> { pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf) -> Task<Message> {

View file

@ -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) => { Message::RecentRemove(path) => {
self.remove_recent(&path); self.remove_recent(&path);
Task::none() Task::none()
@ -318,7 +357,7 @@ impl OpenCADStudio {
self.command_line self.command_line
.push_info(&format!("Open cancelled: \"{}\"", p.name)); .push_info(&format!("Open cancelled: \"{}\"", p.name));
} }
Task::none() self.drain_pending_open()
} }
Message::FileOpened(Ok((name, path, doc, caches))) => { Message::FileOpened(Ok((name, path, doc, caches))) => {
@ -332,7 +371,9 @@ impl OpenCADStudio {
if was_open && e != "Cancelled" { if was_open && e != "Cancelled" {
self.command_line.push_error(&format!("Open failed: {e}")); 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 => { Message::ImagePick => {

View file

@ -1677,6 +1677,12 @@ impl OpenCADStudio {
}; };
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
let autosave = Subscription::none(); 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([ iced::Subscription::batch([
frames, frames,
history_tick, history_tick,
@ -1686,6 +1692,7 @@ impl OpenCADStudio {
caret_blink, caret_blink,
web_fonts, web_fonts,
autosave, autosave,
single_instance,
event::listen_with(|ev, status, win_id| { event::listen_with(|ev, status, win_id| {
use iced::event::Status; use iced::event::Status;
match ev { match ev {

View file

@ -24,14 +24,20 @@ use clap::Parser;
long_about = None, long_about = None,
)] )]
pub struct Cli { pub struct Cli {
/// CAD file to open at startup (.dwg / .dxf). Also used by the OS file /// CAD files to open at startup (.dwg / .dxf). Also how the OS file
/// association when a drawing is double-clicked. /// association launches us when drawings are double-clicked — selecting
pub file: Option<PathBuf>, /// several hands them all to one launch, so this takes a list.
pub files: Vec<PathBuf>,
/// Start with a new empty drawing, ignoring any file argument. /// Start with a new empty drawing, ignoring any file argument.
#[arg(long)] #[arg(long)]
pub new: bool, 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. /// Open read-only: editing is allowed but saving is disabled.
#[arg(long)] #[arg(long)]
pub read_only: bool, pub read_only: bool,
@ -78,8 +84,8 @@ pub struct Cli {
/// because the iced daemon's boot closure takes no arguments. /// because the iced daemon's boot closure takes no arguments.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct GuiConfig { pub struct GuiConfig {
/// File to open on launch (`None` for a blank session). /// Files to open on launch (empty for a blank session).
pub file: Option<PathBuf>, pub files: Vec<PathBuf>,
/// Open a fresh drawing tab on launch instead of the welcome screen. /// Open a fresh drawing tab on launch instead of the welcome screen.
pub new: bool, pub new: bool,
/// Saving disabled for this session. /// Saving disabled for this session.

View file

@ -503,7 +503,7 @@ mod linux_impl {
"[Desktop Entry]\n\ "[Desktop Entry]\n\
Name=Open CAD Studio\n\ Name=Open CAD Studio\n\
Comment=A CAD application for 2D/3D drawing and design\n\ Comment=A CAD application for 2D/3D drawing and design\n\
Exec={exec} %f\n\ Exec={exec} %F\n\
Icon={APP_ID}\n\ Icon={APP_ID}\n\
Terminal=false\n\ Terminal=false\n\
Type=Application\n\ Type=Application\n\

View file

@ -5,6 +5,8 @@
pub mod file_association; pub mod file_association;
pub mod obj; pub mod obj;
#[cfg(not(target_arch = "wasm32"))]
pub mod single_instance;
pub mod pdf_export; pub mod pdf_export;
pub mod plot_style; pub mod plot_style;
pub mod print_to_printer; pub mod print_to_printer;

432
src/io/single_instance.rs Normal file
View file

@ -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<Option<TcpListener>> = 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<String> = 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<PathBuf> {
// `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<PathBuf>;
fn worker() -> impl iced::futures::Stream<Item = PathBuf> {
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::<Value>(&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::<PathBuf>(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);
}
}

View file

@ -100,6 +100,29 @@ fn main() -> iced::Result {
std::process::exit(code); 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. // GUI: stash the startup config for `app::boot` to pick up.
let script_lines = args let script_lines = args
.script .script
@ -118,7 +141,7 @@ fn main() -> iced::Result {
}) })
.unwrap_or_default(); .unwrap_or_default();
let _ = cli::GUI_CONFIG.set(cli::GuiConfig { 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, new: args.new,
read_only: args.read_only, read_only: args.read_only,
script_lines, script_lines,