1pub 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 pub socket: PathBuf,
29 pub smoke_frames: Option<u32>,
31 pub watch: bool,
33 pub request_id: Option<String>,
35 pub discover: bool,
36}
37
38pub 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#[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
100pub 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 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
143}
144
145pub 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 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 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 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 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}