1pub 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 pub socket: PathBuf,
28 pub smoke_frames: Option<u32>,
30}
31
32pub 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#[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
88pub 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 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
131}
132
133pub 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 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 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 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 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}