mirror of
https://github.com/emilk/egui.git
synced 2026-09-02 14:50:03 -04:00
Add egui_inspection crate and eframe inspection hooks
New `egui_inspection` crate ships: - `protocol` (default): wire types + length-prefixed msgpack framing for the inspector ↔ egui-peer connection. Transport-neutral (stdio / unix socket / TCP). - `plugin`: `InspectionPlugin`, an `egui::Plugin` that dials a unix socket from `EGUI_INSPECTION_SOCKET`, streams frames + accesskit tree updates, and applies inbound `InspectorCommand`s back into the running `egui::Context`. eframe gains an `inspection` feature that auto-attaches the plugin during native startup (glow + wgpu integrations) when the env var is set. Connection failures log via `log::warn!` and do not abort startup. Lives in its own crate (rather than `egui_kittest`) so eframe can pull the protocol in without picking up the test harness, and so external tools can depend on it directly.
This commit is contained in:
356
crates/egui_inspection/src/plugin.rs
Normal file
356
crates/egui_inspection/src/plugin.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
//! [`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
|
||||
//! running app.
|
||||
//!
|
||||
//! Connection model:
|
||||
//! - The inspector binds a unix 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.
|
||||
//! - If the writer channel is saturated, the plugin drops the oldest frame in favor of the
|
||||
//! newest so the UI thread never blocks on a slow inspector.
|
||||
//!
|
||||
//! Live apps don't own a deterministic run loop, so `Step` / `Run` / `Play` / `Pause`
|
||||
//! commands are no-ops. `Handle { events }` is honored by appending the events to the next
|
||||
//! `RawInput`. After every received command the reader thread calls
|
||||
//! `Context::request_repaint` so the integration wakes up even when the UI is otherwise
|
||||
//! idle — without this, queued events would sit in the channel until the next mouse move.
|
||||
//!
|
||||
//! # Reference cycle
|
||||
//!
|
||||
//! The plugin holds a clone of `egui::Context` so the reader thread can wake the UI loop.
|
||||
//! `egui::Context` is `Arc<RwLock<…>>` and the context owns its plugins, so this creates an
|
||||
//! intentional cycle: the context will not drop until the process exits. Acceptable for a
|
||||
//! live-debugging inspector — the typical workflow is "attach for the lifetime of the
|
||||
//! 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 crate::INSPECTION_SOCKET_ENV_VAR;
|
||||
use crate::protocol::{
|
||||
Capabilities, Frame, FrameScreenshot, HarnessMessage, InspectorCommand, PROTOCOL_VERSION,
|
||||
PeerHello, PeerKind, read_message, write_message,
|
||||
};
|
||||
|
||||
/// Errors that can occur attaching to an inspector.
|
||||
#[derive(Debug)]
|
||||
pub enum InspectionError {
|
||||
/// Failed to dial the inspector socket.
|
||||
Connect(std::io::Error),
|
||||
/// Failed to set up reader / writer threads.
|
||||
Pipe(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InspectionError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Connect(err) => write!(
|
||||
f,
|
||||
"failed to connect to egui_inspection socket (set {INSPECTION_SOCKET_ENV_VAR}): {err}"
|
||||
),
|
||||
Self::Pipe(msg) => write!(f, "egui_inspection pipe setup failed: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InspectionError {}
|
||||
|
||||
/// Bounded outbound queue depth. If the inspector falls behind we drop oldest frames
|
||||
/// rather than block the UI thread.
|
||||
const OUTBOUND_QUEUE_DEPTH: usize = 8;
|
||||
|
||||
/// Shared between [`InspectionPlugin::setup`] and the reader thread so the reader can wake
|
||||
/// the UI loop after each received command. Written exactly once in `setup`.
|
||||
type SharedCtx = Arc<OnceLock<Context>>;
|
||||
|
||||
/// `egui::Plugin` that streams the running app's state to an inspector.
|
||||
pub struct InspectionPlugin {
|
||||
/// Incoming commands from the inspector.
|
||||
command_rx: Arc<Mutex<mpsc::Receiver<InspectorCommand>>>,
|
||||
/// Outbound messages → writer thread → socket. Bounded; oldest is dropped on overflow.
|
||||
outbound_tx: mpsc::SyncSender<HarnessMessage>,
|
||||
/// Filled in `Plugin::setup`; read by the reader thread to call `request_repaint` after
|
||||
/// every received command.
|
||||
shared_ctx: SharedCtx,
|
||||
/// Monotonic frame counter.
|
||||
step: u64,
|
||||
/// Frame data (accesskit + meta) captured in `output_hook`, held until the matching
|
||||
/// `Event::Screenshot` arrives in the next `input_hook`. Emitting only on pair-up keeps
|
||||
/// the inspector's screenshot and accesskit tree in lockstep — the alternative (emit
|
||||
/// accesskit now, screenshot later) shows widget boxes that don't match the rendered
|
||||
/// frame they overlay.
|
||||
pending_frame: Option<Frame>,
|
||||
/// `true` between dispatching `ViewportCommand::Screenshot` and observing the reply
|
||||
/// `Event::Screenshot`. While set, the plugin keeps requesting repaints so the
|
||||
/// integration eventually paints a visible frame and the screenshot fulfills (the eframe
|
||||
/// wgpu path skips capture when the viewport reports `visible=false`).
|
||||
awaiting_screenshot: bool,
|
||||
/// Set by [`InspectorCommand::Screenshot`]; consumed by the next `output_hook` which
|
||||
/// dispatches a `ViewportCommand::Screenshot` and stashes the frame.
|
||||
one_shot_screenshot: bool,
|
||||
/// When `true`, every `output_hook` requests a `ViewportCommand::Screenshot` and holds
|
||||
/// the frame until the screenshot returns. Toggled by
|
||||
/// [`InspectorCommand::SetContinuousScreenshots`].
|
||||
continuous_screenshots: bool,
|
||||
/// Background threads — held so they live as long as the plugin.
|
||||
_reader_thread: thread::JoinHandle<()>,
|
||||
_writer_thread: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl InspectionPlugin {
|
||||
/// If [`INSPECTION_SOCKET_ENV_VAR`] is set, return a plugin connected to it.
|
||||
/// Returns `Ok(None)` when the env var is unset.
|
||||
///
|
||||
/// # 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 {
|
||||
return Ok(None);
|
||||
};
|
||||
Self::attach(PathBuf::from(path), label).map(Some)
|
||||
}
|
||||
|
||||
/// Dial the given unix socket 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;
|
||||
|
||||
let shared_ctx: SharedCtx = Arc::new(OnceLock::new());
|
||||
|
||||
let (command_tx, command_rx) = mpsc::channel::<InspectorCommand>();
|
||||
let reader_ctx = shared_ctx.clone();
|
||||
let reader_thread = thread::Builder::new()
|
||||
.name("egui_inspection_reader".into())
|
||||
.spawn(move || run_reader(BufReader::new(reader_stream), &command_tx, &reader_ctx))
|
||||
.map_err(|err| InspectionError::Pipe(format!("spawn reader thread: {err}")))?;
|
||||
|
||||
let (outbound_tx, outbound_rx) = mpsc::sync_channel::<HarnessMessage>(OUTBOUND_QUEUE_DEPTH);
|
||||
let writer_thread = thread::Builder::new()
|
||||
.name("egui_inspection_writer".into())
|
||||
.spawn(move || run_writer(BufWriter::new(writer_stream), outbound_rx))
|
||||
.map_err(|err| InspectionError::Pipe(format!("spawn writer thread: {err}")))?;
|
||||
|
||||
// Hello must be the first message on the wire. Send via the writer-thread queue
|
||||
// (rather than directly on the stream) so ordering against later frames is
|
||||
// preserved even under contention.
|
||||
let hello = HarnessMessage::Hello(PeerHello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
peer_kind: PeerKind::Live,
|
||||
capabilities: Capabilities::LIVE,
|
||||
// Live apps start accesskit-only; inspector flips on via
|
||||
// `SetContinuousScreenshots(true)` when it wants images.
|
||||
continuous_screenshots: false,
|
||||
label,
|
||||
});
|
||||
outbound_tx
|
||||
.send(hello)
|
||||
.map_err(|err| InspectionError::Pipe(format!("send Hello: {err}")))?;
|
||||
|
||||
Ok(Self {
|
||||
command_rx: Arc::new(Mutex::new(command_rx)),
|
||||
outbound_tx,
|
||||
shared_ctx,
|
||||
step: 0,
|
||||
pending_frame: None,
|
||||
awaiting_screenshot: false,
|
||||
one_shot_screenshot: false,
|
||||
continuous_screenshots: false,
|
||||
_reader_thread: reader_thread,
|
||||
_writer_thread: writer_thread,
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort send. Drops oldest frame on overflow so the UI thread never blocks.
|
||||
fn send(&self, msg: HarnessMessage) {
|
||||
match self.outbound_tx.try_send(msg) {
|
||||
Ok(()) => {}
|
||||
Err(mpsc::TrySendError::Full(msg)) => {
|
||||
// Queue saturated — try once more in case the writer just drained a slot.
|
||||
// If still full we drop the message. UI thread never blocks.
|
||||
let _ = self.outbound_tx.try_send(msg);
|
||||
}
|
||||
Err(mpsc::TrySendError::Disconnected(_)) => { /* writer is gone */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl egui::Plugin for InspectionPlugin {
|
||||
fn debug_name(&self) -> &'static str {
|
||||
"egui_inspection"
|
||||
}
|
||||
|
||||
fn setup(&mut self, ctx: &Context) {
|
||||
// We rely on the AccessKit tree to describe the UI structure to the inspector.
|
||||
ctx.enable_accesskit();
|
||||
// Hand the context to the reader thread so it can wake the UI loop when commands
|
||||
// arrive on an otherwise-idle app. `set` only succeeds the first time, which is
|
||||
// what we want — `setup` is documented to run once per plugin registration.
|
||||
let _ = self.shared_ctx.set(ctx.clone());
|
||||
}
|
||||
|
||||
fn input_hook(&mut self, input: &mut RawInput) {
|
||||
// Capture any screenshot reply the integration produced in response to our previous
|
||||
// `ViewportCommand::Screenshot`. If we're holding a frame waiting for this
|
||||
// screenshot, attach the pixels and emit the pair now. Without a pending frame the
|
||||
// screenshot is stray (we never dispatched) and we drop it. We observe (don't
|
||||
// consume) — apps using the same event keep getting it.
|
||||
for ev in &input.events {
|
||||
if let egui::Event::Screenshot { image, .. } = ev {
|
||||
self.awaiting_screenshot = false;
|
||||
if let Some(mut frame) = self.pending_frame.take() {
|
||||
let [w, h] = [image.size[0] as u32, image.size[1] as u32];
|
||||
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
|
||||
frame.screenshot = Some(FrameScreenshot {
|
||||
width: w,
|
||||
height: h,
|
||||
rgba,
|
||||
});
|
||||
self.send(HarnessMessage::Frame(Box::new(frame)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Drain any commands the inspector sent since the previous frame.
|
||||
let mut got_command = false;
|
||||
let rx = self.command_rx.lock().expect("poisoned");
|
||||
while let Ok(cmd) = rx.try_recv() {
|
||||
got_command = true;
|
||||
match cmd {
|
||||
InspectorCommand::Handle { events } => {
|
||||
input.events.extend(events);
|
||||
}
|
||||
InspectorCommand::Screenshot => {
|
||||
self.one_shot_screenshot = true;
|
||||
}
|
||||
InspectorCommand::SetContinuousScreenshots(on) => {
|
||||
self.continuous_screenshots = on;
|
||||
}
|
||||
InspectorCommand::Resize { width, height } => {
|
||||
if let Some(ctx) = self.shared_ctx.get() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
|
||||
width as f32,
|
||||
height as f32,
|
||||
)));
|
||||
}
|
||||
}
|
||||
// The live-app path doesn't own a deterministic run loop, so the
|
||||
// step/run/play/pause commands are no-ops here. The deterministic side
|
||||
// lives in `egui_kittest::InspectorPlugin`.
|
||||
InspectorCommand::Step
|
||||
| InspectorCommand::Run
|
||||
| InspectorCommand::Play
|
||||
| InspectorCommand::Pause => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive-mode apps only paint on input. The reader thread's `request_repaint`
|
||||
// woke us for the current frame, but viewport-command replies (`Event::Screenshot`)
|
||||
// and synthetic `Handle` events both need at least one *more* frame to be observed
|
||||
// by the host app and round-trip back into a `Frame` we can emit. Without an extra
|
||||
// repaint scheduled now, the app goes idle until an unrelated wake-up (mouse move,
|
||||
// timer) and the inspector sees a multi-second stall.
|
||||
//
|
||||
// While a screenshot is outstanding (or continuous mode is on), keep requesting
|
||||
// repaints every frame — eframe's wgpu path skips screenshot capture when the
|
||||
// viewport reports `visible=false`, so a backgrounded window won't fulfill the
|
||||
// request until it next becomes visible. We can't force visibility from here without
|
||||
// disturbing focus, but pumping repaints keeps the app alive so the moment the OS
|
||||
// reports visibility (cursor enters, app brought forward, system unhide) the queued
|
||||
// action fires.
|
||||
if got_command || self.awaiting_screenshot || self.continuous_screenshots {
|
||||
if let Some(ctx) = self.shared_ctx.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn output_hook(&mut self, output: &mut FullOutput) {
|
||||
self.step = self.step.saturating_add(1);
|
||||
let want_screenshot = self.continuous_screenshots || self.one_shot_screenshot;
|
||||
self.one_shot_screenshot = false;
|
||||
|
||||
// Pull the AccessKit tree update out of the PlatformOutput. We *clone* rather than
|
||||
// take so the host integration still receives it for the real accessibility stack.
|
||||
let tree = output.platform_output.accesskit_update.clone();
|
||||
|
||||
let frame = Frame {
|
||||
step: self.step,
|
||||
pixels_per_point: output.pixels_per_point,
|
||||
screenshot: None,
|
||||
accesskit: tree,
|
||||
source: None,
|
||||
};
|
||||
|
||||
if !want_screenshot {
|
||||
// No screenshot needed — emit immediately.
|
||||
self.send(HarnessMessage::Frame(Box::new(frame)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Want a screenshot. If the previous frame's request is still outstanding, drop
|
||||
// this output entirely (the screenshot reply would otherwise pair with a stale
|
||||
// accesskit tree). Slow inspector → matched-pair frames > throughput; the user
|
||||
// explicitly opted into this delay by enabling continuous screenshots.
|
||||
if self.awaiting_screenshot {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hold the frame; dispatch a screenshot request for what was just rendered. The
|
||||
// matching `Event::Screenshot` arrives in the next `input_hook`, where we attach
|
||||
// pixels and emit.
|
||||
self.pending_frame = Some(frame);
|
||||
if let Some(ctx) = self.shared_ctx.get() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
|
||||
self.awaiting_screenshot = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reader-thread entry point: forward every decoded [`InspectorCommand`] into the channel
|
||||
/// 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>,
|
||||
tx: &mpsc::Sender<InspectorCommand>,
|
||||
ctx: &SharedCtx,
|
||||
) {
|
||||
loop {
|
||||
match read_message::<_, InspectorCommand>(&mut reader) {
|
||||
Ok(cmd) => {
|
||||
if tx.send(cmd).is_err() {
|
||||
return;
|
||||
}
|
||||
if let Some(ctx) = ctx.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writer-thread entry point: drain the outbound queue, framing each message to the socket.
|
||||
fn run_writer(
|
||||
mut writer: BufWriter<UnixStream>,
|
||||
rx: mpsc::Receiver<HarnessMessage>,
|
||||
) {
|
||||
while let Ok(msg) = rx.recv() {
|
||||
if write_message(&mut writer, &msg).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user