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 }