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