Skip to main content

computer_use_linux/
indicator.rs

1//! On-screen indicator for agent activity.
2//!
3//! The MCP server reports each desktop action to the
4//! `computer-use-linux-indicator` overlay as one JSON datagram on a socket in
5//! `$XDG_RUNTIME_DIR`. The overlay draws a software cursor that glides to the
6//! target, keycaps for key presses, an edge glow and a status pill. The server
7//! starts the overlay on the first action. Set `COMPUTER_USE_LINUX_INDICATOR=0`
8//! to turn it off.
9//!
10//! Every server keeps the overlay out of its screen captures, even with its
11//! own indicator disabled, because one overlay serves all agents in the
12//! session. For the length of a capture the server holds a shared lock on
13//! [`capture_lock_path`], and the overlay draws nothing while anyone holds it.
14//! The kernel drops the lock of a server that dies, so a crash cannot freeze
15//! the overlay. `capture: begin` and `end` datagrams make the overlay react
16//! at once; it answers `begin` with [`CAPTURE_REPLY_SHOWN`] or
17//! [`CAPTURE_REPLY_EMPTY`], so a capture waits for the compositor only when
18//! something was on screen.
19
20use std::{
21    env,
22    fs::{File, OpenOptions, TryLockError},
23    io::{self, ErrorKind},
24    os::linux::net::SocketAddrExt,
25    os::unix::net::{SocketAddr, UnixDatagram},
26    path::{Path, PathBuf},
27    process::Stdio,
28    sync::{
29        atomic::{AtomicU32, Ordering},
30        Mutex, OnceLock,
31    },
32    time::{Duration, Instant},
33};
34
35use rmcp::{Peer, RoleServer};
36use serde::{Deserialize, Serialize};
37
38/// Overlay binary name, resolved next to the server binary or on `PATH`.
39pub const INDICATOR_BINARY: &str = "computer-use-linux-indicator";
40/// Socket file name inside `$XDG_RUNTIME_DIR`.
41pub const SOCKET_NAME: &str = "computer-use-linux-indicator.sock";
42/// Reply to `capture: begin` when the overlay had something on screen.
43pub const CAPTURE_REPLY_SHOWN: &[u8] = b"shown";
44/// Reply to `capture: begin` when nothing was on screen.
45pub const CAPTURE_REPLY_EMPTY: &[u8] = b"empty";
46
47const DISABLE_ENV: &str = "COMPUTER_USE_LINUX_INDICATOR";
48const HIDE_TEXT_ENV: &str = "COMPUTER_USE_LINUX_INDICATOR_HIDE_TEXT";
49const AGENT_ENV: &str = "COMPUTER_USE_LINUX_AGENT_NAME";
50const BINARY_ENV: &str = "COMPUTER_USE_LINUX_INDICATOR_BIN";
51
52/// How long the overlay cursor takes to reach a target. Pointer actions wait
53/// this long so the cursor lands before the real input happens.
54pub const GLIDE: Duration = Duration::from_millis(350);
55/// Time for the compositor to drop overlay surfaces before a capture.
56pub const HIDE_SETTLE: Duration = Duration::from_millis(120);
57/// How long to wait for the overlay to answer `capture: begin`.
58const CAPTURE_REPLY_WAIT: Duration = Duration::from_millis(100);
59const SPAWN_WAIT: Duration = Duration::from_secs(1);
60const TEXT_LIMIT: usize = 64;
61
62/// A server starting or finishing a screen capture.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum Capture {
66    Begin,
67    End,
68}
69
70/// One overlay update.
71#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
72pub struct IndicatorEvent {
73    /// Capture phase; such an event carries nothing else.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub capture: Option<Capture>,
76    /// Display name of the agent, e.g. `Claude`.
77    #[serde(default, skip_serializing_if = "String::is_empty")]
78    pub agent: String,
79    /// MCP tool name, e.g. `click`.
80    #[serde(default, skip_serializing_if = "String::is_empty")]
81    pub tool: String,
82    /// Target in screenshot pixels, the coordinate space of the click tools.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub x: Option<i32>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub y: Option<i32>,
87    /// Size of the full-desktop capture that `x`/`y` refer to. The overlay
88    /// spreads it over the logical output layout, as the compositor does for
89    /// the absolute pointer. Without it `x`/`y` are taken as logical.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub space: Option<(u32, u32)>,
92    /// Key chord parts for `press_key`, e.g. `["ctrl", "l"]`.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub keys: Vec<String>,
95    /// Typed text (tail only, possibly masked).
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub text: Option<String>,
98}
99
100/// Path of the overlay socket, or `None` without `XDG_RUNTIME_DIR`.
101pub fn socket_path() -> Option<PathBuf> {
102    env::var_os("XDG_RUNTIME_DIR")
103        .filter(|dir| !dir.is_empty())
104        .map(|dir| Path::new(&dir).join(SOCKET_NAME))
105}
106
107/// Lock file held shared by every server for the length of a capture.
108pub fn capture_lock_path(socket: &Path) -> PathBuf {
109    socket.with_extension("capture")
110}
111
112/// Whether the session uses the GNOME Shell renderer.
113pub fn is_gnome_session() -> bool {
114    env::var("XDG_CURRENT_DESKTOP")
115        .unwrap_or_default()
116        .split(':')
117        .any(|desktop| desktop.eq_ignore_ascii_case("gnome"))
118}
119
120/// Updates the indicator in the verified GNOME Shell extension. The age keeps
121/// a capture resume from extending the idle deadline.
122pub async fn show_gnome_indicator(
123    connection: &zbus::Connection,
124    event: &IndicatorEvent,
125    age_ms: u32,
126) -> anyhow::Result<()> {
127    let proxy = crate::windowing::backends::gnome::verified_extension_proxy(connection).await?;
128    let _: () = proxy
129        .call("ShowIndicator", &(serde_json::to_string(event)?, age_ms))
130        .await?;
131    Ok(())
132}
133
134/// State of the activity renderer in the verified GNOME Shell extension.
135pub async fn gnome_indicator_state(connection: &zbus::Connection) -> anyhow::Result<String> {
136    let proxy = crate::windowing::backends::gnome::verified_extension_proxy(connection).await?;
137    Ok(proxy.call("GetIndicatorState", &()).await?)
138}
139
140/// Hides the GNOME indicator and reports whether compositor settling is needed.
141/// An absent or older extension cannot show an indicator.
142pub async fn hide_gnome_indicator(connection: &zbus::Connection) -> anyhow::Result<bool> {
143    let bus = zbus::fdo::DBusProxy::new(connection).await?;
144    if !bus
145        .name_has_owner(crate::identity::DBUS_SERVICE.try_into()?)
146        .await?
147    {
148        return Ok(false);
149    }
150    let proxy = crate::windowing::backends::gnome::verified_extension_proxy(connection).await?;
151    match proxy.call("HideIndicator", &()).await {
152        Ok(shown) => Ok(shown),
153        Err(error) if error.to_string().contains("UnknownMethod") => Ok(false),
154        Err(error) => Err(error.into()),
155    }
156}
157
158async fn settle_gnome_indicator_if_present() -> anyhow::Result<()> {
159    // Unit-test senders must never connect to the real desktop bus. A server
160    // launched without desktop metadata can still share GNOME's renderer.
161    let runtime_bus = env::var_os("XDG_RUNTIME_DIR")
162        .map(PathBuf::from)
163        .unwrap_or_else(|| {
164            // SAFETY: geteuid takes no arguments and only reads the process uid.
165            PathBuf::from(format!("/run/user/{}", unsafe { libc::geteuid() }))
166        })
167        .join("bus");
168    if cfg!(test)
169        || (!is_gnome_session()
170            && env::var_os("DBUS_SESSION_BUS_ADDRESS").is_none()
171            && !runtime_bus.exists())
172    {
173        return Ok(());
174    }
175    let probe = tokio::time::timeout(CAPTURE_REPLY_WAIT, async {
176        let connection = zbus::Connection::session().await?;
177        let bus = zbus::fdo::DBusProxy::new(&connection).await?;
178        let present = bus
179            .name_has_owner(crate::identity::DBUS_SERVICE.try_into()?)
180            .await?;
181        Ok::<_, anyhow::Error>(present.then_some(connection))
182    })
183    .await;
184    let connection = match probe {
185        Ok(Ok(Some(connection))) => connection,
186        Ok(Ok(None)) => return Ok(()),
187        Ok(Err(error)) if is_gnome_session() => return Err(error),
188        Err(error) if is_gnome_session() => return Err(error.into()),
189        _ => return Ok(()),
190    };
191    let hidden = tokio::time::timeout(CAPTURE_REPLY_WAIT, hide_gnome_indicator(&connection))
192        .await
193        .map_err(|_| {
194            anyhow::anyhow!("GNOME indicator did not acknowledge hiding before capturing")
195        })??;
196    if hidden {
197        tokio::time::sleep(HIDE_SETTLE).await;
198    }
199    Ok(())
200}
201
202/// Maps an MCP `clientInfo` to the name shown on screen.
203pub fn agent_display_name(name: &str, title: Option<&str>) -> String {
204    let lower = name.to_ascii_lowercase();
205    let known = [
206        ("claude", "Claude"),
207        ("codex", "Codex"),
208        ("opencode", "OpenCode"),
209        ("gemini", "Gemini"),
210        ("antigravity", "Antigravity"),
211        ("hermes", "Hermes"),
212        ("cursor", "Cursor"),
213        ("goose", "Goose"),
214    ];
215    if let Some((_, display)) = known.iter().find(|(needle, _)| lower.contains(needle)) {
216        return (*display).to_string();
217    }
218    // Pi's native extension identifies as `computer-use-linux-pi`.
219    if lower == "pi" || lower.starts_with("pi-") || lower.ends_with("-pi") {
220        return "Pi".to_string();
221    }
222    if let Some(title) = title.map(str::trim).filter(|title| !title.is_empty()) {
223        return title.to_string();
224    }
225    match name.trim() {
226        "" => "Agent".to_string(),
227        other => other.to_string(),
228    }
229}
230
231fn disabled_value(value: Option<&str>) -> bool {
232    matches!(
233        value
234            .map(|value| value.trim().to_ascii_lowercase())
235            .as_deref(),
236        Some("0" | "false" | "off" | "no")
237    )
238}
239
240/// Last `TEXT_LIMIT` characters, or a fixed mask that does not reveal the
241/// length.
242fn shown_text(text: &str, mask: bool) -> String {
243    if mask {
244        return "••••••••".to_string();
245    }
246    let chars: Vec<char> = text.chars().collect();
247    chars[chars.len().saturating_sub(TEXT_LIMIT)..]
248        .iter()
249        .collect()
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253enum Overlay {
254    NotStarted,
255    Started(Instant),
256    /// No Wayland session, no binary, or the overlay could not start (for
257    /// example GNOME, which has no layer-shell). Not retried by this server.
258    Unavailable,
259}
260
261/// Server-side sender. Action reports are cheap no-ops when disabled;
262/// capture holds work regardless.
263#[derive(Debug)]
264pub(crate) struct Indicator {
265    enabled: bool,
266    mask_text: bool,
267    agent_override: Option<String>,
268    peer: OnceLock<Peer<RoleServer>>,
269    socket: Option<UnixDatagram>,
270    /// The overlay's socket.
271    path: Option<PathBuf>,
272    overlay: Mutex<Overlay>,
273    /// Replies share one socket; captures must not consume each other's ACKs.
274    capture_handshake: tokio::sync::Mutex<()>,
275}
276
277impl Default for Indicator {
278    fn default() -> Self {
279        // Unit tests build servers freely; they must not touch a real overlay.
280        let live = !cfg!(test);
281        let socket = live
282            .then(client_socket)
283            .flatten()
284            .filter(|socket| socket.set_nonblocking(true).is_ok());
285        Self {
286            enabled: socket.is_some() && !disabled_value(env::var(DISABLE_ENV).ok().as_deref()),
287            mask_text: env::var(HIDE_TEXT_ENV).ok().as_deref() == Some("1"),
288            agent_override: env::var(AGENT_ENV)
289                .ok()
290                .filter(|name| !name.trim().is_empty()),
291            peer: OnceLock::new(),
292            socket,
293            path: live.then(socket_path).flatten(),
294            overlay: Mutex::new(Overlay::NotStarted),
295            capture_handshake: tokio::sync::Mutex::new(()),
296        }
297    }
298}
299
300impl Indicator {
301    /// Remembers the MCP client so events carry its name.
302    pub(crate) fn attach(&self, peer: Peer<RoleServer>) {
303        let _ = self.peer.set(peer);
304    }
305
306    fn agent(&self) -> String {
307        if let Some(name) = &self.agent_override {
308            return name.clone();
309        }
310        self.peer
311            .get()
312            .and_then(Peer::peer_info)
313            .map(|info| {
314                agent_display_name(&info.client_info.name, info.client_info.title.as_deref())
315            })
316            .unwrap_or_else(|| "Agent".to_string())
317    }
318
319    fn event(&self, tool: &str) -> IndicatorEvent {
320        IndicatorEvent {
321            agent: self.agent(),
322            tool: tool.to_string(),
323            ..IndicatorEvent::default()
324        }
325    }
326
327    pub(crate) fn enabled(&self) -> bool {
328        self.enabled
329    }
330
331    /// Moves the overlay cursor to a point in screenshot pixels and waits until
332    /// it lands. Without the capture size the point cannot be placed, so only
333    /// the action is shown.
334    pub(crate) async fn pointer(&self, tool: &str, (x, y): (i32, i32), space: Option<(u32, u32)>) {
335        let Some(space) = space else {
336            return self.action(tool).await;
337        };
338        let event = IndicatorEvent {
339            x: Some(x),
340            y: Some(y),
341            space: Some(space),
342            ..self.event(tool)
343        };
344        if self.send(&event).await {
345            tokio::time::sleep(GLIDE).await;
346        }
347    }
348
349    /// Moves the overlay cursor without waiting, e.g. to a drop point.
350    pub(crate) async fn follow(&self, tool: &str, (x, y): (i32, i32), space: Option<(u32, u32)>) {
351        let Some(space) = space else {
352            return;
353        };
354        let event = IndicatorEvent {
355            x: Some(x),
356            y: Some(y),
357            space: Some(space),
358            ..self.event(tool)
359        };
360        self.send(&event).await;
361    }
362
363    /// Reports an action that moves no pointer.
364    pub(crate) async fn action(&self, tool: &str) {
365        self.send(&self.event(tool)).await;
366    }
367
368    pub(crate) async fn keys(&self, chord: &str, secret: bool) {
369        let event = IndicatorEvent {
370            // Character-by-character password entry must not leak through keycaps.
371            keys: if self.mask_text || secret {
372                vec!["••••••••".to_string()]
373            } else {
374                chord
375                    .split('+')
376                    .map(str::trim)
377                    .filter(|key| !key.is_empty())
378                    .map(str::to_string)
379                    .collect()
380            },
381            ..self.event("press_key")
382        };
383        self.send(&event).await;
384    }
385
386    /// Shows entered text; `set_value` and `secret` are always masked.
387    pub(crate) async fn text(&self, tool: &str, text: &str, secret: bool) {
388        let event = IndicatorEvent {
389            // An app can change a settable field to a password after the snapshot.
390            text: Some(shown_text(
391                text,
392                self.mask_text || secret || tool == "set_value",
393            )),
394            ..self.event(tool)
395        };
396        self.send(&event).await;
397    }
398
399    /// Keeps every overlay off the screen until the returned hold drops, and
400    /// waits until the compositor has dropped whatever was showing. Works with
401    /// this server's indicator disabled too: another agent's overlay must not
402    /// end up in this server's captures. Fails when a running overlay cannot
403    /// confirm it has hidden, rather than capture it.
404    pub(crate) async fn hold_for_capture(&self) -> anyhow::Result<CaptureHold<'_>> {
405        let _handshake = self.capture_handshake.lock().await;
406        let hold = CaptureHold {
407            indicator: self,
408            lock: self.take_capture_lock().await?,
409        };
410        let Some(socket) = &self.socket else {
411            if self.path.is_some() {
412                anyhow::bail!("no client socket to confirm the action indicator overlay is hidden");
413            }
414            return Ok(hold);
415        };
416        let mut reply = [0u8; 16];
417        // Drop a late answer to an earlier request.
418        while socket.recv(&mut reply).is_ok() {}
419        let begin = IndicatorEvent {
420            capture: Some(Capture::Begin),
421            ..Default::default()
422        };
423        let deadline = Instant::now() + CAPTURE_REPLY_WAIT;
424        loop {
425            match self.send_raw(&begin) {
426                Ok(()) => break,
427                // No overlay is running; the lock keeps one that starts now blank.
428                Err(error)
429                    if matches!(
430                        error.kind(),
431                        ErrorKind::NotFound | ErrorKind::ConnectionRefused
432                    ) =>
433                {
434                    settle_gnome_indicator_if_present().await?;
435                    return Ok(hold);
436                }
437                // A full queue: the overlay is running but not reading.
438                Err(error)
439                    if error.kind() == ErrorKind::WouldBlock && Instant::now() < deadline =>
440                {
441                    tokio::time::sleep(Duration::from_millis(5)).await
442                }
443                Err(error) => anyhow::bail!(
444                    "could not hide the action indicator overlay before capturing: {error}"
445                ),
446            }
447        }
448        let deadline = Instant::now() + CAPTURE_REPLY_WAIT;
449        while Instant::now() < deadline {
450            match socket.recv_from(&mut reply) {
451                // Only the overlay's socket, in the user's private runtime
452                // directory, can confirm that its surfaces were removed.
453                Ok((len, from)) if from.as_pathname() == self.path.as_deref() => {
454                    match &reply[..len] {
455                        CAPTURE_REPLY_EMPTY => return Ok(hold),
456                        CAPTURE_REPLY_SHOWN => {
457                            tokio::time::sleep(HIDE_SETTLE).await;
458                            return Ok(hold);
459                        }
460                        _ => anyhow::bail!(
461                            "invalid action indicator overlay acknowledgement before capturing"
462                        ),
463                    }
464                }
465                Ok(_) => {}
466                Err(error) if error.kind() == ErrorKind::WouldBlock => {
467                    tokio::time::sleep(Duration::from_millis(5)).await
468                }
469                Err(error) => anyhow::bail!(
470                    "could not confirm the action indicator overlay is hidden before capturing: {error}"
471                ),
472            }
473        }
474        anyhow::bail!("action indicator overlay did not acknowledge hiding before capturing")
475    }
476
477    async fn take_capture_lock(&self) -> anyhow::Result<Option<File>> {
478        let Some(path) = self.path.as_deref() else {
479            return Ok(None);
480        };
481        let file = OpenOptions::new()
482            .create(true)
483            .truncate(false)
484            .write(true)
485            .open(capture_lock_path(path))
486            .map_err(|error| anyhow::anyhow!("could not open indicator capture lock: {error}"))?;
487        // The overlay tests the lock exclusively for an instant; retry past it.
488        for _ in 0..20 {
489            match file.try_lock_shared() {
490                Ok(()) => return Ok(Some(file)),
491                Err(TryLockError::WouldBlock) => tokio::time::sleep(Duration::from_millis(1)).await,
492                Err(TryLockError::Error(error)) => {
493                    anyhow::bail!("could not take indicator capture lock: {error}")
494                }
495            }
496        }
497        anyhow::bail!("indicator capture lock remained busy")
498    }
499
500    fn send_raw(&self, event: &IndicatorEvent) -> io::Result<()> {
501        let (Some(socket), Some(path)) = (&self.socket, &self.path) else {
502            return Err(ErrorKind::NotFound.into());
503        };
504        socket.send_to(&serde_json::to_vec(event)?, path).map(drop)
505    }
506
507    /// Sends an event, starting the overlay first if it is not running.
508    async fn send(&self, event: &IndicatorEvent) -> bool {
509        if !self.enabled {
510            return false;
511        }
512        let (Some(socket), Some(path)) = (&self.socket, &self.path) else {
513            return false;
514        };
515        let Ok(payload) = serde_json::to_vec(event) else {
516            return false;
517        };
518        let sent = match socket.send_to(&payload, path) {
519            Ok(_) => true,
520            Err(error)
521                if matches!(
522                    error.kind(),
523                    ErrorKind::NotFound | ErrorKind::ConnectionRefused
524                ) =>
525            {
526                self.start_overlay(path).await && socket.send_to(&payload, path).is_ok()
527            }
528            Err(_) => false,
529        };
530        sent
531    }
532
533    async fn start_overlay(&self, path: &Path) -> bool {
534        {
535            let Ok(mut overlay) = self.overlay.lock() else {
536                return false;
537            };
538            match *overlay {
539                Overlay::Unavailable => return false,
540                // Still starting, or exited after idling: allow a restart later.
541                Overlay::Started(at) if at.elapsed() < SPAWN_WAIT => return false,
542                _ => {}
543            }
544            if (!is_gnome_session() && env::var_os("WAYLAND_DISPLAY").is_none())
545                || spawn_overlay().is_err()
546            {
547                *overlay = Overlay::Unavailable;
548                return false;
549            }
550            *overlay = Overlay::Started(Instant::now());
551        }
552        let deadline = Instant::now() + SPAWN_WAIT;
553        while Instant::now() < deadline {
554            if UnixDatagram::unbound()
555                .and_then(|probe| probe.connect(path))
556                .is_ok()
557            {
558                return true;
559            }
560            tokio::time::sleep(Duration::from_millis(25)).await;
561        }
562        if let Ok(mut overlay) = self.overlay.lock() {
563            *overlay = Overlay::Unavailable;
564        }
565        false
566    }
567}
568
569/// A capture in progress. Dropping it, also when the request is cancelled,
570/// releases the lock and tells the overlay it may draw again.
571pub(crate) struct CaptureHold<'a> {
572    indicator: &'a Indicator,
573    lock: Option<File>,
574}
575
576impl Drop for CaptureHold<'_> {
577    fn drop(&mut self) {
578        // A concurrently spawning child can inherit the descriptor until exec.
579        // Explicitly unlock before notifying the overlay, even while it is open.
580        if let Some(lock) = self.lock.take() {
581            let _ = lock.unlock();
582        }
583        let _ = self.indicator.send_raw(&IndicatorEvent {
584            capture: Some(Capture::End),
585            ..Default::default()
586        });
587    }
588}
589
590/// A socket the overlay can answer: bound to a unique abstract address. Falls
591/// back to an unbound socket, which only loses the capture replies.
592fn client_socket() -> Option<UnixDatagram> {
593    static NEXT: AtomicU32 = AtomicU32::new(0);
594    let name = format!(
595        "computer-use-linux-indicator-client.{}.{}",
596        std::process::id(),
597        NEXT.fetch_add(1, Ordering::Relaxed)
598    );
599    SocketAddr::from_abstract_name(name.as_bytes())
600        .and_then(|address| UnixDatagram::bind_addr(&address))
601        .or_else(|_| UnixDatagram::unbound())
602        .ok()
603}
604
605/// Whether text for an element with this AT-SPI role must be masked: a
606/// password field (`password text`, or the `PasswordText` fallback name), or
607/// a role that could not be read and so might be one.
608pub(crate) fn is_secret_role(role: &str) -> bool {
609    // Generic enum fallback names do not establish whether an editable field
610    // obscures input. GTK's fresh "text box" role is similarly ambiguous.
611    if matches!(role.trim(), "Entry" | "Text") {
612        return true;
613    }
614    let role = role.trim().to_ascii_lowercase();
615    role.is_empty()
616        || role == crate::atspi_tree::UNKNOWN_ROLE
617        || role == "invalid"
618        || matches!(role.as_str(), "text box" | "textbox" | "terminal")
619        || role.contains("password")
620}
621
622fn overlay_binary() -> PathBuf {
623    if let Some(path) = env::var_os(BINARY_ENV).filter(|path| !path.is_empty()) {
624        return PathBuf::from(path);
625    }
626    env::current_exe()
627        .ok()
628        .map(|exe| exe.with_file_name(INDICATOR_BINARY))
629        .filter(|sibling| sibling.is_file())
630        .unwrap_or_else(|| PathBuf::from(INDICATOR_BINARY))
631}
632
633fn spawn_overlay() -> std::io::Result<()> {
634    let mut child = tokio::process::Command::new(overlay_binary())
635        .stdin(Stdio::null())
636        .stdout(Stdio::null())
637        .stderr(Stdio::null())
638        // Own process group: a Ctrl-C aimed at the agent must not kill the
639        // overlay that other agents may be sharing.
640        .process_group(0)
641        .spawn()?;
642    // Reap it when it exits (it quits on its own after idling).
643    tokio::spawn(async move {
644        let _ = child.wait().await;
645    });
646    Ok(())
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652
653    #[test]
654    fn agent_names_come_from_client_info() {
655        assert_eq!(agent_display_name("claude-code", None), "Claude");
656        assert_eq!(agent_display_name("codex-mcp-client", None), "Codex");
657        assert_eq!(agent_display_name("opencode", None), "OpenCode");
658        assert_eq!(agent_display_name("pi", None), "Pi");
659        assert_eq!(agent_display_name("computer-use-linux-pi", None), "Pi");
660        assert_eq!(
661            agent_display_name("pipeline", Some("Pipeline Bot")),
662            "Pipeline Bot"
663        );
664        assert_eq!(agent_display_name("my-agent", None), "my-agent");
665        assert_eq!(agent_display_name(" ", None), "Agent");
666    }
667
668    #[test]
669    fn password_fields_are_secret() {
670        assert!(is_secret_role("password text"));
671        assert!(is_secret_role("PasswordText"));
672        assert!(!is_secret_role("text"));
673        assert!(!is_secret_role("entry"));
674    }
675
676    #[test]
677    fn unreadable_roles_are_secret() {
678        // `role_name` reports this when both AT-SPI role reads fail.
679        assert!(is_secret_role(crate::atspi_tree::UNKNOWN_ROLE));
680        assert!(is_secret_role("Unknown"));
681        assert!(is_secret_role("Invalid"));
682        assert!(is_secret_role(" "));
683    }
684
685    #[test]
686    fn generic_text_boxes_do_not_establish_password_safety() {
687        // GTK exposes obscured entries with the same text-box role as public
688        // entries. A successful role read does not establish clear-text input.
689        assert!(is_secret_role("text box"));
690        assert!(is_secret_role("TextBox"));
691    }
692
693    #[test]
694    fn enum_fallback_text_and_terminal_roles_are_masked() {
695        assert!(is_secret_role("Entry"));
696        assert!(is_secret_role("Text"));
697        assert!(is_secret_role("terminal"));
698        // A fresh non-generic role read keeps the original public-text path.
699        assert!(!is_secret_role("entry"));
700    }
701
702    /// A sender talking to a stand-in overlay at `path`.
703    fn sender_for(path: &Path, enabled: bool) -> Indicator {
704        let socket = client_socket().unwrap();
705        socket.set_nonblocking(true).unwrap();
706        Indicator {
707            enabled,
708            mask_text: false,
709            agent_override: None,
710            peer: OnceLock::new(),
711            socket: Some(socket),
712            path: Some(path.to_path_buf()),
713            overlay: Mutex::new(Overlay::Unavailable),
714            capture_handshake: tokio::sync::Mutex::new(()),
715        }
716    }
717
718    fn scratch_dir(name: &str) -> PathBuf {
719        let dir = std::env::temp_dir().join(format!("cul-{}-{name}", std::process::id()));
720        let _ = std::fs::remove_dir_all(&dir);
721        std::fs::create_dir_all(&dir).unwrap();
722        dir
723    }
724
725    /// Whether some server holds the capture lock next to `socket`.
726    fn capture_held(socket: &Path) -> bool {
727        let file = File::open(capture_lock_path(socket)).unwrap();
728        match file.try_lock() {
729            Ok(()) => false,
730            Err(TryLockError::WouldBlock) => true,
731            Err(TryLockError::Error(error)) => panic!("{error}"),
732        }
733    }
734
735    /// Takes a capture hold against a stand-in overlay that answers `begin`
736    /// with `reply`, from its own socket or, when `spoofed`, from another one.
737    /// Returns how long taking the hold took, or its error.
738    async fn hold_with_reply(
739        name: &str,
740        reply: &'static [u8],
741        spoofed: bool,
742    ) -> anyhow::Result<Duration> {
743        let dir = scratch_dir(name);
744        let socket = dir.join(SOCKET_NAME);
745        let overlay = UnixDatagram::bind(&socket).unwrap();
746        overlay
747            .set_read_timeout(Some(Duration::from_secs(2)))
748            .unwrap();
749        let impostor = UnixDatagram::bind(dir.join("impostor.sock")).unwrap();
750        let sender = sender_for(&socket, true);
751        let answer = std::thread::spawn(move || {
752            let mut buf = [0u8; 64];
753            let (len, from) = overlay.recv_from(&mut buf).unwrap();
754            assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
755            let via = if spoofed { &impostor } else { &overlay };
756            via.send_to_addr(reply, &from).unwrap();
757            let len = overlay.recv(&mut buf).unwrap();
758            assert_eq!(&buf[..len], br#"{"capture":"end"}"#);
759        });
760        let started = Instant::now();
761        let hold = sender.hold_for_capture().await;
762        let took = started.elapsed();
763        let result = hold.map(|hold| {
764            assert!(capture_held(&socket));
765            drop(hold);
766            took
767        });
768        assert!(!capture_held(&socket));
769        answer.join().unwrap();
770        let _ = std::fs::remove_dir_all(&dir);
771        result
772    }
773
774    #[tokio::test]
775    async fn captures_wait_only_while_the_overlay_was_on_screen() {
776        assert!(
777            hold_with_reply("empty", CAPTURE_REPLY_EMPTY, false)
778                .await
779                .unwrap()
780                < HIDE_SETTLE
781        );
782        assert!(
783            hold_with_reply("shown", CAPTURE_REPLY_SHOWN, false)
784                .await
785                .unwrap()
786                >= HIDE_SETTLE
787        );
788        // Neither another sender nor an unknown reply can authorize a capture.
789        assert!(hold_with_reply("spoofed", CAPTURE_REPLY_EMPTY, true)
790            .await
791            .is_err());
792        assert!(hold_with_reply("invalid", b"garbage", false).await.is_err());
793    }
794
795    #[tokio::test]
796    async fn captures_hold_the_lock_without_an_overlay() {
797        // An overlay another agent starts mid-capture must still stay blank.
798        let dir = scratch_dir("none");
799        let socket = dir.join(SOCKET_NAME);
800        let sender = sender_for(&socket, true);
801        let started = Instant::now();
802        let hold = sender.hold_for_capture().await.unwrap();
803        assert!(started.elapsed() < HIDE_SETTLE);
804        assert!(capture_held(&socket));
805        drop(hold);
806        assert!(!capture_held(&socket));
807        let _ = std::fs::remove_dir_all(&dir);
808    }
809
810    #[tokio::test]
811    async fn capture_release_unlocks_an_inherited_descriptor() {
812        let dir = scratch_dir("inherited-lock");
813        let socket = dir.join(SOCKET_NAME);
814        let sender = sender_for(&socket, true);
815        let hold = sender.hold_for_capture().await.unwrap();
816        let _inherited = hold.lock.as_ref().unwrap().try_clone().unwrap();
817        assert!(capture_held(&socket));
818        drop(hold);
819        assert!(!capture_held(&socket));
820        let _ = std::fs::remove_dir_all(&dir);
821    }
822
823    #[tokio::test]
824    async fn captures_fail_while_a_stalled_overlay_cannot_be_told_to_hide() {
825        let dir = scratch_dir("stalled");
826        let socket = dir.join(SOCKET_NAME);
827        let _overlay = UnixDatagram::bind(&socket).unwrap();
828        let sender = sender_for(&socket, true);
829        // Fill this sender's buffer: a different socket can still enqueue begin.
830        let socket_sender = sender.socket.as_ref().unwrap();
831        let deadline = Instant::now() + Duration::from_secs(2);
832        loop {
833            assert!(Instant::now() < deadline, "sender never became blocked");
834            match socket_sender.send_to(b"{}", &socket) {
835                Ok(_) => {}
836                Err(error) => {
837                    assert_eq!(error.kind(), ErrorKind::WouldBlock);
838                    break;
839                }
840            }
841        }
842        let error = sender.hold_for_capture().await.err().unwrap();
843        assert!(error.to_string().contains("could not hide"), "{error}");
844        assert!(!capture_held(&socket));
845        let _ = std::fs::remove_dir_all(&dir);
846    }
847
848    #[tokio::test]
849    async fn captures_fail_when_a_running_overlay_does_not_reply() {
850        let dir = scratch_dir("silent");
851        let socket = dir.join(SOCKET_NAME);
852        let overlay = UnixDatagram::bind(&socket).unwrap();
853        overlay.set_nonblocking(true).unwrap();
854        let sender = sender_for(&socket, true);
855        let error = sender.hold_for_capture().await.err().unwrap();
856        assert!(error.to_string().contains("did not acknowledge"), "{error}");
857        let mut buf = [0u8; 64];
858        let len = overlay.recv(&mut buf).unwrap();
859        assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
860        assert!(!capture_held(&socket));
861        let _ = std::fs::remove_dir_all(&dir);
862    }
863
864    #[tokio::test]
865    async fn set_values_are_masked_even_when_the_cached_role_was_public() {
866        let dir = scratch_dir("set-value-mask");
867        let socket = dir.join(SOCKET_NAME);
868        let overlay = UnixDatagram::bind(&socket).unwrap();
869        overlay
870            .set_read_timeout(Some(Duration::from_secs(2)))
871            .unwrap();
872        let sender = sender_for(&socket, true);
873        sender.text("set_value", "secret", false).await;
874        let mut buf = [0u8; 512];
875        let len = overlay.recv(&mut buf).unwrap();
876        let event: IndicatorEvent = serde_json::from_slice(&buf[..len]).unwrap();
877        assert_eq!(event.text.as_deref(), Some("••••••••"));
878        let _ = std::fs::remove_dir_all(&dir);
879    }
880
881    #[tokio::test]
882    async fn keycaps_mask_secret_focus_and_the_hide_text_setting() {
883        let dir = scratch_dir("key-mask");
884        let socket = dir.join(SOCKET_NAME);
885        let overlay = UnixDatagram::bind(&socket).unwrap();
886        overlay
887            .set_read_timeout(Some(Duration::from_secs(2)))
888            .unwrap();
889        let mut sender = sender_for(&socket, true);
890        for (secret, hide_text, expected) in [
891            (true, false, vec!["••••••••"]),
892            (false, true, vec!["••••••••"]),
893            (false, false, vec!["Shift", "a"]),
894        ] {
895            sender.mask_text = hide_text;
896            sender.keys("Shift+a", secret).await;
897            let mut buf = [0u8; 512];
898            let len = overlay.recv(&mut buf).unwrap();
899            let event: IndicatorEvent = serde_json::from_slice(&buf[..len]).unwrap();
900            assert_eq!(event.keys, expected);
901        }
902        let _ = std::fs::remove_dir_all(&dir);
903    }
904
905    #[tokio::test]
906    async fn captures_fail_when_the_capture_lock_cannot_be_taken() {
907        let dir = scratch_dir("unavailable-lock");
908        let socket = dir.join(SOCKET_NAME);
909        let lock_path = capture_lock_path(&socket);
910        std::fs::create_dir(&lock_path).unwrap();
911        let sender = sender_for(&socket, true);
912        assert!(sender.hold_for_capture().await.is_err());
913        std::fs::remove_dir(&lock_path).unwrap();
914        let lock = File::create(&lock_path).unwrap();
915        lock.try_lock().unwrap();
916        assert!(sender.hold_for_capture().await.is_err());
917        let _ = std::fs::remove_dir_all(&dir);
918    }
919
920    #[tokio::test]
921    async fn captures_fail_without_a_client_socket_in_a_live_runtime() {
922        let dir = scratch_dir("no-client-socket");
923        let socket = dir.join(SOCKET_NAME);
924        let mut sender = sender_for(&socket, true);
925        sender.socket = None;
926        assert!(sender.hold_for_capture().await.is_err());
927        assert!(!capture_held(&socket));
928        let _ = std::fs::remove_dir_all(&dir);
929    }
930
931    #[tokio::test]
932    async fn concurrent_captures_do_not_consume_each_others_replies() {
933        let dir = scratch_dir("concurrent");
934        let socket = dir.join(SOCKET_NAME);
935        let overlay = UnixDatagram::bind(&socket).unwrap();
936        overlay
937            .set_read_timeout(Some(Duration::from_secs(2)))
938            .unwrap();
939        let sender = sender_for(&socket, true);
940        let (ack_tx, ack_rx) = std::sync::mpsc::channel();
941        let answer = std::thread::spawn(move || {
942            let mut buf = [0u8; 64];
943            for n in 0..2 {
944                let (len, from) = overlay.recv_from(&mut buf).unwrap();
945                assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
946                overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
947                if n == 0 {
948                    ack_tx.send(()).unwrap();
949                }
950            }
951        });
952        let mut first = Box::pin(sender.hold_for_capture());
953        assert!(futures_util::poll!(first.as_mut()).is_pending());
954        ack_rx.recv_timeout(Duration::from_secs(2)).unwrap();
955        // Poll the second request before the first can consume its queued ACK.
956        let (second, first) = tokio::join!(biased; sender.hold_for_capture(), first);
957        let (first, second) = (first.unwrap(), second.unwrap());
958        assert!(capture_held(&socket));
959        drop(first);
960        assert!(capture_held(&socket), "the second capture is still running");
961        drop(second);
962        assert!(!capture_held(&socket));
963        answer.join().unwrap();
964        let _ = std::fs::remove_dir_all(&dir);
965    }
966
967    #[tokio::test]
968    async fn disabled_servers_still_hold_captures() {
969        let dir = scratch_dir("disabled");
970        let socket = dir.join(SOCKET_NAME);
971        let overlay = UnixDatagram::bind(&socket).unwrap();
972        overlay.set_nonblocking(true).unwrap();
973        let sender = sender_for(&socket, false);
974        let hold = sender.hold_for_capture();
975        let acknowledge = async {
976            let mut buf = [0u8; 64];
977            let deadline = Instant::now() + Duration::from_secs(2);
978            loop {
979                assert!(Instant::now() < deadline, "capture begin was not received");
980                match overlay.recv_from(&mut buf) {
981                    Ok((len, from)) => {
982                        assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
983                        overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
984                        break;
985                    }
986                    Err(error) if error.kind() == ErrorKind::WouldBlock => {
987                        tokio::time::sleep(Duration::from_millis(1)).await
988                    }
989                    Err(error) => panic!("{error}"),
990                }
991            }
992        };
993        let (hold, ()) = tokio::join!(hold, acknowledge);
994        let hold = hold.unwrap();
995        assert!(capture_held(&socket));
996        let mut buf = [0u8; 64];
997        // It reports no actions of its own.
998        sender.action("click").await;
999        assert!(overlay.recv(&mut buf).is_err());
1000        drop(hold);
1001        let len = overlay.recv(&mut buf).unwrap();
1002        assert_eq!(&buf[..len], br#"{"capture":"end"}"#);
1003        assert!(!capture_held(&socket));
1004        let _ = std::fs::remove_dir_all(&dir);
1005    }
1006
1007    #[test]
1008    fn overlays_can_answer_the_client_socket() {
1009        let dir = scratch_dir("answer");
1010        let overlay_path = dir.join(SOCKET_NAME);
1011        let overlay = UnixDatagram::bind(&overlay_path).unwrap();
1012        let client = client_socket().unwrap();
1013        client.send_to(b"ping", &overlay_path).unwrap();
1014        let mut buf = [0u8; 64];
1015        let (_, from) = overlay.recv_from(&mut buf).unwrap();
1016        overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
1017        let len = client.recv(&mut buf).unwrap();
1018        assert_eq!(&buf[..len], CAPTURE_REPLY_EMPTY);
1019        let _ = std::fs::remove_dir_all(&dir);
1020    }
1021
1022    #[test]
1023    fn opt_out_values() {
1024        for value in ["0", "false", "OFF", "no"] {
1025            assert!(disabled_value(Some(value)), "{value}");
1026        }
1027        for value in [None, Some("1"), Some("")] {
1028            assert!(!disabled_value(value), "{value:?}");
1029        }
1030    }
1031
1032    #[test]
1033    fn text_is_trimmed_to_its_tail_and_masked() {
1034        let long = "a".repeat(80) + "end";
1035        assert_eq!(shown_text(&long, false).chars().count(), TEXT_LIMIT);
1036        assert!(shown_text(&long, false).ends_with("end"));
1037        assert_eq!(shown_text("пароль", true), shown_text("x", true));
1038        assert!(!shown_text("пароль", true).contains("пароль"));
1039    }
1040
1041    #[test]
1042    fn events_serialize_compactly() {
1043        let event = IndicatorEvent {
1044            agent: "Claude".into(),
1045            tool: "click".into(),
1046            x: Some(10),
1047            y: Some(20),
1048            ..Default::default()
1049        };
1050        let json = serde_json::to_string(&event).unwrap();
1051        assert_eq!(json, r#"{"agent":"Claude","tool":"click","x":10,"y":20}"#);
1052        assert_eq!(
1053            serde_json::from_str::<IndicatorEvent>(&json).unwrap(),
1054            event
1055        );
1056        let scaled = IndicatorEvent {
1057            space: Some((3840, 2160)),
1058            ..event
1059        };
1060        let json = serde_json::to_string(&scaled).unwrap();
1061        assert!(json.ends_with(r#""space":[3840,2160]}"#), "{json}");
1062        assert_eq!(
1063            serde_json::from_str::<IndicatorEvent>(&json).unwrap(),
1064            scaled
1065        );
1066        let begin = serde_json::to_string(&IndicatorEvent {
1067            capture: Some(Capture::Begin),
1068            ..Default::default()
1069        });
1070        assert_eq!(begin.unwrap(), r#"{"capture":"begin"}"#);
1071    }
1072}