Fix #4892: a file opened while VectorCraft runs opens in that window

This commit is contained in:
bflatastic
2026-10-31 23:11:00 +07:00
parent f0da383749
commit 9b17e38a5c
5 changed files with 435 additions and 11 deletions
+11 -7
View File
@@ -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<String>) {
for f in files {
+28 -3
View File
@@ -1,7 +1,10 @@
//! VectorCraft desktop app.
//!
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>]... [--automation-write-root <dir>]...]
//! [--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"))]
+386
View File
@@ -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<Vec<String>> = Mutex::new(Vec::new());
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
static UI: OnceLock<egui::Context> = 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<Primary>),
}
/// 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<String> {
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
}
fn queue(files: Vec<String>) {
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<Endpoint> {
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::<io::Result<Vec<_>>>()?;
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<String>) + Send + 'static) -> Option<Primary> {
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<String>) + Send + 'static) -> io::Result<Primary> {
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<Vec<String>> {
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<Value> {
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, "<svg/>").unwrap();
let file = file.to_string_lossy().into_owned();
(dir.join(FILE_NAME), dir, file)
}
fn primary(path: &Path) -> (Primary, mpsc::Receiver<Vec<String>>) {
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<Value> {
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);
}
}
+6
View File
@@ -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. |
+4 -1
View File
@@ -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 |