Skip to main content

sequel_mcp/gui/
mod.rs

1//! Native approvals GUI: an egui companion window that watches the
2//! authenticated approval IPC socket and answers gate confirmations with
3//! real human clicks. It reuses the `sequel-mcp approve` protocol
4//! verbatim (`wait` → `request` → id-bound `reply` → `ok`/`stale`/
5//! `bad-choice`, one round per connection, reconnect in monitor mode).
6//!
7//! Layout of this module:
8//! * [`companion`] — the testable long-poll client loop (no UI);
9//! * [`app`] — the plain-data view state + event reducer (no UI types);
10//! * [`window`] — the thin egui layer rendering the state.
11//!
12//! Security posture (inherited from the IPC design): the GUI never sees
13//! secrets, never auto-approves, and every failure path fails closed —
14//! if the window is closed or the human is absent, the server's own
15//! deadline refuses the statement.
16
17pub mod app;
18pub mod companion;
19pub mod launch;
20pub mod window;
21
22use crate::approval::ipc::ApprovalIpc;
23use std::path::PathBuf;
24
25#[derive(Debug, Clone)]
26pub struct GuiOptions {
27    /// Approval socket to watch (default: the runtime registry socket).
28    pub socket: PathBuf,
29    /// Smoke-test support: close the window after N rendered frames.
30    pub smoke_frames: Option<u32>,
31    /// Keep the diagnostic monitor open between requests.
32    pub watch: bool,
33    /// Automatically launched dialogs may answer only this request.
34    pub request_id: Option<String>,
35    pub discover: bool,
36}
37
38/// Run the approvals window on the current (main) thread. Blocks until
39/// the window is closed. Returns a process exit code.
40pub fn run_gui(opts: GuiOptions) -> i32 {
41    let socket = opts.socket.clone();
42    let (event_tx, event_rx) = std::sync::mpsc::channel::<companion::CompanionEvent>();
43    let (choice_tx, choice_rx) = tokio::sync::mpsc::channel(8);
44
45    let state = app::ViewState::new(opts.socket.clone());
46    let smoke = opts.smoke_frames;
47    let native_options = eframe::NativeOptions {
48        viewport: egui::ViewportBuilder::default()
49            .with_inner_size([680.0, if opts.watch { 620.0 } else { 390.0 }])
50            .with_min_inner_size([560.0, 390.0])
51            .with_visible(opts.watch || opts.smoke_frames.is_some())
52            .with_title("sequel-mcp approvals"),
53        ..Default::default()
54    };
55    match eframe::run_native(
56        "sequel-mcp approvals",
57        native_options,
58        Box::new(move |cc| {
59            let ctx = cc.egui_ctx.clone();
60            let events = companion::EventSender::new(event_tx, move || ctx.request_repaint());
61            let rt = tokio::runtime::Builder::new_current_thread()
62                .enable_all()
63                .build()?;
64            std::thread::Builder::new()
65                .name("approval-companion".into())
66                .spawn(move || {
67                    rt.block_on(companion::companion_loop(
68                        socket,
69                        events,
70                        choice_rx,
71                        companion::CompanionOptions {
72                            once: !opts.watch,
73                            request_id: opts.request_id,
74                            discover: opts.discover,
75                        },
76                    ))
77                })?;
78            Ok(Box::new(window::ApprovalsApp::new(
79                state, choice_tx, event_rx, smoke, opts.watch,
80            )))
81        }),
82    ) {
83        Ok(()) => 0,
84        Err(e) => {
85            eprintln!("sequel-mcp gui: window creation failed: {e}");
86            1
87        }
88    }
89}
90
91/// One live (or recently recorded) server from the session registry.
92#[derive(Debug, Clone, PartialEq)]
93pub struct SessionInfo {
94    pub pid: u32,
95    pub socket: String,
96    pub started: String,
97    pub alive: bool,
98}
99
100/// Discover servers registered under the default runtime registry.
101/// Informational only — the window watches ONE socket (the registry
102/// path); this list tells the human which servers are alive.
103pub fn live_sessions() -> Vec<SessionInfo> {
104    live_sessions_at(&crate::app::paths::runtime_dir().join("sessions"))
105}
106
107pub fn live_sessions_at(dir: &std::path::Path) -> Vec<SessionInfo> {
108    let Ok(entries) = std::fs::read_dir(dir) else {
109        return Vec::new();
110    };
111    let mut out = Vec::new();
112    for entry in entries.flatten() {
113        let path = entry.path();
114        if path.extension().and_then(|e| e.to_str()) != Some("json") {
115            continue;
116        }
117        let Ok(text) = std::fs::read_to_string(&path) else {
118            continue;
119        };
120        let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
121            continue;
122        };
123        let Some(pid) = value["pid"].as_u64() else {
124            continue;
125        };
126        out.push(SessionInfo {
127            pid: u32::try_from(pid).unwrap_or(0),
128            socket: value["socket"].as_str().unwrap_or("").to_string(),
129            started: value["started"].as_str().unwrap_or("").to_string(),
130            alive: pid_alive(pid),
131        });
132    }
133    out.sort_by(|a, b| b.started.cmp(&a.started));
134    out
135}
136
137fn pid_alive(pid: u64) -> bool {
138    if pid == 0 {
139        return false;
140    }
141    // Signal 0 is a pure existence probe; nothing is delivered.
142    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
143}
144
145/// Default socket for the CLI wiring (kept next to the GUI for symmetry
146/// with `approve`).
147pub fn default_socket() -> PathBuf {
148    ApprovalIpc::default_socket_path()
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn live_sessions_parses_and_flags_liveness() {
157        let dir = tempfile::TempDir::new().unwrap();
158        let sessions = dir.path().join("sessions");
159        std::fs::create_dir_all(&sessions).unwrap();
160        // A live server: this test process itself.
161        std::fs::write(
162            sessions.join("100.json"),
163            serde_json::json!({
164                "pid": std::process::id(),
165                "socket": "/runtime/approval.sock",
166                "started": "2026-08-23T01:00:00Z",
167            })
168            .to_string(),
169        )
170        .unwrap();
171        // A dead server: pid far above any real pid on this machine.
172        std::fs::write(
173            sessions.join("200.json"),
174            serde_json::json!({
175                "pid": 4_000_000u64,
176                "socket": "/runtime/approval.sock",
177                "started": "2026-08-23T02:00:00Z",
178            })
179            .to_string(),
180        )
181        .unwrap();
182        // Garbage and non-json neighbors must be ignored.
183        std::fs::write(sessions.join("300.json"), "not json").unwrap();
184        std::fs::write(sessions.join("readme.txt"), "hello").unwrap();
185
186        let found = live_sessions_at(&sessions);
187        assert_eq!(found.len(), 2, "{found:?}");
188        // Most recent `started` first.
189        assert_eq!(found[0].pid, 4_000_000);
190        assert!(!found[0].alive, "pid 4000000 must read as dead");
191        assert_eq!(found[1].pid, std::process::id());
192        assert!(found[1].alive, "own pid must read as alive");
193        assert_eq!(found[1].socket, "/runtime/approval.sock");
194    }
195
196    #[test]
197    fn live_sessions_missing_dir_is_empty() {
198        assert!(live_sessions_at(std::path::Path::new("/nonexistent/nowhere")).is_empty());
199    }
200}