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