mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-01 05:38:13 +08:00
Desktop: Add support for opening files through the already-running Graphite executable on Windows and Linux
This commit is contained in:
Generated
+32
@@ -1210,6 +1210,12 @@ dependencies = [
|
|||||||
"libloading 0.8.8",
|
"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]]
|
[[package]]
|
||||||
name = "document-features"
|
name = "document-features"
|
||||||
version = "0.2.11"
|
version = "0.2.11"
|
||||||
@@ -2075,6 +2081,7 @@ dependencies = [
|
|||||||
"glam",
|
"glam",
|
||||||
"graphite-desktop-embedded-resources",
|
"graphite-desktop-embedded-resources",
|
||||||
"graphite-desktop-wrapper",
|
"graphite-desktop-wrapper",
|
||||||
|
"interprocess",
|
||||||
"lzma-rust2",
|
"lzma-rust2",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2 0.6.3",
|
"objc2 0.6.3",
|
||||||
@@ -2695,6 +2702,19 @@ dependencies = [
|
|||||||
"wgpu-executor",
|
"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]]
|
[[package]]
|
||||||
name = "io-uring"
|
name = "io-uring"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -4370,6 +4390,12 @@ dependencies = [
|
|||||||
"font-types 0.11.0",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.17"
|
version = "0.5.17"
|
||||||
@@ -6613,6 +6639,12 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "widestring"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
accepted = [
|
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 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`
|
"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`
|
"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)].
|
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
|
||||||
#
|
#
|
||||||
allow = [
|
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 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`
|
"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`
|
"BSD-2-Clause", # Keep this list in sync with those in `/about.toml`
|
||||||
|
|||||||
+5
-1
@@ -52,6 +52,10 @@ fd-lock = "4.0.4"
|
|||||||
ctrlc = "3.5.1"
|
ctrlc = "3.5.1"
|
||||||
window_clipboard = "0.5"
|
window_clipboard = "0.5"
|
||||||
|
|
||||||
|
# Windows and Linux-specific dependencies
|
||||||
|
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||||
|
interprocess = "2.2"
|
||||||
|
|
||||||
# Windows-specific dependencies
|
# Windows-specific dependencies
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
windows = { version = "0.58.0", features = [
|
windows = { version = "0.58.0", features = [
|
||||||
@@ -67,7 +71,7 @@ windows = { version = "0.58.0", features = [
|
|||||||
"Win32_UI_Shell",
|
"Win32_UI_Shell",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
# macOS-specific dependencies
|
# Mac-specific dependencies
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = { version = "0.6.1", default-features = false }
|
objc2 = { version = "0.6.1", default-features = false }
|
||||||
objc2-foundation = { version = "0.3.2", default-features = false }
|
objc2-foundation = { version = "0.3.2", default-features = false }
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ pub(crate) struct App {
|
|||||||
start_render_sender: SyncSender<()>,
|
start_render_sender: SyncSender<()>,
|
||||||
web_communication_initialized: bool,
|
web_communication_initialized: bool,
|
||||||
web_communication_startup_buffer: Vec<Vec<u8>>,
|
web_communication_startup_buffer: Vec<Vec<u8>>,
|
||||||
#[cfg_attr(not(target_os = "macos"), expect(unused))]
|
|
||||||
preferences: Preferences,
|
preferences: Preferences,
|
||||||
launch_documents: Option<Vec<PathBuf>>,
|
launch_documents: Option<Vec<PathBuf>>,
|
||||||
startup_time: Option<Instant>,
|
startup_time: Option<Instant>,
|
||||||
@@ -478,7 +477,6 @@ impl App {
|
|||||||
tracing::info!("Exiting main event loop");
|
tracing::info!("Exiting main event loop");
|
||||||
event_loop.exit();
|
event_loop.exit();
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
AppEvent::AddLaunchDocuments(paths) => {
|
AppEvent::AddLaunchDocuments(paths) => {
|
||||||
if let Some(launch_documents) = &mut self.launch_documents {
|
if let Some(launch_documents) = &mut self.launch_documents {
|
||||||
launch_documents.extend(paths);
|
launch_documents.extend(paths);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ pub(crate) enum AppEvent {
|
|||||||
DesktopWrapperMessage(DesktopWrapperMessage),
|
DesktopWrapperMessage(DesktopWrapperMessage),
|
||||||
NodeGraphExecutionResult(NodeGraphExecutionResult),
|
NodeGraphExecutionResult(NodeGraphExecutionResult),
|
||||||
Exit,
|
Exit,
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
AddLaunchDocuments(Vec<std::path::PathBuf>),
|
AddLaunchDocuments(Vec<std::path::PathBuf>),
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
MenuEvent {
|
MenuEvent {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
+26
-1
@@ -16,6 +16,8 @@ mod cli;
|
|||||||
mod dirs;
|
mod dirs;
|
||||||
mod event;
|
mod event;
|
||||||
mod gpu_context;
|
mod gpu_context;
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
mod instance_ipc;
|
||||||
mod persist;
|
mod persist;
|
||||||
mod preferences;
|
mod preferences;
|
||||||
mod render;
|
mod render;
|
||||||
@@ -57,7 +59,24 @@ pub fn start() {
|
|||||||
guard
|
guard
|
||||||
}
|
}
|
||||||
Err(_) => {
|
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);
|
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_sender, app_event_receiver) = std::sync::mpsc::channel();
|
||||||
let app_event_scheduler = event_loop.create_app_event_scheduler(app_event_sender);
|
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();
|
let (cef_view_info_sender, cef_view_info_receiver) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
if cli.disable_ui_acceleration {
|
if cli.disable_ui_acceleration {
|
||||||
@@ -119,6 +141,9 @@ pub fn start() {
|
|||||||
// Explicitly drop the instance lock
|
// Explicitly drop the instance lock
|
||||||
drop(lock);
|
drop(lock);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
instance_ipc::cleanup();
|
||||||
|
|
||||||
match exit_reason {
|
match exit_reason {
|
||||||
app::ExitReason::Restart | app::ExitReason::UiAccelerationFailure => {
|
app::ExitReason::Restart | app::ExitReason::UiAccelerationFailure => {
|
||||||
tracing::info!("Restarting application");
|
tracing::info!("Restarting application");
|
||||||
|
|||||||
Reference in New Issue
Block a user