mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
Fix #4892: a file opened while VectorCraft runs opens in that window
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user