git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/process.rs (13.3K)

  1 use std::io::Write;
  2 use std::process::Command;
  3 use std::sync::Mutex;
  4 
  5 static TRACKED_PROCESSES: Mutex<Vec<std::process::Child>> = Mutex::new(Vec::new());
  6 
  7 /// Spawns a process detached and automatically reaps it when it exits.
  8 ///
  9 /// If an active Tokio runtime is available, it will use a background Tokio task
 10 /// to wait on the process. Otherwise, it will fallback to a background OS thread.
 11 pub fn spawn_detached(mut cmd: Command) -> std::io::Result<()> {
 12     if let Ok(handle) = tokio::runtime::Handle::try_current() {
 13         let mut tokio_cmd = tokio::process::Command::from(cmd);
 14         let mut child = tokio_cmd.spawn()?;
 15         handle.spawn(async move {
 16             let _ = child.wait().await;
 17         });
 18     } else {
 19         let mut child = cmd.spawn()?;
 20         std::thread::spawn(move || {
 21             let _ = child.wait();
 22         });
 23     }
 24     Ok(())
 25 }
 26 
 27 /// Spawns a process and registers it to be automatically killed when the application exits.
 28 pub fn spawn_tracked(mut cmd: Command) -> std::io::Result<()> {
 29     let child = cmd.spawn()?;
 30     if let Ok(mut lock) = TRACKED_PROCESSES.lock() {
 31         lock.push(child);
 32     }
 33     Ok(())
 34 }
 35 
 36 /// Kills all spawned and tracked child processes. Called automatically on application exit.
 37 pub fn cleanup_spawned_processes() {
 38     if let Ok(mut lock) = TRACKED_PROCESSES.lock() {
 39         for mut child in lock.drain(..) {
 40             let _ = child.kill();
 41         }
 42     }
 43 }
 44 
 45 /// Resolve the `cce-cloud` binary, preferring `~/.local/bin`.
 46 pub fn get_cce_cloud_cmd() -> String {
 47     if let Ok(home) = std::env::var("HOME") {
 48         let path = format!("{}/.local/bin/cce-cloud", home);
 49         if std::path::Path::new(&path).exists() {
 50             return path;
 51         }
 52     }
 53     "cce-cloud".to_string()
 54 }
 55 
 56 fn send_sigterm(pid: u32) {
 57     let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
 58     if ret != 0 {
 59         log::warn!(
 60             "[cloud-popup] SIGTERM to pid {} failed: {}",
 61             pid,
 62             std::io::Error::last_os_error()
 63         );
 64     }
 65 }
 66 
 67 /// What a click on a popup trigger should do next — the result of
 68 /// [`CloudPopupTracker::click`].
 69 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 70 pub enum CloudPopupClick {
 71     /// The click closed (or canceled) this source's own popup; don't spawn a
 72     /// new one.
 73     ToggledOff,
 74     /// Spawn this source's popup now; any other source's popup has been
 75     /// killed and the tracker is pending on this source.
 76     Open,
 77 }
 78 
 79 /// Tracks an application's single active `cce-cloud` popup.
 80 ///
 81 /// The pattern: a trigger (button, tray icon, …) identified by a `source`
 82 /// string spawns a `cce-cloud` process on a worker thread ([`CloudPopup`]);
 83 /// the thread reports the pid back through the app's event loop
 84 /// ([`on_spawned`](Self::on_spawned)) and reports exit when the popup closes
 85 /// ([`on_closed`](Self::on_closed)). Clicking the trigger again while its
 86 /// popup is open (or still spawning) toggles it off. Only one popup exists at
 87 /// a time: opening one source's popup kills any other's.
 88 #[derive(Debug, Default)]
 89 pub struct CloudPopupTracker {
 90     active_pid: Option<u32>,
 91     active_source: Option<String>,
 92 }
 93 
 94 /// Basename of a `/proc/<pid>/exe` link, with the `" (deleted)"` marker the
 95 /// kernel appends once the binary has been unlinked stripped off.
 96 fn exe_basename(exe: &std::path::Path) -> Option<&str> {
 97     let name = exe.file_name()?.to_str()?;
 98     Some(name.strip_suffix(" (deleted)").unwrap_or(name))
 99 }
