diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 05c756a0c..affb23466 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -1,6 +1,8 @@ #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] use super::gpu; use super::prefs::{save_prefs, save_prefs_soon}; +#[cfg(not(target_os = "macos"))] +use super::single_instance; #[cfg(all(windows, not(target_vendor = "win7")))] use super::windows_stylus; use super::{App, VectorcraftApp, file_access, window}; @@ -105,11 +107,13 @@ impl App { vectorcraft_ui_egui::background::wait_all(&mut self.app); return; } - // Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs - // again on its document when it answers. - #[cfg(target_os = "macos")] + // Files from the Finder and the Dock (macOS) or from later launches (#4892) wait while a file + // dialog is open: what asked for it runs again on its document when it answers. if !self.app.file_dialog_open() { + #[cfg(target_os = "macos")] let files = open_documents::take(); + #[cfg(not(target_os = "macos"))] + let files = single_instance::take(); if !files.is_empty() { bring_window_forward(ctx); } @@ -147,15 +151,15 @@ fn end_before_teardown(frames: u64) { } } -/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back -/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769). -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +/// Show the window when files are opened from the Finder or the Dock, or handed over by a later +/// launch (#4892): a minimized window comes back and the app takes focus, as a document-based app +/// does (#3769). pub(crate) fn bring_window_forward(ctx: &egui::Context) { ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } -/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that +/// Open files handed to the app (command line, macOS Finder and Dock, later launches) as documents. A file that /// can't be opened is reported to the user and on stderr; the others still open. pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec) { for f in files { diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 1e2ab4fae..33d27ab35 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1,7 +1,10 @@ //! VectorCraft desktop app. //! //! Usage: `vectorcraft [--control [--automation-read-root ]... [--automation-write-root ]...] -//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! [--in-window-menus] [--new-instance] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! +//! Files given while VectorCraft already runs open in that window, unless `--new-instance` is given +//! (Windows and Linux; `single_instance`). //! //! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on //! macOS instead of the macOS menu bar (`mac_menu`). @@ -45,6 +48,8 @@ mod open_documents; mod portal_save; mod prefs_dir; mod printing; +#[cfg(not(target_os = "macos"))] +mod single_instance; #[cfg(windows)] mod system_fonts; mod tablet; @@ -120,7 +125,9 @@ Options: (may be given more than once) --in-window-menus Keep the menus inside the window on macOS (or VECTORCRAFT_IN_WINDOW_MENUS=1) - --version Print the version and exit + --new-instance Open the files in a new window even when VectorCraft + already runs (Windows and Linux) + --version Print the version and exit -h, --help Print this help and exit Without a window (converting, scripting, the MCP server), use vectorcraft-cli: @@ -144,6 +151,7 @@ fn main() -> std::process::ExitCode { automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT")); let mut files = Vec::new(); let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); + let mut new_instance = false; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) { @@ -156,6 +164,7 @@ fn main() -> std::process::ExitCode { } match a.as_str() { "--in-window-menus" => in_window_menus = true, + "--new-instance" => new_instance = true, "--version" => { println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); return std::process::ExitCode::SUCCESS; @@ -185,6 +194,15 @@ fn main() -> std::process::ExitCode { }; // Before desktop services, so every start-up warning is recorded (`logging`). let logger = logging::install(); + // Files opened from the file manager while VectorCraft runs open in its window (#4892). Before + // the log file is attached: a launch that hands its files over leaves the running app's log be. + #[cfg(not(target_os = "macos"))] + let instance = match single_instance::start(control_port.is_some(), new_instance, &files) { + single_instance::Start::Forwarded => return std::process::ExitCode::SUCCESS, + single_instance::Start::Running(primary) => primary, + }; + #[cfg(target_os = "macos")] + let _ = new_instance; vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); // Before the first render: the cores the process may run on. #[cfg(windows)] @@ -457,7 +475,10 @@ fn main() -> std::process::ExitCode { } } #[cfg(not(target_os = "macos"))] - let _ = in_window_menus; + { + let _ = in_window_menus; + single_instance::set_ui(&cc.egui_ctx); + } app.services.native_input_hook = native_input_hook(&app); file_access::unconfined(|| open_files(&mut app, files)); // The first frame is due from now (`gpu::watch_first_frame`). @@ -478,6 +499,10 @@ fn main() -> std::process::ExitCode { }), ) }); + // The window is gone: later launches start their own app, including the one a graphics + // failure starts again below. + #[cfg(not(target_os = "macos"))] + drop(instance); #[cfg(feature = "wgpu")] let outcome = gpu::finish(outcome, &startup); #[cfg(not(feature = "wgpu"))] diff --git a/apps/vectorcraft/src/single_instance.rs b/apps/vectorcraft/src/single_instance.rs new file mode 100644 index 000000000..b8df2bb5a --- /dev/null +++ b/apps/vectorcraft/src/single_instance.rs @@ -0,0 +1,386 @@ +//! One app per user (#4892): a file opened from the file manager (double-click, Open With, a drop +//! on the executable) while VectorCraft runs opens as a new document tab in the running app, which +//! comes to the front, instead of in a second app. +//! +//! The running app (the primary) listens on a loopback port and records the port and two random +//! tokens in `instance.json` next to the preferences (owner-only on Unix; on Windows the settings +//! folder is the user's own). A later launch with files connects; the primary greets it with +//! `hello`, which only a process that could read the file (or talk to the primary) knows; only then +//! does the launch send `token` and the absolute paths of its files, one JSON line. The primary +//! checks the token, keeps the paths that are existing files, acknowledges and opens them; the +//! launch exits. Anything else (no file, no listener, a wrong greeting, no answer) starts the launch +//! as its own app, which replaces a stale file. Nothing received is ever run. +//! +//! Runs that start many apps side by side (`--control`, `VECTORCRAFT_NO_PREFS`) neither forward +//! nor listen, and `--new-instance` opens a launch's files in an app of its own. macOS hands files +//! opened from Finder to the running app itself (`open_documents`), so this is off there. + +use std::fs::{self, OpenOptions}; +use std::hash::{BuildHasher as _, RandomState}; +use std::io::{self, BufRead as _, BufReader, Read as _, Write as _}; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::prefs::{prefs_enabled, prefs_path}; + +/// The endpoint file, next to the preferences. +const FILE_NAME: &str = "instance.json"; +/// The longest message either side reads, and the longest endpoint file. +const MAX_MESSAGE: u64 = 64 * 1024; +/// The most files one launch hands over. +const MAX_FILES: usize = 256; +/// A live primary's kernel accepts at once, even while its window is busy; a stale port on Windows +/// would otherwise retry for about two seconds. +const CONNECT_TIMEOUT: Duration = Duration::from_millis(500); +/// How long the primary waits on one launch; it serves launches one at a time. +const SERVER_TIMEOUT: Duration = Duration::from_secs(2); +/// How long a launch waits on the primary: longer than a launch served before it can take. +const CLIENT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Paths handed over by later launches that the UI hasn't opened yet. +static PENDING: Mutex> = Mutex::new(Vec::new()); +/// The UI to wake when files arrive (unset until the window exists; the first frame opens them). +static UI: OnceLock = OnceLock::new(); + +/// What this launch does about an app already running. +pub enum Start { + /// The running app took the files: this launch exits. + Forwarded, + /// This launch runs; `Some` while it is the app later launches hand their files to. + Running(Option), +} + +/// Hand `files` to the app already running, or become the app later launches hand theirs to. +/// `automation`: started with `--control`. +pub fn start(automation: bool, new_instance: bool, files: &[String]) -> Start { + if automation || !prefs_enabled() { + return Start::Running(None); + } + let Some(path) = prefs_path().and_then(|p| Some(p.parent()?.join(FILE_NAME))) else { return Start::Running(None) }; + // With nothing to hand over, the same exchange only asks whether an app is running. + let send: &[String] = if new_instance { &[] } else { files }; + match forward(&path, send) { + Ok(()) if send.is_empty() => Start::Running(None), + Ok(()) => Start::Forwarded, + Err(e) => { + log::debug!("no running VectorCraft to hand files to ({e})"); + Start::Running(listen(&path, queue)) + } + } +} + +/// Wake `ctx` when files arrive from later launches. +pub fn set_ui(ctx: &egui::Context) { + // Set once; a second call would hand over the same context. + let _ = UI.set(ctx.clone()); +} + +/// The paths later launches handed over since the last call. +pub fn take() -> Vec { + std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner)) +} + +fn queue(files: Vec) { + PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(files); + if let Some(ctx) = UI.get() { + ctx.request_repaint(); + } +} + +/// This app is the one recorded in the endpoint file; dropping it removes the file, unless a later +/// app has replaced it since. +pub struct Primary { + path: PathBuf, + token: String, +} + +impl Drop for Primary { + fn drop(&mut self) { + if read_endpoint(&self.path).is_some_and(|e| e.token == self.token) { + // Best effort: a file left behind is stale, and the next launch replaces it. + let _ = fs::remove_file(&self.path); + } + } +} + +struct Endpoint { + port: u16, + token: String, + hello: String, +} + +fn invalid(why: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, why) +} + +fn read_endpoint(path: &Path) -> Option { + let mut text = String::new(); + fs::File::open(path).ok()?.take(MAX_MESSAGE).read_to_string(&mut text).ok()?; + let v: Value = serde_json::from_str(&text).ok()?; + let port = u16::try_from(v.get("port")?.as_u64()?).ok().filter(|p| *p != 0)?; + Some(Endpoint { port, token: v.get("token")?.as_str()?.to_owned(), hello: v.get("hello")?.as_str()?.to_owned() }) +} + +/// Hand `files` (made absolute against this launch's folder) to the app recorded at `path`; `Ok` +/// once it acknowledged them. +fn forward(path: &Path, files: &[String]) -> io::Result<()> { + let endpoint = read_endpoint(path).ok_or_else(|| invalid("no endpoint file"))?; + let files = files.iter().map(|f| std::path::absolute(f).map(|p| p.to_string_lossy().into_owned())).collect::>>()?; + let stream = TcpStream::connect_timeout(&(Ipv4Addr::LOCALHOST, endpoint.port).into(), CONNECT_TIMEOUT)?; + stream.set_read_timeout(Some(CLIENT_TIMEOUT))?; + stream.set_write_timeout(Some(CLIENT_TIMEOUT))?; + // The token and the paths go only to a listener that knows the greeting. + if !same(read_message(&stream)?.get("hello"), &endpoint.hello) { + return Err(invalid("the listener isn't VectorCraft")); + } + write_message(&stream, &json!({ "token": endpoint.token, "files": files }))?; + match read_message(&stream)?.get("ok") { + Some(Value::Bool(true)) => Ok(()), + _ => Err(invalid("refused")), + } +} + +/// Listen for later launches and record this app at `path`; each launch's files go to `deliver`. +/// `None` when that fails (logged): the app then runs on its own, as before. +fn listen(path: &Path, deliver: impl Fn(Vec) + Send + 'static) -> Option { + try_listen(path, deliver).inspect_err(|e| log::warn!("files opened while VectorCraft runs will open in another window: {e}")).ok() +} + +fn try_listen(path: &Path, deliver: impl Fn(Vec) + Send + 'static) -> io::Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = listener.local_addr()?.port(); + let (token, hello) = random_tokens(); + write_endpoint(path, &json!({ "port": port, "token": token, "hello": hello }))?; + // From here on a failure removes the file again. + let primary = Primary { path: path.to_path_buf(), token: token.clone() }; + std::thread::Builder::new().name("open-files-listener".into()).spawn(move || { + for stream in listener.incoming() { + match stream.and_then(|s| receive(&s, &hello, &token)) { + Ok(files) if files.is_empty() => {} + Ok(files) => deliver(files), + Err(e) => { + log::warn!("refused files from another launch: {e}"); + // An accept that keeps failing (out of handles) doesn't spin. + std::thread::sleep(Duration::from_millis(50)); + } + } + } + })?; + Ok(primary) +} + +/// Serve one launch: greet it, check its token, keep the paths that are existing files. +fn receive(stream: &TcpStream, hello: &str, token: &str) -> io::Result> { + stream.set_read_timeout(Some(SERVER_TIMEOUT))?; + stream.set_write_timeout(Some(SERVER_TIMEOUT))?; + write_message(stream, &json!({ "hello": hello }))?; + let message = read_message(stream)?; + if !same(message.get("token"), token) { + return Err(invalid("wrong token")); + } + let files = message.get("files").and_then(Value::as_array).ok_or_else(|| invalid("no file list"))?; + if files.len() > MAX_FILES { + return Err(invalid("too many files")); + } + let files = files + .iter() + .filter_map(Value::as_str) + .filter(|f| { + let p = Path::new(f); + let ok = p.is_absolute() && p.is_file(); + if !ok { + log::warn!("another launch named {f}, which isn't a file"); + } + ok + }) + .map(str::to_owned) + .collect(); + write_message(stream, &json!({ "ok": true }))?; + Ok(files) +} + +/// One JSON line of at most [`MAX_MESSAGE`] bytes. Each side sends one line and waits for the +/// other's, so nothing read past it is lost. +fn read_message(stream: &TcpStream) -> io::Result { + let mut line = Vec::new(); + BufReader::new(stream.take(MAX_MESSAGE)).read_until(b'\n', &mut line)?; + if line.last() != Some(&b'\n') { + return Err(invalid("message too long or cut short")); + } + serde_json::from_slice(&line).map_err(|e| invalid(&e.to_string())) +} + +fn write_message(mut stream: &TcpStream, message: &Value) -> io::Result<()> { + let mut line = serde_json::to_vec(message).map_err(io::Error::other)?; + line.push(b'\n'); + stream.write_all(&line) +} + +/// `given` is the string `expected`, compared in constant time. +fn same(given: Option<&Value>, expected: &str) -> bool { + given + .and_then(Value::as_str) + .is_some_and(|g| g.len() == expected.len() && g.bytes().zip(expected.bytes()).fold(0, |acc, (a, b)| acc | (a ^ b)) == 0) +} + +/// Two unrelated 128-bit hex tokens. `RandomState` is keyed from the system's random numbers, and +/// its keyed hash of distinct values gives unpredictable, independent output (no crate needed). +fn random_tokens() -> (String, String) { + let keyed = RandomState::new(); + let token = |a: u8, b: u8| format!("{:016x}{:016x}", keyed.hash_one(a), keyed.hash_one(b)); + (token(1, 2), token(3, 4)) +} + +/// Replace `path` with `endpoint` at once (a temporary file renamed over it), readable only by the +/// user on Unix. +fn write_endpoint(path: &Path, endpoint: &Value) -> io::Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let tmp = path.with_extension(format!("{}.tmp", std::process::id())); + // A leftover of an earlier app with the same process id; `create_new` sets the mode below. + let _ = fs::remove_file(&tmp); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let bytes = serde_json::to_vec(endpoint).map_err(io::Error::other)?; + let written = options.open(&tmp).and_then(|mut f| f.write_all(&bytes)).and_then(|()| fs::rename(&tmp, path)); + if written.is_err() { + // Best effort: the temporary file is all there is to clean up. + let _ = fs::remove_file(&tmp); + } + written +} + +#[cfg(test)] +mod tests { + use std::sync::mpsc; + + use super::*; + + /// A fresh folder for one test, with an existing file in it. + fn folder(name: &str) -> (PathBuf, PathBuf, String) { + let dir = std::env::temp_dir().join(format!("vc-single-instance-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a b.svg"); + fs::write(&file, "").unwrap(); + let file = file.to_string_lossy().into_owned(); + (dir.join(FILE_NAME), dir, file) + } + + fn primary(path: &Path) -> (Primary, mpsc::Receiver>) { + let (tx, rx) = mpsc::channel(); + let primary = listen(path, move |files| tx.send(files).unwrap()).unwrap(); + (primary, rx) + } + + /// Connects as a launch would and sends `message` after the greeting; what came back. + fn send_raw(path: &Path, message: &[u8]) -> io::Result { + let endpoint = read_endpoint(path).unwrap(); + let stream = TcpStream::connect((Ipv4Addr::LOCALHOST, endpoint.port)).unwrap(); + stream.set_read_timeout(Some(CLIENT_TIMEOUT)).unwrap(); + assert!(same(read_message(&stream).unwrap().get("hello"), &endpoint.hello)); + (&stream).write_all(message)?; + read_message(&stream) + } + + #[test] + fn a_launch_hands_its_files_to_the_running_app() { + let (path, dir, file) = folder("forward"); + let (primary, rx) = primary(&path); + let missing = dir.join("missing.svg").to_string_lossy().into_owned(); + forward(&path, &[file.clone(), missing]).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), std::slice::from_ref(&file), "the missing file is left out"); + // An empty list only asks whether the app runs: nothing is delivered. + forward(&path, &[]).unwrap(); + forward(&path, std::slice::from_ref(&file)).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]); + drop(primary); + assert!(!path.exists(), "the endpoint file goes with the app"); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn a_wrong_token_garbage_and_oversized_messages_are_refused() { + let (path, dir, file) = folder("refused"); + let (_primary, rx) = primary(&path); + let token = read_endpoint(&path).unwrap().token; + let wrong = format!("{}\n", json!({ "token": "0".repeat(token.len()), "files": [file] })); + assert!(send_raw(&path, wrong.as_bytes()).is_err()); + assert!(send_raw(&path, b"not json\n").is_err()); + assert!(send_raw(&path, &vec![b'a'; MAX_MESSAGE as usize + 10]).is_err()); + let many = format!("{}\n", json!({ "token": token, "files": vec![file.clone(); MAX_FILES + 1] })); + assert!(send_raw(&path, many.as_bytes()).is_err()); + let relative = format!("{}\n", json!({ "token": token, "files": ["a b.svg", 7] })); + assert_eq!(send_raw(&path, relative.as_bytes()).unwrap(), json!({ "ok": true })); + assert!(rx.try_recv().is_err(), "nothing was delivered"); + // The listener still serves a launch with the right token. + forward(&path, std::slice::from_ref(&file)).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn a_stale_endpoint_file_is_ignored_and_replaced() { + let (path, dir, file) = folder("stale"); + // No listener on the recorded port any more. + let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port(); + write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap(); + assert!(forward(&path, std::slice::from_ref(&file)).is_err()); + // A listener that doesn't know the greeting gets neither the token nor the paths. + let impostor = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = impostor.local_addr().unwrap().port(); + write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap(); + let heard = std::thread::spawn(move || { + let (stream, _) = impostor.accept().unwrap(); + write_message(&stream, &json!({ "hello": "x" })).unwrap(); + stream.set_read_timeout(Some(CLIENT_TIMEOUT)).unwrap(); + read_message(&stream).ok() + }); + assert!(forward(&path, std::slice::from_ref(&file)).is_err()); + assert!(heard.join().unwrap().is_none()); + // Garbage in the file is no endpoint either. + fs::write(&path, b"\xff not json").unwrap(); + assert!(forward(&path, std::slice::from_ref(&file)).is_err()); + // A new primary replaces the stale file. + let (_primary, rx) = primary(&path); + forward(&path, std::slice::from_ref(&file)).unwrap(); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn a_replaced_endpoint_file_stays_when_the_earlier_app_ends() { + let (path, dir, _) = folder("replaced"); + let (first, _rx1) = primary(&path); + let (_second, _rx2) = primary(&path); + let second_token = read_endpoint(&path).unwrap().token; + drop(first); + assert_eq!(read_endpoint(&path).map(|e| e.token), Some(second_token)); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn tokens_are_random_and_distinct() { + let (a, b) = random_tokens(); + let (c, _) = random_tokens(); + assert_eq!(a.len(), 32); + assert!(a != b && a != c); + } + + #[cfg(unix)] + #[test] + fn the_endpoint_file_is_the_users_alone() { + use std::os::unix::fs::PermissionsExt as _; + let (path, dir, _) = folder("mode"); + let _primary = primary(&path); + assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/docs/development.md b/docs/development.md index 6f4859d68..2dfa4fea6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,6 @@ # Development +> **Last updated:** 2026-10-31 · **Change:** files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance`. > **Last updated:** 2026-10-29 · **Change:** Linux clipboard: the Flatpak on GNOME copies and pastes text through the core Wayland protocol. > **Last updated:** 2026-10-29 · **Change:** Linux clipboard: a clipboard that can't be opened is retried with a growing wait and logged once. > **Last updated:** 2026-10-25 · **Change:** Linux Wayland drag-and-drop note names eframe 0.37 (still on winit 0.30). @@ -109,6 +110,10 @@ On macOS the menus live in the system menu bar, not in the window's app bar (whi - **Keys stay with egui.** AppKit runs a menu's key equivalents before the window sees the key, so the item's key goes back to egui as the key press it was (⌘X/⌘C/⌘V as egui's Cut/Copy/Paste events), through `raw_input_hook`: `shortcuts::handle` runs it once, user shortcuts apply, and a focused text field keeps ⌘A, ⌘C, ⌘V and ⌘Z. A click runs the item like an in-window click. Bare keys and ⌥/⇧ chords are never key equivalents (they type). - Built and clippy-checked for `aarch64-apple-darwin` and `x86_64-apple-darwin` from any host: `cargo clippy -p vectorcraft --target aarch64-apple-darwin --all-targets -- -D warnings`. +## Files opened while the app runs (Windows and Linux) + +A file opened from the file manager (double-click, Open With, a drop on the executable) or given on the command line while VectorCraft already runs opens as a new document tab in that window, which comes to the front; the second launch exits (#4892). `apps/vectorcraft/src/single_instance.rs`: the running app listens on a loopback port and records the port and two random tokens in `instance.json` next to the preferences (owner-only on Unix). A launch with files reads it, connects, checks the app's greeting, sends the token and the absolute paths as one JSON line and waits for the acknowledgement; the app keeps only existing files (at most 256, 64 KiB per message). A stale file (no listener, a wrong greeting) is ignored and replaced; the app removes its file when its window closes. Launches with `--control` or `VECTORCRAFT_NO_PREFS` neither hand files over nor listen, so tests and agents start as many apps as before; `--new-instance` opens a launch's files in a window of its own. macOS hands Finder's files to the running app as open-document events (`open_documents.rs`). + ## Linux: Wayland and X11 On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. @@ -493,6 +498,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance` opts out. | | 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME).; its reads wait 2 s at most. | | 2026-10-29 | minor | Linux clipboard: a clipboard arboard can't open is retried after 1 s doubling to 60 s and logged once, instead of every 250 ms. | | 2026-10-28 | minor | Localisation: Catalan (`ca`) catalog and registry entry (every `ca-*` locale); complete under the catalog tests. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 760cf95c2..ecda87aae 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (New Document: Save Preset in More Settings, #4893; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (files opened from the file manager while VectorCraft runs open in its window on Windows and Linux, #4892) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -359,6 +359,8 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # - [x] Compact Color Picker with a distinct header, stacked actions and aligned input groups. #2008; translated action widths, visible warnings, stable mode switching and vertical scrolling in narrow windows; real egui events and two UI scales covered; German and enlarged English renders reviewed locally. Scores unchanged +- [x] A file opened from the file manager (double-click, Open With, a drop on the executable) while VectorCraft runs opens as a new document tab in that window, which comes to the front, on Windows and Linux as on macOS; the second launch exits (#4892). `--new-instance` opts out; runs with `--control` or `VECTORCRAFT_NO_PREFS` start apps of their own as before. Protocol, token, stale-file and hand-over tests in `apps/vectorcraft/src/single_instance.rs`. + ## Highlighted-text Eyedropper (2026-10-21) Type/Eyedropper switches preserve highlighted characters; sampling changes only their enabled @@ -379,6 +381,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | A file opened from the file manager while VectorCraft runs opens as a new tab in that window, which comes to the front, on Windows and Linux (#4892); `--new-instance` opts out; scores unchanged | | 2026-10-31 | minor | Object › Path › Smooth: Auto-Smooth takes a shaky line to one smooth curve, and direct-selected anchors smooth only their section; scores unchanged | | 2026-10-31 | minor | New Document: More Settings has Save Preset beside the name, as Preset Details does, and saves its settings (#4893); scores and estimates unchanged | | 2026-10-31 | minor | Export for Screens is laid out as the reference app's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged |