Desktop: Add support for opening files through the already-running instance via a local socket (#4123)

* Desktop: Forward file-open args from a second launch to the running instance

Also adds socket infrastructure that can be used in the future to allow dispatching actions from another process

* Use socket instead of ipc terminologie

* Fix

* Fix

* Better pipe name on windows
This commit is contained in:
Timon 2026-05-09 18:11:44 +00:00 committed by GitHub
parent a28b9437aa
commit 98daa75c26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 260 additions and 83 deletions

32
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -48,6 +48,7 @@ lzma-rust2 = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
rand = { workspace = true, features = ["thread_rng"] } rand = { workspace = true, features = ["thread_rng"] }
clap = { workspace = true, features = ["derive"] } clap = { workspace = true, features = ["derive"] }
interprocess = "2.4.2"
fd-lock = "4.0.4" fd-lock = "4.0.4"
ctrlc = "3.5.1" ctrlc = "3.5.1"
window_clipboard = "0.5" window_clipboard = "0.5"

View File

@ -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>,
@ -320,7 +319,7 @@ impl App {
tracing::error!("OpenLaunchDocuments should only be sent once"); tracing::error!("OpenLaunchDocuments should only be sent once");
return; return;
}; };
self.open_files(launch_documents); self.app_event_scheduler.schedule(AppEvent::OpenFiles(launch_documents));
} }
DesktopFrontendMessage::UpdateMenu { entries } => { DesktopFrontendMessage::UpdateMenu { entries } => {
if let Some(window) = &self.window { if let Some(window) = &self.window {
@ -478,22 +477,13 @@ 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::OpenFiles(paths) => {
AppEvent::AddLaunchDocuments(paths) => { // Accumulate launch documents until OpenLaunchDocuments message is received
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);
} else { return;
self.open_files(paths);
}
}
#[cfg(target_os = "macos")]
AppEvent::MenuEvent { id } => {
self.dispatch_desktop_wrapper_message(DesktopWrapperMessage::MenuEvent { id });
}
}
} }
fn open_files(&mut self, paths: Vec<PathBuf>) {
if paths.is_empty() { if paths.is_empty() {
return; return;
} }
@ -510,6 +500,12 @@ impl App {
} }
}); });
} }
#[cfg(target_os = "macos")]
AppEvent::MenuEvent { id } => {
self.dispatch_desktop_wrapper_message(DesktopWrapperMessage::MenuEvent { id });
}
}
}
} }
impl ApplicationHandler for App { impl ApplicationHandler for App {
fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) { fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) {

View File

@ -7,6 +7,7 @@ pub(crate) const APP_DIRECTORY_NAME: &str = "graphite";
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub(crate) const APP_DIRECTORY_NAME: &str = "Graphite"; pub(crate) const APP_DIRECTORY_NAME: &str = "Graphite";
pub(crate) const APP_LOCK_FILE_NAME: &str = "instance.lock"; pub(crate) const APP_LOCK_FILE_NAME: &str = "instance.lock";
pub(crate) const APP_SOCKET_FILE_NAME: &str = "instance.sock";
pub(crate) const APP_STATE_FILE_NAME: &str = "state.ron"; pub(crate) const APP_STATE_FILE_NAME: &str = "state.ron";
pub(crate) const APP_PREFERENCES_FILE_NAME: &str = "preferences.ron"; pub(crate) const APP_PREFERENCES_FILE_NAME: &str = "preferences.ron";
pub(crate) const APP_DOCUMENTS_DIRECTORY_NAME: &str = "documents"; pub(crate) const APP_DOCUMENTS_DIRECTORY_NAME: &str = "documents";

View File

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

View File

@ -19,6 +19,7 @@ mod gpu_context;
mod persist; mod persist;
mod preferences; mod preferences;
mod render; mod render;
mod socket;
mod window; mod window;
pub(crate) mod consts; pub(crate) mod consts;
@ -58,8 +59,14 @@ pub fn start() {
} }
Err(_) => { Err(_) => {
tracing::error!("Another instance is already running, Exiting."); tracing::error!("Another instance is already running, Exiting.");
if !cli.files.is_empty()
&& let Err(error) = socket::send(socket::Message::OpenFiles(cli.files))
{
tracing::error!("Failed to send socket message to running instance: {}", error);
std::process::exit(1); std::process::exit(1);
} }
return;
}
}; };
dirs::app_tmp_dir_cleanup(); dirs::app_tmp_dir_cleanup();
@ -78,6 +85,8 @@ 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);
let _socket_handle = socket::start(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 {

126
desktop/src/socket.rs Normal file
View File

@ -0,0 +1,126 @@
use interprocess::local_socket::{GenericFilePath, GenericNamespaced, ListenerNonblockingMode, ListenerOptions, Name, prelude::*};
use std::io::{ErrorKind, Read, Write};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use crate::consts::APP_SOCKET_FILE_NAME;
use crate::event::{AppEvent, AppEventScheduler};
// TODO: Needs to be integrated/replaced with the action system.
// TODO: At that point this should just wrap the action, meaning all actions bindable by the user can also be accessed via the socket.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) enum Message {
OpenFiles(Vec<std::path::PathBuf>),
}
fn handle_message(message: Message, app_event_scheduler: &AppEventScheduler) {
match message {
Message::OpenFiles(paths) => {
app_event_scheduler.schedule(AppEvent::OpenFiles(paths));
}
}
}
pub(crate) fn send(message: Message) -> std::io::Result<()> {
let data = ron::ser::to_string(&message).map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
let mut connection = interprocess::local_socket::Stream::connect(socket_name())?;
connection.write_all(data.as_bytes())
}
pub(crate) struct SocketHandle {
thread: Option<thread::JoinHandle<()>>,
shutdown_sender: mpsc::Sender<()>,
}
impl Drop for SocketHandle {
fn drop(&mut self) {
let _ = self.shutdown_sender.send(());
let _ = self.thread.take().expect("SocketHandle can only be dropped once").join();
}
}
pub(crate) fn start(app_event_scheduler: AppEventScheduler) -> SocketHandle {
let (shutdown_sender, shutdown_receiver) = mpsc::channel();
let thread = thread::Builder::new()
.name("socket".to_string())
.spawn(move || run(app_event_scheduler, shutdown_receiver))
.expect("Failed to spawn socket thread");
SocketHandle {
shutdown_sender,
thread: Some(thread),
}
}
fn run(app_event_scheduler: AppEventScheduler, shutdown_receiver: mpsc::Receiver<()>) {
let listener = match ListenerOptions::new()
.name(socket_name())
.nonblocking(ListenerNonblockingMode::Accept)
.try_overwrite(true)
.max_spin_time(Duration::from_millis(100))
.create_sync()
{
Ok(listener) => listener,
Err(error) => {
tracing::error!("Failed to bind socket: {}", error);
return;
}
};
let max_backoff = Duration::from_millis(100);
let mut backoff = Duration::ZERO;
loop {
if backoff.is_zero() {
match shutdown_receiver.try_recv() {
Ok(()) | Err(mpsc::TryRecvError::Disconnected) => break,
Err(mpsc::TryRecvError::Empty) => {}
}
backoff = Duration::from_nanos(1);
} else {
match shutdown_receiver.recv_timeout(backoff) {
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
backoff = (backoff * 2).min(max_backoff);
}
match listener.accept() {
Ok(mut connection) => {
backoff = Duration::ZERO;
let app_event_scheduler = app_event_scheduler.clone();
let spawn_result = thread::Builder::new().name("socket-connection".to_string()).spawn(move || {
let mut data = String::new();
if let Err(error) = connection.read_to_string(&mut data) {
tracing::error!("Failed to read socket message: {}", error);
return;
}
match ron::de::from_str(&data) {
Ok(message) => handle_message(message, &app_event_scheduler),
Err(error) => tracing::error!("Failed to deserialize socket message: {}", error),
}
});
if let Err(error) = spawn_result {
tracing::error!("Failed to spawn socket connection thread: {}", error);
}
}
Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::Interrupted) => {}
Err(error) => {
tracing::error!("Failed to accept socket connection: {}", error);
}
}
}
}
fn socket_name() -> Name<'static> {
if cfg!(target_os = "windows") {
let user = std::env::var("USERNAME").unwrap_or_default();
let name = format!("{user}-{app}-{APP_SOCKET_FILE_NAME}", app = crate::consts::APP_NAME);
name.to_ns_name::<GenericNamespaced>().expect("valid named pipe name")
} else {
crate::dirs::app_data_dir().join(APP_SOCKET_FILE_NAME).to_fs_name::<GenericFilePath>().expect("valid socket path")
}
}

