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 forever).
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 window;
20
21use crate::approval::ipc::ApprovalIpc;
22use std::path::PathBuf;
23
24#[derive(Debug, Clone)]
25pub struct GuiOptions {
26    /// Approval socket to watch (default: the runtime registry socket).
27    pub socket: PathBuf,
28    /// Smoke-test support: close the window after N rendered frames.
29    pub smoke_frames: Option<u32>,
30}
31
32/// Run the approvals window on the current (main) thread. Blocks until
33/// the window is closed. Returns a process exit code.
34pub fn run_gui(opts: GuiOptions) -> i32 {
35    let socket = opts.socket.clone();
36    let (event_tx, event_rx) = std::sync::mpsc::channel::<companion::CompanionEvent>();
37    let (choice_tx, choice_rx) = tokio::sync::mpsc::channel::<crate::approval::GrantChoice>(8);
38    let companion_thread = std::thread::Builder::new()
39        .name("approval-companion".into())
40        .spawn(move || {
41            let Ok(rt) = tokio::runtime::Builder::new_current_thread()
42                .enable_all()
43                .build()
44            else {
45                return;
46            };
47            rt.block_on(companion::companion_loop(socket, event_tx, choice_rx));
48        });
49    if companion_thread.is_err() {
50        eprintln!("sequel-mcp gui: failed to start the companion thread");
51        return 1;
52    }
53
54    let state = app::ViewState::new(opts.socket.clone());
55    let smoke = opts.smoke_frames;
56    let native_options = eframe::NativeOptions {
57        viewport: egui::ViewportBuilder::default()
58            .with_inner_size([680.0, 620.0])
59            .with_title("sequel-mcp approvals"),
60        ..Default::default()
61    };
62    match eframe::run_native(
63        "sequel-mcp approvals",
64        native_options,
65        Box::new(move |_cc| {
66            Ok(Box::new(window::ApprovalsApp::new(
67                state, choice_tx, event_rx, smoke,
68            )))
69        }),
70    ) {
71        Ok(()) => 0,
72        Err(e) => {
73            eprintln!("sequel-mcp gui: window creation failed: {e}");
74            1
75        }
76    }
77}
78
79/// One live (or recently recorded) server from the session registry.
80#[derive(Debug, Clone, PartialEq)]
81pub struct SessionInfo {
82    pub pid: u32,
83    pub socket: String,
84    pub started: String,
85    pub alive: bool,
86}
87
88/// Discover servers registered under the default runtime registry.
89/// Informational only — the window watches ONE socket (the registry
90/// path); this list tells the human which servers are alive.
91pub fn live_sessions() -> Vec<SessionInfo> {
92    live_sessions_at(&crate::app::paths::runtime_dir().join("sessions"))
93}
94
95pub fn live_sessions_at(dir: &std::path::Path) -> Vec<SessionInfo> {
96    let Ok(entries) = std::fs::read_dir(dir) else {
97        return Vec::new();
98    };
99    let mut out = Vec::new();
100    for entry in entries.flatten() {
101        let path = entry.path();
102        if path.extension().and_then(|e| e.to_str()) != Some("json") {
103            continue;
104        }
105        let Ok(text) = std::fs::read_to_string(&path) else {
106            continue;
107        };
108        let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
109            continue;
110        };
111        let Some(pid) = value["pid"].as_u64() else {
112            continue;
113        };
114        out.push(SessionInfo {
115            pid: u32::try_from(pid).unwrap_or(0),
116            socket: value["socket"].as_str().unwrap_or("").to_string(),
117            started: value["started"].as_str().unwrap_or("").to_string(),
118            alive: pid_alive(pid),
119        });
120    }
121    out.sort_by(|a, b| b.started.cmp(&a.started));
122    out
123}
124
125fn pid_alive(pid: u64) -> bool {
126    if pid == 0 {
127        return false;
128    }
129    // Signal 0 is a pure existence probe; nothing is delivered.
130    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
131}
132
133/// Default socket for the CLI wiring (kept next to the GUI for symmetry
134/// with `approve`).
135pub fn default_socket() -> PathBuf {
136    ApprovalIpc::default_socket_path()
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn live_sessions_parses_and_flags_liveness() {
145        let dir = tempfile::TempDir::new().unwrap();
146        let sessions = dir.path().join("sessions");
147        std::fs::create_dir_all(&sessions).unwrap();
148        // A live server: this test process itself.
149        std::fs::write(
150            sessions.join("100.json"),
151            serde_json::json!({
152                "pid": std::process::id(),
153                "socket": "/runtime/approval.sock",
154                "started": "2026-08-23T01:00:00Z",
155            })
156            .to_string(),
157        )
158        .unwrap();
159        // A dead server: pid far above any real pid on this machine.
160        std::fs::write(
161            sessions.join("200.json"),
162            serde_json::json!({
163                "pid": 4_000_000u64,
164                "socket": "/runtime/approval.sock",
165                "started": "2026-08-23T02:00:00Z",
166            })
167            .to_string(),
168        )
169        .unwrap();
170        // Garbage and non-json neighbors must be ignored.
171        std::fs::write(sessions.join("300.json"), "not json").unwrap();
172        std::fs::write(sessions.join("readme.txt"), "hello").unwrap();
173
174        let found = live_sessions_at(&sessions);
175        assert_eq!(found.len(), 2, "{found:?}");
176        // Most recent `started` first.
177        assert_eq!(found[0].pid, 4_000_000);
178        assert!(!found[0].alive, "pid 4000000 must read as dead");
179        assert_eq!(found[1].pid, std::process::id());
180        assert!(found[1].alive, "own pid must read as alive");
181        assert_eq!(found[1].socket, "/runtime/approval.sock");
182    }
183
184    #[test]
185    fn live_sessions_missing_dir_is_empty() {
186        assert!(live_sessions_at(std::path::Path::new("/nonexistent/nowhere")).is_empty());
187    }
188}