mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
Desktop: Add support for opening files through the already-running Graphite executable on Windows and Linux
This commit is contained in:
32
Cargo.lock
generated
32
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
167
desktop/src/instance_ipc.rs
Normal 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))
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user