100 
101 impl CloudPopupTracker {
102     pub fn new() -> Self {
103         Self::default()
104     }
105 
106     /// The tracked popup's pid, if that process is still alive and still a
107     /// `cce-cloud` (guards against pid reuse).
108     ///
109     /// Identity comes from the exe link, not `/proc/<pid>/comm`: comm is capped
110     /// at 15 characters, so it silently truncates for over half the cce
111     /// binaries (all three `cce-keyring-unlock*` collapse onto one string).
112     /// `cce-cloud` fits today, which is the only reason a comm comparison
113     /// worked — a rename to anything longer would have quietly made this always
114     /// return `None`, leaving the tracker convinced no popup is ever open.
115     ///
116     /// The exe link needs the `" (deleted)"` suffix stripped: `ccebuild install`
117     /// unlinks before writing, so every popup running across a reinstall reads
118     /// as `cce-cloud (deleted)` — the one case where comm was the more forgiving
119     /// of the two.
120     fn running_pid(&self) -> Option<u32> {
121         let pid = self.active_pid?;
122         let exe = std::fs::read_link(format!("/proc/{}/exe", pid)).ok()?;
123         (exe_basename(&exe) == Some("cce-cloud")).then_some(pid)
124     }
125 
126     /// Whether the tracked popup is currently running.
127     pub fn is_open(&self) -> bool {
128         self.running_pid().is_some()
129     }
130 
131     /// Handle a click on `source`'s trigger. Kills whatever popup is open;
132     /// returns [`CloudPopupClick::ToggledOff`] when the click closed or
133     /// canceled `source`'s own popup, [`CloudPopupClick::Open`] when the
134     /// caller should now spawn `source`'s popup.
135     pub fn click(&mut self, source: &str) -> CloudPopupClick {
136         if let Some(pid) = self.running_pid() {
137             log::debug!("[cloud-popup] killing open popup pid {} on click for '{}'", pid, source);
138             send_sigterm(pid);
139             self.active_pid = None;
140             if self.active_source.as_deref() == Some(source) {
141                 self.active_source = None;
142                 return CloudPopupClick::ToggledOff;
143             }
144         } else if self.active_source.as_deref() == Some(source) {
145             // Same source clicked again while its popup was still spawning:
146             // cancel it (on_spawned will kill the late-arriving pid).
147             self.active_source = None;
148             return CloudPopupClick::ToggledOff;
149         }
150         self.active_source = Some(source.to_string());
151         CloudPopupClick::Open
152     }
153 
154     /// A spawner thread announced its popup's pid. Adopts the pid if `source`
155     /// is still the active one; kills the process if the popup was canceled
156     /// or superseded while spawning.
157     pub fn on_spawned(&mut self, pid: u32, source: &str) {
158         if self.active_source.as_deref() == Some(source) {
159             self.active_pid = Some(pid);
160         } else {
161             log::debug!("[cloud-popup] pid {} for source '{}' is obsolete/canceled, killing", pid, source);
162             send_sigterm(pid);
163         }
164     }
165 
166     /// A spawner thread reported its popup closed (`pid` 0 when it never
167     /// spawned). Returns `true` — with the tracker cleared — when it was the
168     /// active popup, so the caller can run its close action (e.g. restore
169     /// focus). `false` for stale reports from superseded popups.
170     pub fn on_closed(&mut self, pid: u32, source: &str) -> bool {
171         if self.active_pid == Some(pid)
172             || (pid == 0 && self.active_source.as_deref() == Some(source))
173         {
174             self.active_pid = None;
175             self.active_source = None;
176             true
177         } else {
178             false
179         }
180     }
181 }
182 
183 /// One `cce-cloud` popup invocation: where it opens and how it's parented.
184 ///
185 /// [`run_json`](Self::run_json) / [`run_dmenu`](Self::run_dmenu) block until
186 /// the popup closes — call them from a worker thread, report the pid from
187 /// `on_spawn` back to the event loop for [`CloudPopupTracker::on_spawned`],
188 /// and report [`CloudPopupTracker::on_closed`] when they return.
189 #[derive(Debug, Clone, Default)]
190 pub struct CloudPopup {
191     x: i32,
192     y: i32,
193     parent_app_id: Option<String>,
194     align_right: bool,
195 }
196 
197 impl CloudPopup {
198     pub fn at(x: i32, y: i32) -> Self {
199         Self { x, y, ..Self::default() }
200     }
201 
202     /// Parent the popup to a surface by app id (compositor-side placement).
203     pub fn parent_app_id(mut self, app_id: impl Into<String>) -> Self {
204         self.parent_app_id = Some(app_id.into());
205         self
206     }
207 
208     /// Grow the popup leftward from `x` instead of rightward.
209     pub fn align_right(mut self) -> Self {
210         self.align_right = true;
211         self
212     }
213 
214     /// `--json` mode: pipe a JSON page description, block until the popup
215     /// closes, and return its trimmed stdout (the selection JSON). `None`
216     /// when the popup was dismissed/killed without a selection.
217     pub fn run_json(
218         &self,
219         layout_json: &str,
220         on_spawn: impl FnOnce(u32),
221     ) -> std::io::Result<Option<String>> {
222         self.run(&["--json".to_string()], layout_json, on_spawn)
223     }
224 
225     /// `--dmenu` mode: pipe newline-separated items, block until the popup
226     /// closes, and return the selected line. `None` when nothing was picked.
227     pub fn run_dmenu(
228         &self,
229         prompt: &str,
230         items: &str,
231         on_spawn: impl FnOnce(u32),
232     ) -> std::io::Result<Option<String>> {
233         self.run(&["--dmenu".to_string(), "-p".to_string(), prompt.to_string()], items, on_spawn)
234     }
235 
236     fn run(
237         &self,
238         mode_args: &[String],
239         stdin_payload: &str,
240         on_spawn: impl FnOnce(u32),
241     ) -> std::io::Result<Option<String>> {
242         let mut args = mode_args.to_vec();
243         args.extend(["-x".to_string(), self.x.to_string(), "-y".to_string(), self.y.to_string()]);
244         if let Some(ref parent) = self.parent_app_id {
245             args.extend(["--parent-app-id".to_string(), parent.clone()]);
246         }
247         if self.align_right {
248             args.push("--align-right".to_string());
249         }
250 
251         let mut child = Command::new(get_cce_cloud_cmd())
252             .args(&args)
253             .stdin(std::process::Stdio::piped())
254             .stdout(std::process::Stdio::piped())
255             .stderr(std::process::Stdio::piped())
256             .spawn()?;
257 
258         on_spawn(child.id());
259 
260         if let Some(mut stdin) = child.stdin.take() {
261             let _ = stdin.write_all(stdin_payload.as_bytes());
262             // dropped here: cce-cloud sees EOF
263         }
264 
265         let output = child.wait_with_output()?;
266         let err_str = String::from_utf8_lossy(&output.stderr);
267         if !err_str.is_empty() {
268             log::debug!("[cce-cloud stderr] {}", err_str);
269         }
270         if !output.status.success() {
271             return Ok(None);
272         }
273         let out = String::from_utf8_lossy(&output.stdout).trim().to_string();
274         Ok((!out.is_empty()).then_some(out))
275     }
276 }
277 
278 #[cfg(test)]
279 mod tests {
280     use super::*;
281 
282     #[test]
283     fn test_spawn_detached() {
284         let cmd = Command::new("true");
285         assert!(spawn_detached(cmd).is_ok());
286     }
287 
288     #[test]
289     fn test_spawn_tracked() {
290         let cmd = Command::new("true");
291         assert!(spawn_tracked(cmd).is_ok());
292         cleanup_spawned_processes();
293     }
294 
295     #[test]
296     fn exe_basename_strips_the_deleted_marker() {
297         use std::path::Path;
298         assert_eq!(exe_basename(Path::new("/home/u/.local/bin/cce-cloud")), Some("cce-cloud"));
299         // `ccebuild install` unlinks before writing, so a popup that outlives a
300         // reinstall reads like this — still a cce-cloud, and the tracker must
301         // keep recognizing it or it loses the ability to close its own popup.
302         assert_eq!(
303             exe_basename(Path::new("/home/u/.local/bin/cce-cloud (deleted)")),
304             Some("cce-cloud")
305         );
306         assert_eq!(exe_basename(Path::new("/usr/bin/foot")), Some("foot"));
307     }
308 
309     #[test]
310     fn running_pid_rejects_a_live_process_that_is_not_cce_cloud() {
311         // The state-machine tests below use pids that are almost certainly
312         // dead, so `running_pid` returns None for want of a process at all.
313         // This one adopts a pid that definitely IS alive — the test runner —
314         // to show the gate is identity, not mere liveness. (Only `is_open` is
315         // called here: it never signals, so the runner is in no danger.)
316         let mut t = CloudPopupTracker::new();
317         assert_eq!(t.click("layout"), CloudPopupClick::Open);
318         t.on_spawned(std::process::id(), "layout");
319         assert!(!t.is_open(), "a live non-cce-cloud pid must not count as an open popup");
320     }
321 
322     // --- CloudPopupTracker state machine ---
323     // (pids here are never live cce-cloud processes, so running_pid() is
324     // always None — these tests cover the pending/source transitions; the
325     // kill-the-open-popup paths need a live popup and are covered by the
326     // manual smoke test.)
327 
328     #[test]
329     fn click_open_then_second_click_cancels_pending() {
330         let mut t = CloudPopupTracker::new();
331         assert_eq!(t.click("layout"), CloudPopupClick::Open);
332         assert_eq!(t.click("layout"), CloudPopupClick::ToggledOff);
333         // canceled: a late pid announcement must not be adopted
334         t.on_spawned(4_000_000, "layout");
335         assert!(!t.is_open());
336         assert!(!t.on_closed(4_000_000, "layout"));
337     }
338 
339     #[test]
340     fn different_source_supersedes_pending() {
341         let mut t = CloudPopupTracker::new();
342         assert_eq!(t.click("tray:a"), CloudPopupClick::Open);
343         assert_eq!(t.click("tray:b"), CloudPopupClick::Open);
344         // a's late spawn is obsolete; b's is adopted
345         t.on_spawned(4_000_001, "tray:a");
346         t.on_spawned(4_000_002, "tray:b");
347         assert!(t.on_closed(4_000_002, "tray:b"));
348     }
349 
350     #[test]
351     fn closed_with_pid_zero_matches_pending_source_only() {
352         let mut t = CloudPopupTracker::new();
353         assert_eq!(t.click("window"), CloudPopupClick::Open);
354         assert!(!t.on_closed(0, "layout"));
355         assert!(t.on_closed(0, "window"));
356         assert_eq!(t.click("window"), CloudPopupClick::Open);
357     }
358 }