use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
pub use egui_mcp_protocol::{FrameStats, LogEntry, MouseButton, PerfReport, Request, Response};
mod log_layer;
mod server;
pub use log_layer::{LogBuffer, McpLogLayer, level_to_priority};
pub use server::IpcServer;
pub use egui;
#[derive(Debug, Clone)]
pub enum PendingInput {
Click { x: f32, y: f32, button: MouseButton },
DoubleClick { x: f32, y: f32, button: MouseButton },
MoveMouse { x: f32, y: f32 },
Keyboard { key: String },
Scroll {
x: f32,
y: f32,
delta_x: f32,
delta_y: f32,
},
Drag {
start_x: f32,
start_y: f32,
end_x: f32,
end_y: f32,
button: MouseButton,
},
}
#[derive(Debug, Clone)]
pub struct Highlight {
pub rect: egui::Rect,
pub color: egui::Color32,
pub expires_at: Option<std::time::Instant>,
}
#[derive(Clone)]
pub struct McpClient {
state: Arc<RwLock<ClientState>>,
}
struct ClientState {
socket_path: PathBuf,
screenshot_data: Option<Vec<u8>>,
screenshot_requested: bool,
pending_inputs: Vec<PendingInput>,
highlights: Vec<Highlight>,
log_buffer: Option<LogBuffer>,
frame_times: std::collections::VecDeque<std::time::Duration>,
max_frame_samples: usize,
perf_recording: Option<PerfRecording>,
last_frame_instant: Option<std::time::Instant>,
}
struct PerfRecording {
start_time: std::time::Instant,
frame_times: Vec<std::time::Duration>,
duration_ms: u64,
}
impl McpClient {
pub fn new() -> Self {
Self::with_socket_path(egui_mcp_protocol::default_socket_path())
}
pub fn with_socket_path(socket_path: PathBuf) -> Self {
Self {
state: Arc::new(RwLock::new(ClientState {
socket_path,
screenshot_data: None,
screenshot_requested: false,
pending_inputs: Vec::new(),
highlights: Vec::new(),
log_buffer: None,
frame_times: std::collections::VecDeque::with_capacity(120),
max_frame_samples: 120, perf_recording: None,
last_frame_instant: None,
})),
}
}
pub async fn with_log_buffer(self, buffer: LogBuffer) -> Self {
self.state.write().await.log_buffer = Some(buffer);
self
}
pub fn with_log_buffer_sync(self, buffer: LogBuffer) -> Self {
if let Ok(mut state) = self.state.try_write() {
state.log_buffer = Some(buffer);
}
self
}
pub async fn socket_path(&self) -> PathBuf {
self.state.read().await.socket_path.clone()
}
pub async fn set_screenshot(&self, data: Vec<u8>) {
self.state.write().await.screenshot_data = Some(data);
}
pub async fn get_screenshot(&self) -> Option<Vec<u8>> {
self.state.read().await.screenshot_data.clone()
}
pub async fn clear_screenshot(&self) {
self.state.write().await.screenshot_data = None;
}
pub async fn request_screenshot(&self) {
self.state.write().await.screenshot_requested = true;
}
pub async fn take_screenshot_request(&self) -> bool {
let mut state = self.state.write().await;
let requested = state.screenshot_requested;
state.screenshot_requested = false;
requested
}
pub async fn queue_input(&self, input: PendingInput) {
self.state.write().await.pending_inputs.push(input);
}
pub async fn take_pending_inputs(&self) -> Vec<PendingInput> {
std::mem::take(&mut self.state.write().await.pending_inputs)
}
pub async fn add_highlight(&self, highlight: Highlight) {
self.state.write().await.highlights.push(highlight);
}
pub async fn clear_highlights(&self) {
self.state.write().await.highlights.clear();
}
pub async fn get_highlights(&self) -> Vec<Highlight> {
let mut state = self.state.write().await;
let now = std::time::Instant::now();
state
.highlights
.retain(|h| h.expires_at.is_none() || h.expires_at.unwrap() > now);
state.highlights.clone()
}
pub async fn get_logs(&self, min_level: Option<&str>, limit: Option<usize>) -> Vec<LogEntry> {
let state = self.state.read().await;
if let Some(ref buffer) = state.log_buffer {
let buf = buffer.lock();
let min_priority = min_level.map(level_to_priority).unwrap_or(0);
let filtered: Vec<LogEntry> = buf
.iter()
.filter(|entry| level_to_priority(&entry.level) >= min_priority)
.cloned()
.collect();
match limit {
Some(n) => filtered.into_iter().rev().take(n).rev().collect(),
None => filtered,
}
} else {
Vec::new()
}
}
pub async fn clear_logs(&self) {
let state = self.state.read().await;
if let Some(ref buffer) = state.log_buffer {
buffer.lock().clear();
}
}
pub async fn record_frame_auto(&self) {
let mut state = self.state.write().await;
let now = std::time::Instant::now();
if let Some(last) = state.last_frame_instant {
let frame_time = now.duration_since(last);
let max_samples = state.max_frame_samples;
state.frame_times.push_back(frame_time);
while state.frame_times.len() > max_samples {
state.frame_times.pop_front();
}
if let Some(ref mut recording) = state.perf_recording {
recording.frame_times.push(frame_time);
}
}
state.last_frame_instant = Some(now);
}
pub async fn record_frame(&self, frame_time: std::time::Duration) {
let mut state = self.state.write().await;
let max_samples = state.max_frame_samples;
state.frame_times.push_back(frame_time);
while state.frame_times.len() > max_samples {
state.frame_times.pop_front();
}
if let Some(ref mut recording) = state.perf_recording {
recording.frame_times.push(frame_time);
if recording.duration_ms > 0 {
let elapsed = recording.start_time.elapsed().as_millis() as u64;
if elapsed >= recording.duration_ms {
}
}
}
}
pub async fn get_frame_stats(&self) -> FrameStats {
let state = self.state.read().await;
if state.frame_times.is_empty() {
return FrameStats {
fps: 0.0,
frame_time_ms: 0.0,
frame_time_min_ms: 0.0,
frame_time_max_ms: 0.0,
sample_count: 0,
};
}
let times: Vec<f32> = state
.frame_times
.iter()
.map(|d| d.as_secs_f32() * 1000.0)
.collect();
let sum: f32 = times.iter().sum();
let avg = sum / times.len() as f32;
let min = times.iter().cloned().fold(f32::INFINITY, f32::min);
let max = times.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
FrameStats {
fps: if avg > 0.0 { 1000.0 / avg } else { 0.0 },
frame_time_ms: avg,
frame_time_min_ms: min,
frame_time_max_ms: max,
sample_count: times.len(),
}
}
pub async fn start_perf_recording(&self, duration_ms: u64) {
let mut state = self.state.write().await;
state.perf_recording = Some(PerfRecording {
start_time: std::time::Instant::now(),
frame_times: Vec::new(),
duration_ms,
});
}
pub async fn get_perf_report(&self) -> Option<PerfReport> {
let mut state = self.state.write().await;
let recording = state.perf_recording.take()?;
if recording.frame_times.is_empty() {
return None;
}
let duration_ms = recording.start_time.elapsed().as_millis() as u64;
let total_frames = recording.frame_times.len();
let mut times_ms: Vec<f32> = recording
.frame_times
.iter()
.map(|d| d.as_secs_f32() * 1000.0)
.collect();
let sum: f32 = times_ms.iter().sum();
let avg_frame_time = sum / total_frames as f32;
let avg_fps = if avg_frame_time > 0.0 {
1000.0 / avg_frame_time
} else {
0.0
};
let min_frame_time = times_ms.iter().cloned().fold(f32::INFINITY, f32::min);
let max_frame_time = times_ms.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
times_ms.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p95_idx = (total_frames as f32 * 0.95) as usize;
let p99_idx = (total_frames as f32 * 0.99) as usize;
let p95_frame_time = times_ms
.get(p95_idx.min(total_frames - 1))
.copied()
.unwrap_or(0.0);
let p99_frame_time = times_ms
.get(p99_idx.min(total_frames - 1))
.copied()
.unwrap_or(0.0);
Some(PerfReport {
duration_ms,
total_frames,
avg_fps,
avg_frame_time_ms: avg_frame_time,
min_frame_time_ms: min_frame_time,
max_frame_time_ms: max_frame_time,
p95_frame_time_ms: p95_frame_time,
p99_frame_time_ms: p99_frame_time,
})
}
pub fn start_server(&self) -> tokio::task::JoinHandle<()> {
let client = self.clone();
tokio::spawn(async move {
if let Err(e) = IpcServer::run(client).await {
tracing::error!("IPC server error: {}", e);
}
})
}
}
impl Default for McpClient {
fn default() -> Self {
Self::new()
}
}
fn convert_mouse_button(button: &MouseButton) -> egui::PointerButton {
match button {
MouseButton::Left => egui::PointerButton::Primary,
MouseButton::Right => egui::PointerButton::Secondary,
MouseButton::Middle => egui::PointerButton::Middle,
}
}
fn parse_special_key(key: &str) -> Option<egui::Key> {
match key.to_lowercase().as_str() {
"enter" | "return" => Some(egui::Key::Enter),
"tab" => Some(egui::Key::Tab),
"backspace" => Some(egui::Key::Backspace),
"delete" => Some(egui::Key::Delete),
"escape" | "esc" => Some(egui::Key::Escape),
"space" => Some(egui::Key::Space),
"arrowup" | "up" => Some(egui::Key::ArrowUp),
"arrowdown" | "down" => Some(egui::Key::ArrowDown),
"arrowleft" | "left" => Some(egui::Key::ArrowLeft),
"arrowright" | "right" => Some(egui::Key::ArrowRight),
"home" => Some(egui::Key::Home),
"end" => Some(egui::Key::End),
"pageup" => Some(egui::Key::PageUp),
"pagedown" => Some(egui::Key::PageDown),
"insert" => Some(egui::Key::Insert),
"f1" => Some(egui::Key::F1),
"f2" => Some(egui::Key::F2),
"f3" => Some(egui::Key::F3),
"f4" => Some(egui::Key::F4),
"f5" => Some(egui::Key::F5),
"f6" => Some(egui::Key::F6),
"f7" => Some(egui::Key::F7),
"f8" => Some(egui::Key::F8),
"f9" => Some(egui::Key::F9),
"f10" => Some(egui::Key::F10),
"f11" => Some(egui::Key::F11),
"f12" => Some(egui::Key::F12),
_ => None,
}
}
pub fn inject_inputs(
ctx: &egui::Context,
raw_input: &mut egui::RawInput,
inputs: Vec<PendingInput>,
) {
if inputs.is_empty() {
return;
}
ctx.request_repaint();
for input in inputs {
match input {
PendingInput::MoveMouse { x, y } => {
tracing::debug!("Injecting mouse move to ({}, {})", x, y);
raw_input
.events
.push(egui::Event::PointerMoved(egui::pos2(x, y)));
}
PendingInput::Click { x, y, button } => {
tracing::debug!("Injecting click at ({}, {})", x, y);
let egui_button = convert_mouse_button(&button);
let pos = egui::pos2(x, y);
raw_input.events.push(egui::Event::PointerMoved(pos));
raw_input.events.push(egui::Event::PointerButton {
pos,
button: egui_button,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
raw_input.events.push(egui::Event::PointerButton {
pos,
button: egui_button,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
}
PendingInput::DoubleClick { x, y, button } => {
tracing::debug!("Injecting double click at ({}, {})", x, y);
let egui_button = convert_mouse_button(&button);
let pos = egui::pos2(x, y);
raw_input.events.push(egui::Event::PointerMoved(pos));
raw_input.events.push(egui::Event::PointerButton {
pos,
button: egui_button,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
raw_input.events.push(egui::Event::PointerButton {
pos,
button: egui_button,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
raw_input.events.push(egui::Event::PointerButton {
pos,
button: egui_button,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
raw_input.events.push(egui::Event::PointerButton {
pos,
button: egui_button,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
}
PendingInput::Drag {
start_x,
start_y,
end_x,
end_y,
button,
} => {
tracing::debug!(
"Injecting drag from ({}, {}) to ({}, {})",
start_x,
start_y,
end_x,
end_y
);
let egui_button = convert_mouse_button(&button);
let start_pos = egui::pos2(start_x, start_y);
let end_pos = egui::pos2(end_x, end_y);
raw_input.events.push(egui::Event::PointerMoved(start_pos));
raw_input.events.push(egui::Event::PointerButton {
pos: start_pos,
button: egui_button,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
raw_input.events.push(egui::Event::PointerMoved(end_pos));
raw_input.events.push(egui::Event::PointerButton {
pos: end_pos,
button: egui_button,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
}
PendingInput::Keyboard { key } => {
tracing::debug!("Injecting keyboard input: {}", key);
if let Some(egui_key) = parse_special_key(&key) {
raw_input.events.push(egui::Event::Key {
key: egui_key,
physical_key: Some(egui_key),
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
raw_input.events.push(egui::Event::Key {
key: egui_key,
physical_key: Some(egui_key),
pressed: false,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
} else {
raw_input.events.push(egui::Event::Text(key));
}
}
PendingInput::Scroll {
x,
y,
delta_x,
delta_y,
} => {
tracing::debug!(
"Injecting scroll at ({}, {}) delta ({}, {})",
x,
y,
delta_x,
delta_y
);
raw_input
.events
.push(egui::Event::PointerMoved(egui::pos2(x, y)));
raw_input.events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(delta_x, delta_y),
modifiers: egui::Modifiers::NONE,
});
}
}
}
}
pub fn draw_highlights(ctx: &egui::Context, highlights: &[Highlight]) {
if highlights.is_empty() {
return;
}
ctx.request_repaint();
let painter = ctx.debug_painter();
for highlight in highlights {
painter.rect_stroke(
highlight.rect,
0.0, egui::Stroke::new(3.0, highlight.color),
egui::StrokeKind::Outside,
);
let fill_color = egui::Color32::from_rgba_unmultiplied(
highlight.color.r(),
highlight.color.g(),
highlight.color.b(),
highlight.color.a() / 4, );
painter.rect_filled(highlight.rect, 0.0, fill_color);
}
}