diff --git a/Cargo.lock b/Cargo.lock index ba0bbcd2b5..dcc71e997b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1210,6 +1210,12 @@ dependencies = [ "libloading 0.8.8", ] +[[package]] +name = "doctest-file" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + [[package]] name = "document-features" version = "0.2.11" @@ -2075,6 +2081,7 @@ dependencies = [ "glam", "graphite-desktop-embedded-resources", "graphite-desktop-wrapper", + "interprocess", "lzma-rust2", "muda", "objc2 0.6.3", @@ -2695,6 +2702,19 @@ dependencies = [ "wgpu-executor", ] +[[package]] +name = "interprocess" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069323743400cb7ab06a8fe5c1ed911d36b6919ec531661d034c89083629595b" +dependencies = [ + "doctest-file", + "libc", + "recvmsg", + "widestring", + "windows-sys 0.61.2", +] + [[package]] name = "io-uring" version = "0.7.10" @@ -4370,6 +4390,12 @@ dependencies = [ "font-types 0.11.0", ] +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + [[package]] name = "redox_syscall" version = "0.5.17" @@ -6613,6 +6639,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" diff --git a/about.toml b/about.toml index 7f9568c754..8b93e0f4f4 100644 --- a/about.toml +++ b/about.toml @@ -1,4 +1,5 @@ accepted = [ + "0BSD", # Keep this list in sync with those in `/deny.toml` "Apache-2.0 WITH LLVM-exception", # Keep this list in sync with those in `/deny.toml` "Apache-2.0", # Keep this list in sync with those in `/deny.toml` "BSD-2-Clause", # Keep this list in sync with those in `/deny.toml` diff --git a/deny.toml b/deny.toml index 462d08a99b..2c6797371e 100644 --- a/deny.toml +++ b/deny.toml @@ -63,6 +63,7 @@ ignore = [ # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. # allow = [ + "0BSD", # Keep this list in sync with those in `/about.toml` "Apache-2.0 WITH LLVM-exception", # Keep this list in sync with those in `/about.toml` "Apache-2.0", # Keep this list in sync with those in `/about.toml` "BSD-2-Clause", # Keep this list in sync with those in `/about.toml` diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 2dcdd7e24e..22851d7dd7 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -52,6 +52,10 @@ fd-lock = "4.0.4" ctrlc = "3.5.1" window_clipboard = "0.5" +# Windows and Linux-specific dependencies +[target.'cfg(not(target_os = "macos"))'.dependencies] +interprocess = "2.2" + # Windows-specific dependencies [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.58.0", features = [ @@ -67,7 +71,7 @@ windows = { version = "0.58.0", features = [ "Win32_UI_Shell", ] } -# macOS-specific dependencies +# Mac-specific dependencies [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.6.1", default-features = false } objc2-foundation = { version = "0.3.2", default-features = false } diff --git a/desktop/src/app.rs b/desktop/src/app.rs index d69b578350..15074049ed 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -45,7 +45,6 @@ pub(crate) struct App { start_render_sender: SyncSender<()>, web_communication_initialized: bool, web_communication_startup_buffer: Vec>, - #[cfg_attr(not(target_os = "macos"), expect(unused))] preferences: Preferences, launch_documents: Option>, startup_time: Option, @@ -465,11 +464,13 @@ impl App { tracing::info!("Exiting main event loop"); event_loop.exit(); } - #[cfg(target_os = "macos")] AppEvent::AddLaunchDocuments(paths) => { if let Some(launch_documents) = &mut self.launch_documents { launch_documents.extend(paths); } else { + if let Some(window) = &self.window { + window.restore_and_focus(); + } self.open_files(paths); } } diff --git a/desktop/src/event.rs b/desktop/src/event.rs index 99776e6812..f0d9df015c 100644 --- a/desktop/src/event.rs +++ b/desktop/src/event.rs @@ -9,7 +9,6 @@ pub(crate) enum AppEvent { DesktopWrapperMessage(DesktopWrapperMessage), NodeGraphExecutionResult(NodeGraphExecutionResult), Exit, - #[cfg(target_os = "macos")] AddLaunchDocuments(Vec), #[cfg(target_os = "macos")] MenuEvent { diff --git a/desktop/src/instance_ipc.rs b/desktop/src/instance_ipc.rs new file mode 100644 index 0000000000..c219119184 --- /dev/null +++ b/desktop/src/instance_ipc.rs @@ -0,0 +1,203 @@ +//! Single-instance file-open handoff for Windows and Linux. +//! +//! When the user double-clicks a `.graphite` file (or drags it onto the executable) while a +//! Graphite instance is already running, the OS spawns a new process. The new process fails to +//! acquire the application lock, then forwards its file paths to the running instance over a +//! local IPC channel and exits. The running instance opens those files in place. +//! +//! Mac handles the same scenario natively via `NSApplicationDelegate::application:openURLs:` +//! and so this module is unused there. + +use std::ffi::{OsStr, OsString}; +#[cfg(windows)] +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::io::{self, Read, Write}; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use interprocess::local_socket::traits::{ListenerExt, Stream as StreamTrait}; +#[cfg(unix)] +use interprocess::local_socket::{GenericFilePath, ToFsName}; +#[cfg(windows)] +use interprocess::local_socket::{GenericNamespaced, ToNsName}; +use interprocess::local_socket::{ListenerOptions, Name, Stream}; + +use crate::dirs; +use crate::event::{AppEvent, AppEventScheduler}; + +const MAX_PATH_COUNT: u32 = 1024; +const MAX_PATH_BYTES: u32 = 32 * 1024; +const CONNECT_RETRY_ATTEMPTS: u32 = 30; +const CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(100); + +#[cfg(windows)] +fn endpoint_name() -> io::Result> { + // Named pipes share a global namespace per machine, so derive a per-user identifier from the user's app data directory (which is itself per-user). + let mut hasher = DefaultHasher::new(); + dirs::app_data_dir().hash(&mut hasher); + let pipe_name = format!("graphite-instance-{:016x}", hasher.finish()); + pipe_name.to_ns_name::().map(|name| name.into_owned()) +} + +#[cfg(unix)] +fn endpoint_path() -> PathBuf { + dirs::app_data_dir().join("instance.sock") +} + +#[cfg(unix)] +fn endpoint_name() -> io::Result> { + endpoint_path().to_fs_name::().map(|name| name.into_owned()) +} + +/// Bind the IPC endpoint and spawn a listener thread that forwards received paths to the live +/// instance via [`AppEvent::AddLaunchDocuments`]. Called once after the application lock is acquired. +pub(crate) fn start_listener(scheduler: AppEventScheduler) { + #[cfg(unix)] + { + // A stale socket file may remain after a previous unclean exit. Removing it before bind + // is safe because we hold the application lock, so no other instance can be listening. + let _ = std::fs::remove_file(endpoint_path()); + } + + let name = match endpoint_name() { + Ok(name) => name, + Err(error) => { + tracing::error!("Failed to construct instance IPC endpoint name: {error}"); + return; + } + }; + + let listener = match ListenerOptions::new().name(name).create_sync() { + Ok(listener) => listener, + Err(error) => { + tracing::error!("Failed to bind instance IPC listener: {error}"); + return; + } + }; + + let _ = thread::Builder::new().name("graphite-instance-ipc".into()).spawn(move || { + for connection in listener.incoming() { + match connection { + Ok(mut stream) => match read_paths(&mut stream) { + Ok(paths) if !paths.is_empty() => { + tracing::info!("Received {} file path(s) from secondary instance", paths.len()); + scheduler.schedule(AppEvent::AddLaunchDocuments(paths)); + } + Ok(_) => {} + Err(error) => tracing::error!("Failed to read paths from secondary instance: {error}"), + }, + Err(error) => tracing::error!("Instance IPC accept failed: {error}"), + } + } + }); +} + +/// Connect to the live instance's IPC endpoint and send `paths` to it. Retries briefly to cover +/// the brief timeframe during which the live instance has acquired the lock but has not yet bound +/// its listener. Returns `Ok(())` only if the live instance acknowledged the write. +pub(crate) fn try_send_paths(paths: &[PathBuf]) -> io::Result<()> { + let mut last_error: Option = None; + for _ in 0..CONNECT_RETRY_ATTEMPTS { + let name = endpoint_name()?; + match Stream::connect(name) { + Ok(mut stream) => { + write_paths(&mut stream, paths)?; + return Ok(()); + } + Err(error) => { + last_error = Some(error); + thread::sleep(CONNECT_RETRY_INTERVAL); + } + } + } + Err(last_error.unwrap_or_else(|| io::Error::other("Failed to connect to instance IPC endpoint"))) +} + +/// Best-effort removal of the Unix socket file on shutdown. No-op on Windows since the named pipe is reclaimed when the process exits. +pub(crate) fn cleanup() { + #[cfg(unix)] + { + let _ = std::fs::remove_file(endpoint_path()); + } +} + +fn read_paths(stream: &mut Stream) -> io::Result> { + let count = read_u32(stream)?; + if count > MAX_PATH_COUNT { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Too many paths in IPC payload")); + } + + let mut paths = Vec::with_capacity(count as usize); + for _ in 0..count { + let length = read_u32(stream)?; + if length > MAX_PATH_BYTES { + return Err(io::Error::new(io::ErrorKind::InvalidData, "IPC path exceeds maximum length")); + } + + let mut buffer = vec![0_u8; length as usize]; + stream.read_exact(&mut buffer)?; + paths.push(PathBuf::from(decode_os_string(buffer)?)); + } + Ok(paths) +} + +fn write_paths(stream: &mut Stream, paths: &[PathBuf]) -> io::Result<()> { + let count = u32::try_from(paths.len()).map_err(|_| io::Error::other("Too many paths"))?; + stream.write_all(&count.to_le_bytes())?; + + for path in paths { + let bytes = encode_os_str(path.as_os_str()); + let length = u32::try_from(bytes.len()).map_err(|_| io::Error::other("Path too long"))?; + stream.write_all(&length.to_le_bytes())?; + stream.write_all(&bytes)?; + } + + stream.flush() +} + +/// Encode an `OsStr` into a byte sequence whose round-trip is provided by *safe* OS-specific APIs +/// on the receiving side. The wire format is platform-specific (raw bytes on Unix, little-endian +/// UTF-16 code units on Windows), which is acceptable because both endpoints are the same +/// executable on the same machine. +#[cfg(unix)] +fn encode_os_str(value: &OsStr) -> Vec { + use std::os::unix::ffi::OsStrExt; + value.as_bytes().to_vec() +} + +#[cfg(windows)] +fn encode_os_str(value: &OsStr) -> Vec { + use std::os::windows::ffi::OsStrExt; + let mut buffer = Vec::with_capacity(value.len() * 2); + for code_unit in value.encode_wide() { + buffer.extend_from_slice(&code_unit.to_le_bytes()); + } + buffer +} + +/// Inverse of [`encode_os_str`]. Both branches are total over their input domain (any byte +/// sequence is a valid Unix `OsString`; any sequence of `u16` is a valid Windows `OsString`), so +/// untrusted local IPC input cannot trigger UB, only the even-length precondition for Windows +/// needs validation. +#[cfg(unix)] +fn decode_os_string(bytes: Vec) -> io::Result { + use std::os::unix::ffi::OsStringExt; + Ok(OsString::from_vec(bytes)) +} + +#[cfg(windows)] +fn decode_os_string(bytes: Vec) -> io::Result { + use std::os::windows::ffi::OsStringExt; + if !bytes.len().is_multiple_of(2) { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Path payload must be UTF-16 code units (even byte length)")); + } + let wide: Vec = bytes.chunks_exact(2).map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])).collect(); + Ok(OsString::from_wide(&wide)) +} + +fn read_u32(stream: &mut Stream) -> io::Result { + let mut buffer = [0_u8; 4]; + stream.read_exact(&mut buffer)?; + Ok(u32::from_le_bytes(buffer)) +} diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index a23d80edd2..8183c8aacf 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -16,6 +16,8 @@ mod cli; mod dirs; mod event; mod gpu_context; +#[cfg(not(target_os = "macos"))] +mod instance_ipc; mod persist; mod preferences; mod render; @@ -57,7 +59,24 @@ pub fn start() { guard } Err(_) => { - tracing::error!("Another instance is already running, Exiting."); + // Another instance is already running. On Windows and Linux, hand any requested file paths + // off to that instance over local IPC before exiting. Mac routes file opens natively + // through `NSApplicationDelegate` and never reaches this branch with a secondary process. + #[cfg(not(target_os = "macos"))] + { + if !cli.files.is_empty() { + match instance_ipc::try_send_paths(&cli.files) { + Ok(()) => { + tracing::info!("Forwarded {} file path(s) to running instance", cli.files.len()); + std::process::exit(0); + } + Err(error) => { + tracing::error!("Failed to forward file paths to running instance: {error}"); + } + } + } + } + tracing::error!("Another instance is already running, exiting."); std::process::exit(1); } }; @@ -78,6 +97,9 @@ pub fn start() { let (app_event_sender, app_event_receiver) = std::sync::mpsc::channel(); let app_event_scheduler = event_loop.create_app_event_scheduler(app_event_sender); + #[cfg(not(target_os = "macos"))] + instance_ipc::start_listener(app_event_scheduler.clone()); + let (cef_view_info_sender, cef_view_info_receiver) = std::sync::mpsc::channel(); if cli.disable_ui_acceleration { @@ -119,6 +141,9 @@ pub fn start() { // Explicitly drop the instance lock drop(lock); + #[cfg(not(target_os = "macos"))] + instance_ipc::cleanup(); + match exit_reason { app::ExitReason::Restart | app::ExitReason::UiAccelerationFailure => { tracing::info!("Restarting application"); diff --git a/desktop/src/window.rs b/desktop/src/window.rs index a9b48e38fc..551d2cde16 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -110,6 +110,11 @@ impl Window { self.winit_window.set_minimized(true); } + pub(crate) fn restore_and_focus(&self) { + self.winit_window.set_minimized(false); + self.winit_window.focus_window(); + } + pub(crate) fn toggle_maximize(&self) { if self.is_fullscreen() { return;