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:
33
Cargo.lock
33
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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
70
crates/egui_inspection/src/transport.rs
Normal file
70
crates/egui_inspection/src/transport.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user