use std::sync::mpsc;
use std::time::Duration;
use egui::{Context, FullOutput, RawInput};
use crate::protocol::{EncodedPng, Request, Response};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(PartialEq, Eq)]
enum Phase {
New,
AwaitOutput,
AwaitScreenshot { id: u64 },
}
struct InFlight {
req: Request,
reply: Option<Box<dyn FnOnce(Response) + Send + Sync>>,
phase: Phase,
}
pub struct InspectionPlugin {
in_flight: Vec<InFlight>,
step: u64,
next_screenshot_id: u64,
label: Option<String>,
}
impl InspectionPlugin {
pub fn new(label: Option<String>) -> Self {
Self {
in_flight: Vec::new(),
step: 0,
next_screenshot_id: 0,
label,
}
}
pub fn submit(
&mut self,
req: Request,
on_reply: impl FnOnce(Response) + Send + Sync + 'static,
) {
self.in_flight.push(InFlight {
req,
reply: Some(Box::new(on_reply)),
phase: Phase::New,
});
}
fn maybe_repaint(&self, ctx: &Context) {
if !self.in_flight.is_empty() {
ctx.request_repaint();
}
}
}
impl egui::Plugin for InspectionPlugin {
fn debug_name(&self) -> &'static str {
"egui_inspection"
}
fn setup(&mut self, ctx: &Context) {
ctx.enable_accesskit();
}
fn input_hook(&mut self, ctx: &Context, input: &mut RawInput) {
if self.in_flight.is_empty() {
return;
}
let pixels_per_point = ctx.pixels_per_point();
for ev in &input.events {
let egui::Event::Screenshot {
user_data, image, ..
} = ev
else {
continue;
};
let Some(id) = user_data
.data
.as_ref()
.and_then(|d| d.downcast_ref::<u64>())
.copied()
else {
continue; };
self.in_flight.retain_mut(|item| {
if item.phase != (Phase::AwaitScreenshot { id }) {
return true;
}
let scale = match item.req {
Request::GetScreenshot {
pixels_per_point: Some(requested_ppp),
} => requested_ppp / pixels_per_point,
_ => 1.0,
};
let png = match EncodedPng::from_color_image_scaled(image.as_ref(), scale) {
Ok(png) => png,
Err(err) => {
log::error!("egui_inspection: PNG encode failed: {err}");
return false;
}
};
if let Some(reply) = item.reply.take() {
reply(Response::Screenshot(png));
}
false
});
}
let label = self.label.clone();
let mut next_id = self.next_screenshot_id;
self.in_flight.retain_mut(|item| {
if item.phase != Phase::New {
return true;
}
match &item.req {
Request::GetInfo => {
if let Some(reply) = item.reply.take() {
reply(Response::Info {
label: label.clone(),
egui_version: env!("CARGO_PKG_VERSION").to_owned(),
});
}
false
}
Request::GetTree => {
item.phase = Phase::AwaitOutput;
true
}
Request::ApplyEvents { events } => {
input.events.extend(events.iter().cloned());
item.phase = Phase::AwaitOutput;
true
}
Request::Resize { width, height } => {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
*width as f32,
*height as f32,
)));
item.phase = Phase::AwaitOutput;
true
}
Request::GetScreenshot { .. } => {
let id = next_id;
next_id += 1;
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(
id,
)));
item.phase = Phase::AwaitScreenshot { id };
true
}
}
});
self.next_screenshot_id = next_id;
self.maybe_repaint(ctx);
}
fn output_hook(&mut self, ctx: &Context, output: &mut FullOutput) {
self.step = self.step.saturating_add(1);
if self.in_flight.is_empty() {
return;
}
let step = self.step;
self.in_flight
.retain_mut(|item| match (&item.phase, &item.req) {
(Phase::AwaitOutput, Request::GetTree) => {
if let Some(reply) = item.reply.take() {
reply(Response::Tree {
step,
pixels_per_point: output.pixels_per_point,
accesskit: output.platform_output.accesskit_update.clone(),
});
}
false
}
(Phase::AwaitOutput, Request::ApplyEvents { .. } | Request::Resize { .. }) => {
if let Some(reply) = item.reply.take() {
reply(Response::Done);
}
false
}
_ => true,
});
self.maybe_repaint(ctx);
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn attach_from_env(ctx: &Context, label: Option<String>) -> std::io::Result<bool> {
let Some(addr) = crate::bind_addr_from_env() else {
return Ok(false);
};
ctx.add_plugin(InspectionPlugin::new(label));
serve(ctx, &addr)?;
Ok(true)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn serve(ctx: &Context, addr: &str) -> std::io::Result<()> {
use std::net::{TcpListener, ToSocketAddrs as _};
let resolved = addr
.to_socket_addrs()?
.next()
.ok_or_else(|| std::io::Error::other(format!("no address resolved from {addr:?}")))?;
let listener = TcpListener::bind(resolved)?;
let bound = listener.local_addr()?;
if bound.ip().is_loopback() {
log::info!("egui_inspection: listening on {bound}");
} else {
log::warn!(
"egui_inspection: listening on {bound} — the inspection port is reachable from \
the network with NO authentication; anyone who can reach it can drive the app \
and read its screen"
);
}
let ctx = ctx.clone();
std::thread::Builder::new()
.name("egui_inspection_accept".into())
.spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let ctx = ctx.clone();
std::thread::Builder::new()
.name("egui_inspection_conn".into())
.spawn(move || {
if let Err(err) = serve_connection(stream, &ctx) {
log::warn!("egui_inspection: connection ended: {err}");
}
})
.expect("failed to spawn egui_inspection connection thread");
}
})?;
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn serve_connection(stream: std::net::TcpStream, ctx: &Context) -> std::io::Result<()> {
use crate::protocol::{read_message, write_handshake, write_message};
let mut reader = std::io::BufReader::new(stream.try_clone()?);
let mut writer = std::io::BufWriter::new(stream);
write_handshake(&mut writer)?;
loop {
let req: Request = match read_message(&mut reader) {
Ok(req) => req,
Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), Err(err) => return Err(err),
};
let (tx, rx) = mpsc::channel();
let registered = ctx
.with_plugin::<InspectionPlugin, _>(|p| {
p.submit(req, move |resp| {
let _ = tx.send(resp);
});
})
.is_some();
if !registered {
return write_message(
&mut writer,
&Response::Error {
message: "egui_inspection plugin not registered".to_owned(),
},
);
}
ctx.request_repaint();
let resp = rx.recv_timeout(REQUEST_TIMEOUT).unwrap_or_else(|_| {
log::error!(
"egui_inspection: request timed out after {REQUEST_TIMEOUT:?}; the app is not \
painting (is the window occluded or minimized?)"
);
Response::Error {
message: "request timed out — the app is not painting; bring its window to the \
foreground"
.to_owned(),
}
});
write_message(&mut writer, &resp)?;
}
}