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 = crate::test_sockets::socket_fixture(
721            &format!("cul-{}-{name}", std::process::id()),
722            SOCKET_NAME,
723        );
724        let _ = std::fs::remove_dir_all(&dir);
725        std::fs::create_dir_all(&dir).unwrap();
726        dir
727    }
728
729    /// Whether some server holds the capture lock next to `socket`.
730    fn capture_held(socket: &Path) -> bool {
731        let file = File::open(capture_lock_path(socket)).unwrap();
732        match file.try_lock() {
733            Ok(()) => false,
734            Err(TryLockError::WouldBlock) => true,
735            Err(TryLockError::Error(error)) => panic!("{error}"),
736        }
737    }
738
739    /// Takes a capture hold against a stand-in overlay that answers `begin`
740    /// with `reply`, from its own socket or, when `spoofed`, from another one.
741    /// Returns how long taking the hold took, or its error.
742    async fn hold_with_reply(
743        name: &str,
744        reply: &'static [u8],
745        spoofed: bool,
746    ) -> anyhow::Result<Duration> {
747        let dir = scratch_dir(name);
748        let socket = dir.join(SOCKET_NAME);
749        let overlay = UnixDatagram::bind(&socket).unwrap();
750        overlay
751            .set_read_timeout(Some(Duration::from_secs(2)))
752            .unwrap();
753        let impostor = UnixDatagram::bind(dir.join("impostor.sock")).unwrap();
754        let sender = sender_for(&socket, true);
755        let answer = std::thread::spawn(move || {
756            let mut buf = [0u8; 64];
757            let (len, from) = overlay.recv_from(&mut buf).unwrap();
758            assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
759            let via = if spoofed { &impostor } else { &overlay };
760            via.send_to_addr(reply, &from).unwrap();
761            let len = overlay.recv(&mut buf).unwrap();
762            assert_eq!(&buf[..len], br#"{"capture":"end"}"#);
763        });
764        let started = Instant::now();
765        let hold = sender.hold_for_capture().await;
766        let took = started.elapsed();
767        let result = hold.map(|hold| {
768            assert!(capture_held(&socket));
769            drop(hold);
770            took
771        });
772        assert!(!capture_held(&socket));
773        answer.join().unwrap();
774        let _ = std::fs::remove_dir_all(&dir);
775        result
776    }
777
778    #[tokio::test]
779    async fn captures_wait_only_while_the_overlay_was_on_screen() {
780        assert!(
781            hold_with_reply("empty", CAPTURE_REPLY_EMPTY, false)
782                .await
783                .unwrap()
784                < HIDE_SETTLE
785        );
786        assert!(
787            hold_with_reply("shown", CAPTURE_REPLY_SHOWN, false)
788                .await
789                .unwrap()
790                >= HIDE_SETTLE
791        );
792        // Neither another sender nor an unknown reply can authorize a capture.
793        assert!(hold_with_reply("spoofed", CAPTURE_REPLY_EMPTY, true)
794            .await
795            .is_err());
796        assert!(hold_with_reply("invalid", b"garbage", false).await.is_err());
797    }
798
799    #[tokio::test]
800    async fn captures_hold_the_lock_without_an_overlay() {
801        // An overlay another agent starts mid-capture must still stay blank.
802        let dir = scratch_dir("none");
803        let socket = dir.join(SOCKET_NAME);
804        let sender = sender_for(&socket, true);
805        let started = Instant::now();
806        let hold = sender.hold_for_capture().await.unwrap();
807        assert!(started.elapsed() < HIDE_SETTLE);
808        assert!(capture_held(&socket));
809        drop(hold);
810        assert!(!capture_held(&socket));
811        let _ = std::fs::remove_dir_all(&dir);
812    }
813
814    #[tokio::test]
815    async fn capture_release_unlocks_an_inherited_descriptor() {
816        let dir = scratch_dir("inherited-lock");
817        let socket = dir.join(SOCKET_NAME);
818        let sender = sender_for(&socket, true);
819        let hold = sender.hold_for_capture().await.unwrap();
820        let _inherited = hold.lock.as_ref().unwrap().try_clone().unwrap();
821        assert!(capture_held(&socket));
822        drop(hold);
823        assert!(!capture_held(&socket));
824        let _ = std::fs::remove_dir_all(&dir);
825    }
826
827    #[tokio::test]
828    async fn captures_fail_while_a_stalled_overlay_cannot_be_told_to_hide() {
829        let dir = scratch_dir("stalled");
830        let socket = dir.join(SOCKET_NAME);
831        let _overlay = UnixDatagram::bind(&socket).unwrap();
832        let sender = sender_for(&socket, true);
833        // Fill this sender's buffer: a different socket can still enqueue begin.
834        let socket_sender = sender.socket.as_ref().unwrap();
835        let deadline = Instant::now() + Duration::from_secs(2);
836        loop {
837            assert!(Instant::now() < deadline, "sender never became blocked");
838            match socket_sender.send_to(b"{}", &socket) {
839                Ok(_) => {}
840                Err(error) => {
841                    assert_eq!(error.kind(), ErrorKind::WouldBlock);
842                    break;
843                }
844            }
845        }
846        let error = sender.hold_for_capture().await.err().unwrap();
847        assert!(error.to_string().contains("could not hide"), "{error}");
848        assert!(!capture_held(&socket));
849        let _ = std::fs::remove_dir_all(&dir);
850    }
851
852    #[tokio::test]
853    async fn captures_fail_when_a_running_overlay_does_not_reply() {
854        let dir = scratch_dir("silent");
855        let socket = dir.join(SOCKET_NAME);
856        let overlay = UnixDatagram::bind(&socket).unwrap();
857        overlay.set_nonblocking(true).unwrap();
858        let sender = sender_for(&socket, true);
859        let error = sender.hold_for_capture().await.err().unwrap();
860        assert!(error.to_string().contains("did not acknowledge"), "{error}");
861        let mut buf = [0u8; 64];
862        let len = overlay.recv(&mut buf).unwrap();
863        assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
864        assert!(!capture_held(&socket));
865        let _ = std::fs::remove_dir_all(&dir);
866    }
867
868    #[tokio::test]
869    async fn set_values_are_masked_even_when_the_cached_role_was_public() {
870        let dir = scratch_dir("set-value-mask");
871        let socket = dir.join(SOCKET_NAME);
872        let overlay = UnixDatagram::bind(&socket).unwrap();
873        overlay
874            .set_read_timeout(Some(Duration::from_secs(2)))
875            .unwrap();
876        let sender = sender_for(&socket, true);
877        sender.text("set_value", "secret", false).await;
878        let mut buf = [0u8; 512];
879        let len = overlay.recv(&mut buf).unwrap();
880        let event: IndicatorEvent = serde_json::from_slice(&buf[..len]).unwrap();
881        assert_eq!(event.text.as_deref(), Some("••••••••"));
882        let _ = std::fs::remove_dir_all(&dir);
883    }
884
885    #[tokio::test]
886    async fn keycaps_mask_secret_focus_and_the_hide_text_setting() {
887        let dir = scratch_dir("key-mask");
888        let socket = dir.join(SOCKET_NAME);
889        let overlay = UnixDatagram::bind(&socket).unwrap();
890        overlay
891            .set_read_timeout(Some(Duration::from_secs(2)))
892            .unwrap();
893        let mut sender = sender_for(&socket, true);
894        for (secret, hide_text, expected) in [
895            (true, false, vec!["••••••••"]),
896            (false, true, vec!["••••••••"]),
897            (false, false, vec!["Shift", "a"]),
898        ] {
899            sender.mask_text = hide_text;
900            sender.keys("Shift+a", secret).await;
901            let mut buf = [0u8; 512];
902            let len = overlay.recv(&mut buf).unwrap();
903            let event: IndicatorEvent = serde_json::from_slice(&buf[..len]).unwrap();
904            assert_eq!(event.keys, expected);
905        }
906        let _ = std::fs::remove_dir_all(&dir);
907    }
908
909    #[tokio::test]
910    async fn captures_fail_when_the_capture_lock_cannot_be_taken() {
911        let dir = scratch_dir("unavailable-lock");
912        let socket = dir.join(SOCKET_NAME);
913        let lock_path = capture_lock_path(&socket);
914        std::fs::create_dir(&lock_path).unwrap();
915        let sender = sender_for(&socket, true);
916        assert!(sender.hold_for_capture().await.is_err());
917        std::fs::remove_dir(&lock_path).unwrap();
918        let lock = File::create(&lock_path).unwrap();
919        lock.try_lock().unwrap();
920        assert!(sender.hold_for_capture().await.is_err());
921        let _ = std::fs::remove_dir_all(&dir);
922    }
923
924    #[tokio::test]
925    async fn captures_fail_without_a_client_socket_in_a_live_runtime() {
926        let dir = scratch_dir("no-client-socket");
927        let socket = dir.join(SOCKET_NAME);
928        let mut sender = sender_for(&socket, true);
929        sender.socket = None;
930        assert!(sender.hold_for_capture().await.is_err());
931        assert!(!capture_held(&socket));
932        let _ = std::fs::remove_dir_all(&dir);
933    }
934
935    #[tokio::test]
936    async fn concurrent_captures_do_not_consume_each_others_replies() {
937        let dir = scratch_dir("concurrent");
938        let socket = dir.join(SOCKET_NAME);
939        let overlay = UnixDatagram::bind(&socket).unwrap();
940        overlay
941            .set_read_timeout(Some(Duration::from_secs(2)))
942            .unwrap();
943        let sender = sender_for(&socket, true);
944        let (ack_tx, ack_rx) = std::sync::mpsc::channel();
945        let answer = std::thread::spawn(move || {
946            let mut buf = [0u8; 64];
947            for n in 0..2 {
948                let (len, from) = overlay.recv_from(&mut buf).unwrap();
949                assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
950                overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
951                if n == 0 {
952                    ack_tx.send(()).unwrap();
953                }
954            }
955        });
956        let mut first = Box::pin(sender.hold_for_capture());
957        assert!(futures_util::poll!(first.as_mut()).is_pending());
958        ack_rx.recv_timeout(Duration::from_secs(2)).unwrap();
959        // Poll the second request before the first can consume its queued ACK.
960        let (second, first) = tokio::join!(biased; sender.hold_for_capture(), first);
961        let (first, second) = (first.unwrap(), second.unwrap());
962        assert!(capture_held(&socket));
963        drop(first);
964        assert!(capture_held(&socket), "the second capture is still running");
965        drop(second);
966        assert!(!capture_held(&socket));
967        answer.join().unwrap();
968        let _ = std::fs::remove_dir_all(&dir);
969    }
970
971    #[tokio::test]
972    async fn disabled_servers_still_hold_captures() {
973        let dir = scratch_dir("disabled");
974        let socket = dir.join(SOCKET_NAME);
975        let overlay = UnixDatagram::bind(&socket).unwrap();
976        overlay.set_nonblocking(true).unwrap();
977        let sender = sender_for(&socket, false);
978        let hold = sender.hold_for_capture();
979        let acknowledge = async {
980            let mut buf = [0u8; 64];
981            let deadline = Instant::now() + Duration::from_secs(2);
982            loop {
983                assert!(Instant::now() < deadline, "capture begin was not received");
984                match overlay.recv_from(&mut buf) {
985                    Ok((len, from)) => {
986                        assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
987                        overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
988                        break;
989                    }
990                    Err(error) if error.kind() == ErrorKind::WouldBlock => {
991                        tokio::time::sleep(Duration::from_millis(1)).await
992                    }
993                    Err(error) => panic!("{error}"),
994                }
995            }
996        };
997        let (hold, ()) = tokio::join!(hold, acknowledge);
998        let hold = hold.unwrap();
999        assert!(capture_held(&socket));
1000        let mut buf = [0u8; 64];
1001        // It reports no actions of its own.
1002        sender.action("click").await;
1003        assert!(overlay.recv(&mut buf).is_err());
1004        drop(hold);
1005        let len = overlay.recv(&mut buf).unwrap();
1006        assert_eq!(&buf[..len], br#"{"capture":"end"}"#);
1007        assert!(!capture_held(&socket));
1008        let _ = std::fs::remove_dir_all(&dir);
1009    }
1010
1011    #[test]
1012    fn overlays_can_answer_the_client_socket() {
1013        let dir = scratch_dir("answer");
1014        let overlay_path = dir.join(SOCKET_NAME);
1015        let overlay = UnixDatagram::bind(&overlay_path).unwrap();
1016        let client = client_socket().unwrap();
1017        client.send_to(b"ping", &overlay_path).unwrap();
1018        let mut buf = [0u8; 64];
1019        let (_, from) = overlay.recv_from(&mut buf).unwrap();
1020        overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
1021        let len = client.recv(&mut buf).unwrap();
1022        assert_eq!(&buf[..len], CAPTURE_REPLY_EMPTY);
1023        let _ = std::fs::remove_dir_all(&dir);
1024    }
1025
1026    #[test]
1027    fn opt_out_values() {
1028        for value in ["0", "false", "OFF", "no"] {
1029            assert!(disabled_value(Some(value)), "{value}");
1030        }
1031        for value in [None, Some("1"), Some("")] {
1032            assert!(!disabled_value(value), "{value:?}");
1033        }
1034    }
1035
1036    #[test]
1037    fn text_is_trimmed_to_its_tail_and_masked() {
1038        let long = "a".repeat(80) + "end";
1039        assert_eq!(shown_text(&long, false).chars().count(), TEXT_LIMIT);
1040        assert!(shown_text(&long, false).ends_with("end"));
1041        assert_eq!(shown_text("пароль", true), shown_text("x", true));
1042        assert!(!shown_text("пароль", true).contains("пароль"));
1043    }
1044
1045    #[test]
1046    fn events_serialize_compactly() {
1047        let event = IndicatorEvent {
1048            agent: "Claude".into(),
1049            tool: "click".into(),
1050            x: Some(10),
1051            y: Some(20),
1052            ..Default::default()
1053        };
1054        let json = serde_json::to_string(&event).unwrap();
1055        assert_eq!(json, r#"{"agent":"Claude","tool":"click","x":10,"y":20}"#);
1056        assert_eq!(
1057            serde_json::from_str::<IndicatorEvent>(&json).unwrap(),
1058            event
1059        );
1060        let scaled = IndicatorEvent {
1061            space: Some((3840, 2160)),
1062            ..event
1063        };
1064        let json = serde_json::to_string(&scaled).unwrap();
1065        assert!(json.ends_with(r#""space":[3840,2160]}"#), "{json}");
1066        assert_eq!(
1067            serde_json::from_str::<IndicatorEvent>(&json).unwrap(),
1068            scaled
1069        );
1070        let begin = serde_json::to_string(&IndicatorEvent {
1071            capture: Some(Capture::Begin),
1072            ..Default::default()
1073        });
1074        assert_eq!(begin.unwrap(), r#"{"capture":"begin"}"#);
1075    }
1076}