Desktop: Add support for opening files through the already-running Graphite executable on Windows and Linux

This commit is contained in:
Keavon Chambers
2026-05-06 06:26:04 -07:00
parent d97fe835b5
commit b0f60e29d9
8 changed files with 232 additions and 5 deletions

32
Cargo.lock generated
View File

@@ -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"

View File

@@ -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`

View File

@@ -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`

View File

@@ -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 }

View File

@@ -45,7 +45,6 @@ pub(crate) struct App {
start_render_sender: SyncSender<()>,
web_communication_initialized: bool,
web_communication_startup_buffer: Vec<Vec<u8>>,
#[cfg_attr(not(target_os = "macos"), expect(unused))]
preferences: Preferences,
launch_documents: Option<Vec<PathBuf>>,
startup_time: Option<Instant>,
@@ -478,7 +477,6 @@ 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);

View File

@@ -9,7 +9,6 @@ pub(crate) enum AppEvent {
DesktopWrapperMessage(DesktopWrapperMessage),
NodeGraphExecutionResult(NodeGraphExecutionResult),
Exit,
#[cfg(target_os = "macos")]
AddLaunchDocuments(Vec<std::path::PathBuf>),
#[cfg(target_os = "macos")]
MenuEvent {

167
desktop/src/instance_ipc.rs Normal file
View File

@@ -0,0 +1,167 @@
//! 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::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<Name<'static>> {
// 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::<GenericNamespaced>().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<Name<'static>> {
endpoint_path().to_fs_name::<GenericFilePath>().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<io::Error> = 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<Vec<PathBuf>> {
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)?;
// Safety: bytes were produced by `OsStr::as_encoded_bytes` on the same OS,
// which is the documented round-trip contract for `from_encoded_bytes_unchecked`.
let os_string = unsafe { OsString::from_encoded_bytes_unchecked(buffer) };
paths.push(PathBuf::from(os_string));
}
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 = path.as_os_str().as_encoded_bytes();
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()
}
fn read_u32(stream: &mut Stream) -> io::Result<u32> {
let mut buffer = [0_u8; 4];
stream.read_exact(&mut buffer)?;
Ok(u32::from_le_bytes(buffer))
}

View File

@@ -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");