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..832cdf943b 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -48,6 +48,7 @@ lzma-rust2 = { workspace = true } serde = { workspace = true } rand = { workspace = true, features = ["thread_rng"] } clap = { workspace = true, features = ["derive"] } +interprocess = "2.4.2" fd-lock = "4.0.4" ctrlc = "3.5.1" window_clipboard = "0.5" diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 6becdf279f..19bab01a9b 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, @@ -320,7 +319,7 @@ impl App { tracing::error!("OpenLaunchDocuments should only be sent once"); return; }; - self.open_files(launch_documents); + self.app_event_scheduler.schedule(AppEvent::OpenFiles(launch_documents)); } DesktopFrontendMessage::UpdateMenu { entries } => { if let Some(window) = &self.window { @@ -478,13 +477,28 @@ impl App { tracing::info!("Exiting main event loop"); event_loop.exit(); } - #[cfg(target_os = "macos")] - AppEvent::AddLaunchDocuments(paths) => { + AppEvent::OpenFiles(paths) => { + // Accumulate launch documents until OpenLaunchDocuments message is received if let Some(launch_documents) = &mut self.launch_documents { launch_documents.extend(paths); - } else { - self.open_files(paths); + return; + } + + if paths.is_empty() { + return; } + let app_event_scheduler = self.app_event_scheduler.clone(); + let _ = thread::spawn(move || { + for path in paths { + tracing::info!("Opening file: {}", path.display()); + if let Ok(content) = fs::read(&path) { + let message = DesktopWrapperMessage::OpenFile { path, content }; + app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); + } else { + tracing::error!("Failed to read file: {}", path.display()); + } + } + }); } #[cfg(target_os = "macos")] AppEvent::MenuEvent { id } => { @@ -492,24 +506,6 @@ impl App { } } } - - fn open_files(&mut self, paths: Vec) { - if paths.is_empty() { - return; - } - let app_event_scheduler = self.app_event_scheduler.clone(); - let _ = thread::spawn(move || { - for path in paths { - tracing::info!("Opening file: {}", path.display()); - if let Ok(content) = fs::read(&path) { - let message = DesktopWrapperMessage::OpenFile { path, content }; - app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); - } else { - tracing::error!("Failed to read file: {}", path.display()); - } - } - }); - } } impl ApplicationHandler for App { fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) { diff --git a/desktop/src/consts.rs b/desktop/src/consts.rs index d51594ea16..3ffa7d887f 100644 --- a/desktop/src/consts.rs +++ b/desktop/src/consts.rs @@ -7,6 +7,7 @@ pub(crate) const APP_DIRECTORY_NAME: &str = "graphite"; #[cfg(not(target_os = "linux"))] pub(crate) const APP_DIRECTORY_NAME: &str = "Graphite"; 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_PREFERENCES_FILE_NAME: &str = "preferences.ron"; pub(crate) const APP_DOCUMENTS_DIRECTORY_NAME: &str = "documents"; diff --git a/desktop/src/event.rs b/desktop/src/event.rs index 99776e6812..d46debfcd3 100644 --- a/desktop/src/event.rs +++ b/desktop/src/event.rs @@ -9,8 +9,7 @@ pub(crate) enum AppEvent { DesktopWrapperMessage(DesktopWrapperMessage), NodeGraphExecutionResult(NodeGraphExecutionResult), Exit, - #[cfg(target_os = "macos")] - AddLaunchDocuments(Vec), + OpenFiles(Vec), #[cfg(target_os = "macos")] MenuEvent { id: String, diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index a23d80edd2..c8efe6baf0 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -19,6 +19,7 @@ mod gpu_context; mod persist; mod preferences; mod render; +mod socket; mod window; pub(crate) mod consts; @@ -58,7 +59,13 @@ pub fn start() { } Err(_) => { tracing::error!("Another instance is already running, Exiting."); - std::process::exit(1); + 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); + } + return; } }; @@ -78,6 +85,8 @@ 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); + let _socket_handle = socket::start(app_event_scheduler.clone()); + let (cef_view_info_sender, cef_view_info_receiver) = std::sync::mpsc::channel(); if cli.disable_ui_acceleration { diff --git a/desktop/src/socket.rs b/desktop/src/socket.rs new file mode 100644 index 0000000000..77418d9d90 --- /dev/null +++ b/desktop/src/socket.rs @@ -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), +} + +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>, + 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::().expect("valid named pipe name") + } else { + crate::dirs::app_data_dir().join(APP_SOCKET_FILE_NAME).to_fs_name::().expect("valid socket path") + } +} diff --git a/desktop/src/window/mac/app.rs b/desktop/src/window/mac/app.rs index cc97aca100..a152bc535c 100644 --- a/desktop/src/window/mac/app.rs +++ b/desktop/src/window/mac/app.rs @@ -1,6 +1,7 @@ use std::ffi::CStr; use std::ffi::OsStr; use std::ops::Deref; +use std::ops::DerefMut; use std::os::unix::ffi::OsStrExt; use std::path::PathBuf; use std::sync::{Mutex, Once}; @@ -14,9 +15,61 @@ use objc2_foundation::{NSArray, NSObject, NSObjectProtocol, NSURL}; use crate::event::{AppEvent, AppEventScheduler}; static APP_EVENT_SCHEDULER: Mutex> = Mutex::new(None); +static PENDING_EVENTS: Mutex>> = 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 { + unsafe { msg_send![GraphiteApplication::class(), sharedApplication] } +} + static INSTALL_DELEGATE: Once = Once::new(); -static LAUNCH_DOCUMENTS: Mutex> = 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 = 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!( #[unsafe(super(NSApplication, NSResponder, NSObject))] @@ -47,62 +100,20 @@ define_class!( unsafe impl NSApplicationDelegate for GraphiteApplicationDelegate { #[unsafe(method(application:openURLs:))] fn application_open_urls(&self, _application: &NSApplication, urls: &NSArray) { - let app_event_scheduler = APP_EVENT_SCHEDULER.lock().unwrap(); - - let mut pending_paths_to_open = LAUNCH_DOCUMENTS.lock().unwrap(); - - for index in 0..urls.count() { - let url = urls.objectAtIndex(index); - if !url.isFileURL() { - tracing::error!("Ignoring macOS open URL event for non-file URL: {:?}", url); - continue; - } - - let path = unsafe { CStr::from_ptr(url.fileSystemRepresentation().as_ptr()) }; - 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))); - } + let paths = (0..urls.count()) + .filter_map(|index| { + let url = urls.objectAtIndex(index); + if !url.isFileURL() { + tracing::error!("Ignoring open URL event for non-file URL: {:?}", url); + 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::>(); + + dispatch_event(AppEvent::OpenFiles(paths)); } } ); - -fn instance() -> objc2::rc::Retained { - 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 = 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); -}