View File

@ -1,6 +1,7 @@
use std::ffi::CStr; use std::ffi::CStr;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::ops::Deref; use std::ops::Deref;
use std::ops::DerefMut;
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Mutex, Once}; use std::sync::{Mutex, Once};
@ -14,9 +15,61 @@ use objc2_foundation::{NSArray, NSObject, NSObjectProtocol, NSURL};
use crate::event::{AppEvent, AppEventScheduler}; use crate::event::{AppEvent, AppEventScheduler};
static APP_EVENT_SCHEDULER: Mutex<Option<AppEventScheduler>> = Mutex::new(None); static APP_EVENT_SCHEDULER: Mutex<Option<AppEventScheduler>> = Mutex::new(None);
static PENDING_EVENTS: Mutex<Option<Vec<AppEvent>>> = Mutex::new(Some(Vec::new()));
fn dispatch_event(event: AppEvent) {
let app_event_scheduler_guard = APP_EVENT_SCHEDULER.lock().unwrap();
if let Some(app_event_scheduler) = app_event_scheduler_guard.deref() {
app_event_scheduler.schedule(event);
} else if let Some(pending_events) = PENDING_EVENTS.lock().unwrap().deref_mut() {
pending_events.push(event);
} else {
tracing::error!("Failed to dispatch event");
}
}
fn instance() -> objc2::rc::Retained<NSApplication> {
unsafe { msg_send![GraphiteApplication::class(), sharedApplication] }
}
static INSTALL_DELEGATE: Once = Once::new(); static INSTALL_DELEGATE: Once = Once::new();
static LAUNCH_DOCUMENTS: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new()); pub(super) fn init() {
let _ = instance();
INSTALL_DELEGATE.call_once(|| {
let mtm = MainThreadMarker::new().expect("should only ever be called from main thread");
let delegate: Retained<GraphiteApplicationDelegate> = unsafe { msg_send![super(GraphiteApplicationDelegate::alloc(mtm).set_ivars(())), init] };
instance().setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
std::mem::forget(delegate);
});
}
pub(super) fn setup(app_event_scheduler: AppEventScheduler) {
let mut app_event_scheduler_guard = APP_EVENT_SCHEDULER.lock().unwrap();
if let Some(mut pending_events) = PENDING_EVENTS.lock().unwrap().take() {
pending_events.drain(..).for_each(|event| {
app_event_scheduler.schedule(event);
});
} else {
tracing::error!("Failed to take PENDING_EVENTS and schedule them. This a bug.");
}
*app_event_scheduler_guard = Some(app_event_scheduler);
}
pub(super) fn hide() {
instance().hide(None);
}
pub(super) fn hide_others() {
instance().hideOtherApplications(None);
}
pub(super) fn show_all() {
instance().unhideAllApplications(None);
}
define_class!( define_class!(
#[unsafe(super(NSApplication, NSResponder, NSObject))] #[unsafe(super(NSApplication, NSResponder, NSObject))]
@ -47,62 +100,20 @@ define_class!(
unsafe impl NSApplicationDelegate for GraphiteApplicationDelegate { unsafe impl NSApplicationDelegate for GraphiteApplicationDelegate {
#[unsafe(method(application:openURLs:))] #[unsafe(method(application:openURLs:))]
fn application_open_urls(&self, _application: &NSApplication, urls: &NSArray<NSURL>) { fn application_open_urls(&self, _application: &NSApplication, urls: &NSArray<NSURL>) {
let app_event_scheduler = APP_EVENT_SCHEDULER.lock().unwrap(); let paths = (0..urls.count())
.filter_map(|index| {
let mut pending_paths_to_open = LAUNCH_DOCUMENTS.lock().unwrap();
for index in 0..urls.count() {
let url = urls.objectAtIndex(index); let url = urls.objectAtIndex(index);
if !url.isFileURL() { if !url.isFileURL() {
tracing::error!("Ignoring macOS open URL event for non-file URL: {:?}", url); tracing::error!("Ignoring open URL event for non-file URL: {:?}", url);
continue; return None;
} }
let cstr = unsafe { CStr::from_ptr(url.fileSystemRepresentation().as_ptr()) };
let path = PathBuf::from(OsStr::from_bytes(cstr.to_bytes()));
Some(path)
})
.collect::<Vec<_>>();
let path = unsafe { CStr::from_ptr(url.fileSystemRepresentation().as_ptr()) }; dispatch_event(AppEvent::OpenFiles(paths));
let path = PathBuf::from(OsStr::from_bytes(path.to_bytes()));
pending_paths_to_open.push(path);
}
if let Some(app_event_scheduler) = app_event_scheduler.deref() {
app_event_scheduler.schedule(AppEvent::AddLaunchDocuments(std::mem::take(&mut pending_paths_to_open)));
}
} }
} }
); );
fn instance() -> objc2::rc::Retained<NSApplication> {
unsafe { msg_send![GraphiteApplication::class(), sharedApplication] }
}
pub(super) fn init() {
let _ = instance();
INSTALL_DELEGATE.call_once(|| {
let mtm = MainThreadMarker::new().expect("only ever called from main thread");
let delegate: Retained<GraphiteApplicationDelegate> = unsafe { msg_send![super(GraphiteApplicationDelegate::alloc(mtm).set_ivars(())), init] };
instance().setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
std::mem::forget(delegate);
});
}
pub(super) fn setup(app_event_scheduler: AppEventScheduler) {
let mut app_event_scheduler_guard = APP_EVENT_SCHEDULER.lock().unwrap();
let mut pending_paths_to_open = LAUNCH_DOCUMENTS.lock().unwrap();
app_event_scheduler.schedule(AppEvent::AddLaunchDocuments(std::mem::take(&mut pending_paths_to_open)));
*app_event_scheduler_guard = Some(app_event_scheduler);
}
pub(super) fn hide() {
instance().hide(None);
}
pub(super) fn hide_others() {
instance().hideOtherApplications(None);
}
pub(super) fn show_all() {
instance().unhideAllApplications(None);
}