1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -04:00

egui_inspection: cross-platform local socket via interprocess

Replace std::os::unix::net::UnixStream in the InspectionPlugin with the
interprocess crate's local_socket::Stream, so the transport works on Windows
(named pipe) as well as unix/macOS (unix domain socket).

- New transport module (transport feature) with socket_name() and
  generate_socket_target() — one shared, platform-split place to build/allocate
  local-socket names, used by both ends of the connection.
- Drop the cfg(unix) gates on the plugin module; gate on the plugin feature only.
- attach() now takes a socket name string and connects via interprocess; the
  stream is split with Stream::split() instead of UnixStream::try_clone().
This commit is contained in:
lucasmerlin
2026-05-26 14:15:47 +02:00
parent 622218e94f
commit d67862ace6
5 changed files with 136 additions and 26 deletions

View File

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

View File

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

View File

@@ -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};

View File

@@ -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<String>) -> Result<Option<Self>, 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<String>) -> Result<Self, InspectionError> {
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<String>) -> Result<Self, InspectionError> {
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<UnixStream>,
mut reader: BufReader<RecvHalf>,
tx: &mpsc::Sender<InspectorCommand>,
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<UnixStream>,
mut writer: BufWriter<SendHalf>,
rx: mpsc::Receiver<HarnessMessage>,
) {
while let Ok(msg) = rx.recv() {

View File

@@ -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<Name<'static>> {
#[cfg(not(windows))]
{
raw.to_owned().to_fs_name::<GenericFilePath>()
}
#[cfg(windows)]
{
raw.to_owned().to_ns_name::<GenericNamespaced>()
}
}
/// 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<SocketTarget> {
#[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 })
}
}