diff --git a/Cargo.lock b/Cargo.lock index 71f844172..82c015d44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1174,6 +1174,12 @@ dependencies = [ "libloading", ] +[[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.12" @@ -1399,9 +1405,11 @@ dependencies = [ "document-features", "egui", "image", + "interprocess", "rmp-serde", "serde", "serde_bytes", + "tempfile", ] [[package]] @@ -2369,6 +2377,19 @@ dependencies = [ "hashbrown 0.16.1", ] +[[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 = "is-docker" version = "0.2.0" @@ -3832,6 +3853,12 @@ dependencies = [ "font-types", ] +[[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.4.1" @@ -5485,6 +5512,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/crates/egui_inspection/Cargo.toml b/crates/egui_inspection/Cargo.toml index b619bc464..dad208dd6 100644 --- a/crates/egui_inspection/Cargo.toml +++ b/crates/egui_inspection/Cargo.toml @@ -27,10 +27,15 @@ default = ["protocol"] ## MessagePack framing helpers. No `egui` dependency beyond `egui::accesskit`. protocol = ["dep:rmp-serde", "dep:serde", "dep:serde_bytes", "egui/serde"] +## Cross-platform local-socket name helpers ([`transport::socket_name`], +## [`transport::generate_socket_target`]) built on `interprocess`: unix domain sockets on +## unix, named pipes on Windows. Shared by both ends of the connection. +transport = ["dep:interprocess", "dep:tempfile"] + ## `InspectionPlugin` — an `egui::Plugin` impl that streams frames + accesskit tree to -## an inspector over a unix socket and applies received commands. Auto-attaches when +## an inspector over a local socket and applies received commands. Auto-attaches when ## the [`INSPECTION_SOCKET_ENV_VAR`] env var is set. -plugin = ["protocol", "dep:image"] +plugin = ["protocol", "transport", "dep:image"] [dependencies] egui.workspace = true @@ -38,6 +43,8 @@ serde = { workspace = true, optional = true } serde_bytes = { version = "0.11.17", optional = true } rmp-serde = { workspace = true, optional = true } image = { workspace = true, optional = true } +interprocess = { version = "2.4", optional = true } +tempfile = { workspace = true, optional = true } document-features = { workspace = true, optional = true } diff --git a/crates/egui_inspection/src/lib.rs b/crates/egui_inspection/src/lib.rs index 2ef64e2f0..be27df02e 100644 --- a/crates/egui_inspection/src/lib.rs +++ b/crates/egui_inspection/src/lib.rs @@ -12,18 +12,20 @@ pub use protocol::{ PROTOCOL_VERSION, PeerHello, PeerKind, SourceView, read_message, write_message, }; -/// Environment variable: when set to a unix socket path, [`InspectionPlugin::from_env`] -/// (and similar inspector-side code) connects to it. +/// Environment variable: when set to a local-socket name, [`InspectionPlugin::from_env`] +/// (and similar inspector-side code) connects to it. Parse it with +/// [`transport::socket_name`]. /// -/// Exposed unconditionally so both ends of the connection — the plugin (on `plugin`, -/// unix) and the inspector / MCP server — can reference the same name without pulling in -/// the full plugin impl. +/// Exposed unconditionally so both ends of the connection — the plugin (on `plugin`) and +/// the inspector / MCP server — can reference the same name without pulling in the full +/// plugin impl. pub const INSPECTION_SOCKET_ENV_VAR: &str = "EGUI_INSPECTION_SOCKET"; -// The plugin uses `std::os::unix::net::UnixStream` for transport, so the impl is -// unix-only. Non-unix builds with `plugin` enabled still get the protocol types. -#[cfg(all(feature = "plugin", unix))] +#[cfg(feature = "transport")] +pub mod transport; + +#[cfg(feature = "plugin")] mod plugin; -#[cfg(all(feature = "plugin", unix))] +#[cfg(feature = "plugin")] pub use plugin::{InspectionError, InspectionPlugin}; diff --git a/crates/egui_inspection/src/plugin.rs b/crates/egui_inspection/src/plugin.rs index d8403be4d..09a2e9529 100644 --- a/crates/egui_inspection/src/plugin.rs +++ b/crates/egui_inspection/src/plugin.rs @@ -1,9 +1,9 @@ //! [`InspectionPlugin`] — an [`egui::Plugin`] that streams frames + AccessKit tree updates -//! to an inspector over a unix domain socket and applies received commands back into the +//! to an inspector over a local socket and applies received commands back into the //! running app. //! //! Connection model: -//! - The inspector binds a unix socket. The egui peer dials it. +//! - The inspector binds a local socket. The egui peer dials it. //! - The plugin spawns one reader thread and one writer thread, each owning one half of the //! stream. UI-thread hooks (`input_hook` / `output_hook`) only touch in-process channels //! and the reader-side command queue. @@ -25,15 +25,15 @@ //! process, then exit." For deterministic shutdown, kill the process. use std::io::{BufReader, BufWriter}; -use std::os::unix::net::UnixStream; -use std::path::PathBuf; use std::sync::mpsc; use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use egui::{Context, FullOutput, RawInput}; +use interprocess::local_socket::{RecvHalf, SendHalf, Stream, prelude::*}; use crate::INSPECTION_SOCKET_ENV_VAR; +use crate::transport::socket_name; use crate::protocol::{ Capabilities, Frame, FrameScreenshot, HarnessMessage, InspectorCommand, PROTOCOL_VERSION, PeerHello, PeerKind, read_message, write_message, @@ -111,22 +111,20 @@ impl InspectionPlugin { /// # Errors /// When the env var is set but the socket can't be dialed. pub fn from_env(label: Option) -> Result, InspectionError> { - let Ok(path) = std::env::var(INSPECTION_SOCKET_ENV_VAR) else { + let Ok(name) = std::env::var(INSPECTION_SOCKET_ENV_VAR) else { return Ok(None); }; - Self::attach(PathBuf::from(path), label).map(Some) + Self::attach(&name, label).map(Some) } - /// Dial the given unix socket and attach. + /// Dial the given local socket (see [`crate::transport::socket_name`]) and attach. /// /// # Errors /// When the socket can't be dialed or a thread can't be spawned. - pub fn attach(socket_path: PathBuf, label: Option) -> Result { - let stream = UnixStream::connect(&socket_path).map_err(InspectionError::Connect)?; - let reader_stream = stream - .try_clone() - .map_err(InspectionError::Connect)?; - let writer_stream = stream; + pub fn attach(socket: &str, label: Option) -> Result { + let name = socket_name(socket).map_err(InspectionError::Connect)?; + let stream = Stream::connect(name).map_err(InspectionError::Connect)?; + let (reader_stream, writer_stream) = stream.split(); let shared_ctx: SharedCtx = Arc::new(OnceLock::new()); @@ -324,7 +322,7 @@ impl egui::Plugin for InspectionPlugin { /// until EOF or the receiver is dropped. After each enqueue, wake the UI thread so an /// otherwise-idle app actually processes the command on its next frame. fn run_reader( - mut reader: BufReader, + mut reader: BufReader, tx: &mpsc::Sender, ctx: &SharedCtx, ) { @@ -345,7 +343,7 @@ fn run_reader( /// Writer-thread entry point: drain the outbound queue, framing each message to the socket. fn run_writer( - mut writer: BufWriter, + mut writer: BufWriter, rx: mpsc::Receiver, ) { while let Ok(msg) = rx.recv() { diff --git a/crates/egui_inspection/src/transport.rs b/crates/egui_inspection/src/transport.rs new file mode 100644 index 000000000..43d6a05df --- /dev/null +++ b/crates/egui_inspection/src/transport.rs @@ -0,0 +1,70 @@ +//! Cross-platform local-socket addressing for the inspection connection. +//! +//! [`interprocess`] maps a name to a unix domain socket (unix) or a named pipe (Windows), +//! so the transport works on every desktop platform without `cfg(unix)` gates. Both ends +//! must build the name the same way — hence the shared [`socket_name`] helper — and the +//! listener side allocates a fresh target via [`generate_socket_target`]. + +use std::io; + +use interprocess::local_socket::Name; +#[cfg(windows)] +use interprocess::local_socket::{GenericNamespaced, ToNsName as _}; +#[cfg(not(windows))] +use interprocess::local_socket::{GenericFilePath, ToFsName as _}; + +/// Build a platform-appropriate local-socket [`Name`] from the env-var string produced by +/// [`generate_socket_target`]. +/// +/// On unix the string is a filesystem path (unix domain socket); on Windows it is a +/// namespaced identifier (named pipe). Both ends call this so they agree on the mapping. +/// +/// # Errors +/// When the string is not a valid name for the platform's local-socket namespace. +pub fn socket_name(raw: &str) -> io::Result> { + #[cfg(not(windows))] + { + raw.to_owned().to_fs_name::() + } + #[cfg(windows)] + { + raw.to_owned().to_ns_name::() + } +} + +/// A freshly-allocated local-socket target for the listener side. +pub struct SocketTarget { + /// String to hand the peer (e.g. via an env var); parse it back with [`socket_name`]. + pub name: String, + + /// On unix, the tempdir owning the socket file — keep it alive for the socket's + /// lifetime, then dropping it removes the file. Absent on Windows (named pipes have no + /// filesystem object to clean up). + #[cfg(not(windows))] + #[expect(dead_code, reason = "RAII guard: kept alive to own the socket file")] + dir: tempfile::TempDir, +} + +/// Allocate a unique local-socket target for a listener to bind. +/// +/// # Errors +/// On unix, when the backing tempdir can't be created. +pub fn generate_socket_target() -> io::Result { + #[cfg(not(windows))] + { + let dir = tempfile::Builder::new() + .prefix("egui-inspection-") + .tempdir()?; + let name = dir.path().join("inspection.sock").to_string_lossy().into_owned(); + Ok(SocketTarget { name, dir }) + } + #[cfg(windows)] + { + use std::time::{SystemTime, UNIX_EPOCH}; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let name = format!("egui-inspection-{}-{nonce}.sock", std::process::id()); + Ok(SocketTarget { name }) + } +}