1use 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
38pub const INDICATOR_BINARY: &str = "computer-use-linux-indicator";
40pub const SOCKET_NAME: &str = "computer-use-linux-indicator.sock";
42pub const CAPTURE_REPLY_SHOWN: &[u8] = b"shown";
44pub 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
52pub const GLIDE: Duration = Duration::from_millis(350);
55pub const HIDE_SETTLE: Duration = Duration::from_millis(120);
57const CAPTURE_REPLY_WAIT: Duration = Duration::from_millis(100);
59const SPAWN_WAIT: Duration = Duration::from_secs(1);
60const TEXT_LIMIT: usize = 64;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum Capture {
66 Begin,
67 End,
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
72pub struct IndicatorEvent {
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub capture: Option<Capture>,
76 #[serde(default, skip_serializing_if = "String::is_empty")]
78 pub agent: String,
79 #[serde(default, skip_serializing_if = "String::is_empty")]
81 pub tool: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub x: Option<i32>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub y: Option<i32>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub space: Option<(u32, u32)>,
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub keys: Vec<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub text: Option<String>,
98}
99
100pub fn socket_path() -> Option<PathBuf> {
102 env::var_os("XDG_RUNTIME_DIR")
103 .filter(|dir| !dir.is_empty())
104 .map(|dir| Path::new(&dir).join(SOCKET_NAME))
105}
106
107pub fn capture_lock_path(socket: &Path) -> PathBuf {
109 socket.with_extension("capture")
110}
111
112pub fn is_gnome_session() -> bool {
114 env::var("XDG_CURRENT_DESKTOP")
115 .unwrap_or_default()
116 .split(':')
117 .any(|desktop| desktop.eq_ignore_ascii_case("gnome"))
118}
119
120pub async fn show_gnome_indicator(
123 connection: &zbus::Connection,
124 event: &IndicatorEvent,
125 age_ms: u32,
126) -> anyhow::Result<()> {
127 let proxy = crate::windowing::backends::gnome::verified_extension_proxy(connection).await?;
128 let _: () = proxy
129 .call("ShowIndicator", &(serde_json::to_string(event)?, age_ms))
130 .await?;
131 Ok(())
132}
133
134pub async fn gnome_indicator_state(connection: &zbus::Connection) -> anyhow::Result<String> {
136 let proxy = crate::windowing::backends::gnome::verified_extension_proxy(connection).await?;
137 Ok(proxy.call("GetIndicatorState", &()).await?)
138}
139
140pub async fn hide_gnome_indicator(connection: &zbus::Connection) -> anyhow::Result<bool> {
143 let bus = zbus::fdo::DBusProxy::new(connection).await?;
144 if !bus
145 .name_has_owner(crate::identity::DBUS_SERVICE.try_into()?)
146 .await?
147 {
148 return Ok(false);
149 }
150 let proxy = crate::windowing::backends::gnome::verified_extension_proxy(connection).await?;
151 match proxy.call("HideIndicator", &()).await {
152 Ok(shown) => Ok(shown),
153 Err(error) if error.to_string().contains("UnknownMethod") => Ok(false),
154 Err(error) => Err(error.into()),
155 }
156}
157
158async fn settle_gnome_indicator_if_present() -> anyhow::Result<()> {
159 let runtime_bus = env::var_os("XDG_RUNTIME_DIR")
162 .map(PathBuf::from)
163 .unwrap_or_else(|| {
164 PathBuf::from(format!("/run/user/{}", unsafe { libc::geteuid() }))
166 })
167 .join("bus");
168 if cfg!(test)
169 || (!is_gnome_session()
170 && env::var_os("DBUS_SESSION_BUS_ADDRESS").is_none()
171 && !runtime_bus.exists())
172 {
173 return Ok(());
174 }
175 let probe = tokio::time::timeout(CAPTURE_REPLY_WAIT, async {
176 let connection = zbus::Connection::session().await?;
177 let bus = zbus::fdo::DBusProxy::new(&connection).await?;
178 let present = bus
179 .name_has_owner(crate::identity::DBUS_SERVICE.try_into()?)
180 .await?;
181 Ok::<_, anyhow::Error>(present.then_some(connection))
182 })
183 .await;
184 let connection = match probe {
185 Ok(Ok(Some(connection))) => connection,
186 Ok(Ok(None)) => return Ok(()),
187 Ok(Err(error)) if is_gnome_session() => return Err(error),
188 Err(error) if is_gnome_session() => return Err(error.into()),
189 _ => return Ok(()),
190 };
191 let hidden = tokio::time::timeout(CAPTURE_REPLY_WAIT, hide_gnome_indicator(&connection))
192 .await
193 .map_err(|_| {
194 anyhow::anyhow!("GNOME indicator did not acknowledge hiding before capturing")
195 })??;
196 if hidden {
197 tokio::time::sleep(HIDE_SETTLE).await;
198 }
199 Ok(())
200}
201
202pub fn agent_display_name(name: &str, title: Option<&str>) -> String {
204 let lower = name.to_ascii_lowercase();
205 let known = [
206 ("claude", "Claude"),
207 ("codex", "Codex"),
208 ("opencode", "OpenCode"),
209 ("gemini", "Gemini"),
210 ("antigravity", "Antigravity"),
211 ("hermes", "Hermes"),
212 ("cursor", "Cursor"),
213 ("goose", "Goose"),
214 ];
215 if let Some((_, display)) = known.iter().find(|(needle, _)| lower.contains(needle)) {
216 return (*display).to_string();
217 }
218 if lower == "pi" || lower.starts_with("pi-") || lower.ends_with("-pi") {
220 return "Pi".to_string();
221 }
222 if let Some(title) = title.map(str::trim).filter(|title| !title.is_empty()) {
223 return title.to_string();
224 }
225 match name.trim() {
226 "" => "Agent".to_string(),
227 other => other.to_string(),
228 }
229}
230
231fn disabled_value(value: Option<&str>) -> bool {
232 matches!(
233 value
234 .map(|value| value.trim().to_ascii_lowercase())
235 .as_deref(),
236 Some("0" | "false" | "off" | "no")
237 )
238}
239
240fn shown_text(text: &str, mask: bool) -> String {
243 if mask {
244 return "••••••••".to_string();
245 }
246 let chars: Vec<char> = text.chars().collect();
247 chars[chars.len().saturating_sub(TEXT_LIMIT)..]
248 .iter()
249 .collect()
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253enum Overlay {
254 NotStarted,
255 Started(Instant),
256 Unavailable,
259}
260
261#[derive(Debug)]
264pub(crate) struct Indicator {
265 enabled: bool,
266 mask_text: bool,
267 agent_override: Option<String>,
268 peer: OnceLock<Peer<RoleServer>>,
269 socket: Option<UnixDatagram>,
270 path: Option<PathBuf>,
272 overlay: Mutex<Overlay>,
273 capture_handshake: tokio::sync::Mutex<()>,
275}
276
277impl Default for Indicator {
278 fn default() -> Self {
279 let live = !cfg!(test);
281 let socket = live
282 .then(client_socket)
283 .flatten()
284 .filter(|socket| socket.set_nonblocking(true).is_ok());
285 Self {
286 enabled: socket.is_some() && !disabled_value(env::var(DISABLE_ENV).ok().as_deref()),
287 mask_text: env::var(HIDE_TEXT_ENV).ok().as_deref() == Some("1"),
288 agent_override: env::var(AGENT_ENV)
289 .ok()
290 .filter(|name| !name.trim().is_empty()),
291 peer: OnceLock::new(),
292 socket,
293 path: live.then(socket_path).flatten(),
294 overlay: Mutex::new(Overlay::NotStarted),
295 capture_handshake: tokio::sync::Mutex::new(()),
296 }
297 }
298}
299
300impl Indicator {
301 pub(crate) fn attach(&self, peer: Peer<RoleServer>) {
303 let _ = self.peer.set(peer);
304 }
305
306 fn agent(&self) -> String {
307 if let Some(name) = &self.agent_override {
308 return name.clone();
309 }
310 self.peer
311 .get()
312 .and_then(Peer::peer_info)
313 .map(|info| {
314 agent_display_name(&info.client_info.name, info.client_info.title.as_deref())
315 })
316 .unwrap_or_else(|| "Agent".to_string())
317 }
318
319 fn event(&self, tool: &str) -> IndicatorEvent {
320 IndicatorEvent {
321 agent: self.agent(),
322 tool: tool.to_string(),
323 ..IndicatorEvent::default()
324 }
325 }
326
327 pub(crate) fn enabled(&self) -> bool {
328 self.enabled
329 }
330
331 pub(crate) async fn pointer(&self, tool: &str, (x, y): (i32, i32), space: Option<(u32, u32)>) {
335 let Some(space) = space else {
336 return self.action(tool).await;
337 };
338 let event = IndicatorEvent {
339 x: Some(x),
340 y: Some(y),
341 space: Some(space),
342 ..self.event(tool)
343 };
344 if self.send(&event).await {
345 tokio::time::sleep(GLIDE).await;
346 }
347 }
348
349 pub(crate) async fn follow(&self, tool: &str, (x, y): (i32, i32), space: Option<(u32, u32)>) {
351 let Some(space) = space else {
352 return;
353 };
354 let event = IndicatorEvent {
355 x: Some(x),
356 y: Some(y),
357 space: Some(space),
358 ..self.event(tool)
359 };
360 self.send(&event).await;
361 }
362
363 pub(crate) async fn action(&self, tool: &str) {
365 self.send(&self.event(tool)).await;
366 }
367
368 pub(crate) async fn keys(&self, chord: &str, secret: bool) {
369 let event = IndicatorEvent {
370 keys: if self.mask_text || secret {
372 vec!["••••••••".to_string()]
373 } else {
374 chord
375 .split('+')
376 .map(str::trim)
377 .filter(|key| !key.is_empty())
378 .map(str::to_string)
379 .collect()
380 },
381 ..self.event("press_key")
382 };
383 self.send(&event).await;
384 }
385
386 pub(crate) async fn text(&self, tool: &str, text: &str, secret: bool) {
388 let event = IndicatorEvent {
389 text: Some(shown_text(
391 text,
392 self.mask_text || secret || tool == "set_value",
393 )),
394 ..self.event(tool)
395 };
396 self.send(&event).await;
397 }
398
399 pub(crate) async fn hold_for_capture(&self) -> anyhow::Result<CaptureHold<'_>> {
405 let _handshake = self.capture_handshake.lock().await;
406 let hold = CaptureHold {
407 indicator: self,
408 lock: self.take_capture_lock().await?,
409 };
410 let Some(socket) = &self.socket else {
411 if self.path.is_some() {
412 anyhow::bail!("no client socket to confirm the action indicator overlay is hidden");
413 }
414 return Ok(hold);
415 };
416 let mut reply = [0u8; 16];
417 while socket.recv(&mut reply).is_ok() {}
419 let begin = IndicatorEvent {
420 capture: Some(Capture::Begin),
421 ..Default::default()
422 };
423 let deadline = Instant::now() + CAPTURE_REPLY_WAIT;
424 loop {
425 match self.send_raw(&begin) {
426 Ok(()) => break,
427 Err(error)
429 if matches!(
430 error.kind(),
431 ErrorKind::NotFound | ErrorKind::ConnectionRefused
432 ) =>
433 {
434 settle_gnome_indicator_if_present().await?;
435 return Ok(hold);
436 }
437 Err(error)
439 if error.kind() == ErrorKind::WouldBlock && Instant::now() < deadline =>
440 {
441 tokio::time::sleep(Duration::from_millis(5)).await
442 }
443 Err(error) => anyhow::bail!(
444 "could not hide the action indicator overlay before capturing: {error}"
445 ),
446 }
447 }
448 let deadline = Instant::now() + CAPTURE_REPLY_WAIT;
449 while Instant::now() < deadline {
450 match socket.recv_from(&mut reply) {
451 Ok((len, from)) if from.as_pathname() == self.path.as_deref() => {
454 match &reply[..len] {
455 CAPTURE_REPLY_EMPTY => return Ok(hold),
456 CAPTURE_REPLY_SHOWN => {
457 tokio::time::sleep(HIDE_SETTLE).await;
458 return Ok(hold);
459 }
460 _ => anyhow::bail!(
461 "invalid action indicator overlay acknowledgement before capturing"
462 ),
463 }
464 }
465 Ok(_) => {}
466 Err(error) if error.kind() == ErrorKind::WouldBlock => {
467 tokio::time::sleep(Duration::from_millis(5)).await
468 }
469 Err(error) => anyhow::bail!(
470 "could not confirm the action indicator overlay is hidden before capturing: {error}"
471 ),
472 }
473 }
474 anyhow::bail!("action indicator overlay did not acknowledge hiding before capturing")
475 }
476
477 async fn take_capture_lock(&self) -> anyhow::Result<Option<File>> {
478 let Some(path) = self.path.as_deref() else {
479 return Ok(None);
480 };
481 let file = OpenOptions::new()
482 .create(true)
483 .truncate(false)
484 .write(true)
485 .open(capture_lock_path(path))
486 .map_err(|error| anyhow::anyhow!("could not open indicator capture lock: {error}"))?;
487 for _ in 0..20 {
489 match file.try_lock_shared() {
490 Ok(()) => return Ok(Some(file)),
491 Err(TryLockError::WouldBlock) => tokio::time::sleep(Duration::from_millis(1)).await,
492 Err(TryLockError::Error(error)) => {
493 anyhow::bail!("could not take indicator capture lock: {error}")
494 }
495 }
496 }
497 anyhow::bail!("indicator capture lock remained busy")
498 }
499
500 fn send_raw(&self, event: &IndicatorEvent) -> io::Result<()> {
501 let (Some(socket), Some(path)) = (&self.socket, &self.path) else {
502 return Err(ErrorKind::NotFound.into());
503 };
504 socket.send_to(&serde_json::to_vec(event)?, path).map(drop)
505 }
506
507 async fn send(&self, event: &IndicatorEvent) -> bool {
509 if !self.enabled {
510 return false;
511 }
512 let (Some(socket), Some(path)) = (&self.socket, &self.path) else {
513 return false;
514 };
515 let Ok(payload) = serde_json::to_vec(event) else {
516 return false;
517 };
518 let sent = match socket.send_to(&payload, path) {
519 Ok(_) => true,
520 Err(error)
521 if matches!(
522 error.kind(),
523 ErrorKind::NotFound | ErrorKind::ConnectionRefused
524 ) =>
525 {
526 self.start_overlay(path).await && socket.send_to(&payload, path).is_ok()
527 }
528 Err(_) => false,
529 };
530 sent
531 }
532
533 async fn start_overlay(&self, path: &Path) -> bool {
534 {
535 let Ok(mut overlay) = self.overlay.lock() else {
536 return false;
537 };
538 match *overlay {
539 Overlay::Unavailable => return false,
540 Overlay::Started(at) if at.elapsed() < SPAWN_WAIT => return false,
542 _ => {}
543 }
544 if (!is_gnome_session() && env::var_os("WAYLAND_DISPLAY").is_none())
545 || spawn_overlay().is_err()
546 {
547 *overlay = Overlay::Unavailable;
548 return false;
549 }
550 *overlay = Overlay::Started(Instant::now());
551 }
552 let deadline = Instant::now() + SPAWN_WAIT;
553 while Instant::now() < deadline {
554 if UnixDatagram::unbound()
555 .and_then(|probe| probe.connect(path))
556 .is_ok()
557 {
558 return true;
559 }
560 tokio::time::sleep(Duration::from_millis(25)).await;
561 }
562 if let Ok(mut overlay) = self.overlay.lock() {
563 *overlay = Overlay::Unavailable;
564 }
565 false
566 }
567}
568
569pub(crate) struct CaptureHold<'a> {
572 indicator: &'a Indicator,
573 lock: Option<File>,
574}
575
576impl Drop for CaptureHold<'_> {
577 fn drop(&mut self) {
578 if let Some(lock) = self.lock.take() {
581 let _ = lock.unlock();
582 }
583 let _ = self.indicator.send_raw(&IndicatorEvent {
584 capture: Some(Capture::End),
585 ..Default::default()
586 });
587 }
588}
589
590fn client_socket() -> Option<UnixDatagram> {
593 static NEXT: AtomicU32 = AtomicU32::new(0);
594 let name = format!(
595 "computer-use-linux-indicator-client.{}.{}",
596 std::process::id(),
597 NEXT.fetch_add(1, Ordering::Relaxed)
598 );
599 SocketAddr::from_abstract_name(name.as_bytes())
600 .and_then(|address| UnixDatagram::bind_addr(&address))
601 .or_else(|_| UnixDatagram::unbound())
602 .ok()
603}
604
605pub(crate) fn is_secret_role(role: &str) -> bool {
609 if matches!(role.trim(), "Entry" | "Text") {
612 return true;
613 }
614 let role = role.trim().to_ascii_lowercase();
615 role.is_empty()
616 || role == crate::atspi_tree::UNKNOWN_ROLE
617 || role == "invalid"
618 || matches!(role.as_str(), "text box" | "textbox" | "terminal")
619 || role.contains("password")
620}
621
622fn overlay_binary() -> PathBuf {
623 if let Some(path) = env::var_os(BINARY_ENV).filter(|path| !path.is_empty()) {
624 return PathBuf::from(path);
625 }
626 env::current_exe()
627 .ok()
628 .map(|exe| exe.with_file_name(INDICATOR_BINARY))
629 .filter(|sibling| sibling.is_file())
630 .unwrap_or_else(|| PathBuf::from(INDICATOR_BINARY))
631}
632
633fn spawn_overlay() -> std::io::Result<()> {
634 let mut child = tokio::process::Command::new(overlay_binary())
635 .stdin(Stdio::null())
636 .stdout(Stdio::null())
637 .stderr(Stdio::null())
638 .process_group(0)
641 .spawn()?;
642 tokio::spawn(async move {
644 let _ = child.wait().await;
645 });
646 Ok(())
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 #[test]
654 fn agent_names_come_from_client_info() {
655 assert_eq!(agent_display_name("claude-code", None), "Claude");
656 assert_eq!(agent_display_name("codex-mcp-client", None), "Codex");
657 assert_eq!(agent_display_name("opencode", None), "OpenCode");
658 assert_eq!(agent_display_name("pi", None), "Pi");
659 assert_eq!(agent_display_name("computer-use-linux-pi", None), "Pi");
660 assert_eq!(
661 agent_display_name("pipeline", Some("Pipeline Bot")),
662 "Pipeline Bot"
663 );
664 assert_eq!(agent_display_name("my-agent", None), "my-agent");
665 assert_eq!(agent_display_name(" ", None), "Agent");
666 }
667
668 #[test]
669 fn password_fields_are_secret() {
670 assert!(is_secret_role("password text"));
671 assert!(is_secret_role("PasswordText"));
672 assert!(!is_secret_role("text"));
673 assert!(!is_secret_role("entry"));
674 }
675
676 #[test]
677 fn unreadable_roles_are_secret() {
678 assert!(is_secret_role(crate::atspi_tree::UNKNOWN_ROLE));
680 assert!(is_secret_role("Unknown"));
681 assert!(is_secret_role("Invalid"));
682 assert!(is_secret_role(" "));
683 }
684
685 #[test]
686 fn generic_text_boxes_do_not_establish_password_safety() {
687 assert!(is_secret_role("text box"));
690 assert!(is_secret_role("TextBox"));
691 }
692
693 #[test]
694 fn enum_fallback_text_and_terminal_roles_are_masked() {
695 assert!(is_secret_role("Entry"));
696 assert!(is_secret_role("Text"));
697 assert!(is_secret_role("terminal"));
698 assert!(!is_secret_role("entry"));
700 }
701
702 fn sender_for(path: &Path, enabled: bool) -> Indicator {
704 let socket = client_socket().unwrap();
705 socket.set_nonblocking(true).unwrap();
706 Indicator {
707 enabled,
708 mask_text: false,
709 agent_override: None,
710 peer: OnceLock::new(),
711 socket: Some(socket),
712 path: Some(path.to_path_buf()),
713 overlay: Mutex::new(Overlay::Unavailable),
714 capture_handshake: tokio::sync::Mutex::new(()),
715 }
716 }
717
718 fn scratch_dir(name: &str) -> PathBuf {
719 let dir = std::env::temp_dir().join(format!("cul-{}-{name}", std::process::id()));
720 let _ = std::fs::remove_dir_all(&dir);
721 std::fs::create_dir_all(&dir).unwrap();
722 dir
723 }
724
725 fn capture_held(socket: &Path) -> bool {
727 let file = File::open(capture_lock_path(socket)).unwrap();
728 match file.try_lock() {
729 Ok(()) => false,
730 Err(TryLockError::WouldBlock) => true,
731 Err(TryLockError::Error(error)) => panic!("{error}"),
732 }
733 }
734
735 async fn hold_with_reply(
739 name: &str,
740 reply: &'static [u8],
741 spoofed: bool,
742 ) -> anyhow::Result<Duration> {
743 let dir = scratch_dir(name);
744 let socket = dir.join(SOCKET_NAME);
745 let overlay = UnixDatagram::bind(&socket).unwrap();
746 overlay
747 .set_read_timeout(Some(Duration::from_secs(2)))
748 .unwrap();
749 let impostor = UnixDatagram::bind(dir.join("impostor.sock")).unwrap();
750 let sender = sender_for(&socket, true);
751 let answer = std::thread::spawn(move || {
752 let mut buf = [0u8; 64];
753 let (len, from) = overlay.recv_from(&mut buf).unwrap();
754 assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
755 let via = if spoofed { &impostor } else { &overlay };
756 via.send_to_addr(reply, &from).unwrap();
757 let len = overlay.recv(&mut buf).unwrap();
758 assert_eq!(&buf[..len], br#"{"capture":"end"}"#);
759 });
760 let started = Instant::now();
761 let hold = sender.hold_for_capture().await;
762 let took = started.elapsed();
763 let result = hold.map(|hold| {
764 assert!(capture_held(&socket));
765 drop(hold);
766 took
767 });
768 assert!(!capture_held(&socket));
769 answer.join().unwrap();
770 let _ = std::fs::remove_dir_all(&dir);
771 result
772 }
773
774 #[tokio::test]
775 async fn captures_wait_only_while_the_overlay_was_on_screen() {
776 assert!(
777 hold_with_reply("empty", CAPTURE_REPLY_EMPTY, false)
778 .await
779 .unwrap()
780 < HIDE_SETTLE
781 );
782 assert!(
783 hold_with_reply("shown", CAPTURE_REPLY_SHOWN, false)
784 .await
785 .unwrap()
786 >= HIDE_SETTLE
787 );
788 assert!(hold_with_reply("spoofed", CAPTURE_REPLY_EMPTY, true)
790 .await
791 .is_err());
792 assert!(hold_with_reply("invalid", b"garbage", false).await.is_err());
793 }
794
795 #[tokio::test]
796 async fn captures_hold_the_lock_without_an_overlay() {
797 let dir = scratch_dir("none");
799 let socket = dir.join(SOCKET_NAME);
800 let sender = sender_for(&socket, true);
801 let started = Instant::now();
802 let hold = sender.hold_for_capture().await.unwrap();
803 assert!(started.elapsed() < HIDE_SETTLE);
804 assert!(capture_held(&socket));
805 drop(hold);
806 assert!(!capture_held(&socket));
807 let _ = std::fs::remove_dir_all(&dir);
808 }
809
810 #[tokio::test]
811 async fn capture_release_unlocks_an_inherited_descriptor() {
812 let dir = scratch_dir("inherited-lock");
813 let socket = dir.join(SOCKET_NAME);
814 let sender = sender_for(&socket, true);
815 let hold = sender.hold_for_capture().await.unwrap();
816 let _inherited = hold.lock.as_ref().unwrap().try_clone().unwrap();
817 assert!(capture_held(&socket));
818 drop(hold);
819 assert!(!capture_held(&socket));
820 let _ = std::fs::remove_dir_all(&dir);
821 }
822
823 #[tokio::test]
824 async fn captures_fail_while_a_stalled_overlay_cannot_be_told_to_hide() {
825 let dir = scratch_dir("stalled");
826 let socket = dir.join(SOCKET_NAME);
827 let _overlay = UnixDatagram::bind(&socket).unwrap();
828 let sender = sender_for(&socket, true);
829 let socket_sender = sender.socket.as_ref().unwrap();
831 let deadline = Instant::now() + Duration::from_secs(2);
832 loop {
833 assert!(Instant::now() < deadline, "sender never became blocked");
834 match socket_sender.send_to(b"{}", &socket) {
835 Ok(_) => {}
836 Err(error) => {
837 assert_eq!(error.kind(), ErrorKind::WouldBlock);
838 break;
839 }
840 }
841 }
842 let error = sender.hold_for_capture().await.err().unwrap();
843 assert!(error.to_string().contains("could not hide"), "{error}");
844 assert!(!capture_held(&socket));
845 let _ = std::fs::remove_dir_all(&dir);
846 }
847
848 #[tokio::test]
849 async fn captures_fail_when_a_running_overlay_does_not_reply() {
850 let dir = scratch_dir("silent");
851 let socket = dir.join(SOCKET_NAME);
852 let overlay = UnixDatagram::bind(&socket).unwrap();
853 overlay.set_nonblocking(true).unwrap();
854 let sender = sender_for(&socket, true);
855 let error = sender.hold_for_capture().await.err().unwrap();
856 assert!(error.to_string().contains("did not acknowledge"), "{error}");
857 let mut buf = [0u8; 64];
858 let len = overlay.recv(&mut buf).unwrap();
859 assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
860 assert!(!capture_held(&socket));
861 let _ = std::fs::remove_dir_all(&dir);
862 }
863
864 #[tokio::test]
865 async fn set_values_are_masked_even_when_the_cached_role_was_public() {
866 let dir = scratch_dir("set-value-mask");
867 let socket = dir.join(SOCKET_NAME);
868 let overlay = UnixDatagram::bind(&socket).unwrap();
869 overlay
870 .set_read_timeout(Some(Duration::from_secs(2)))
871 .unwrap();
872 let sender = sender_for(&socket, true);
873 sender.text("set_value", "secret", false).await;
874 let mut buf = [0u8; 512];
875 let len = overlay.recv(&mut buf).unwrap();
876 let event: IndicatorEvent = serde_json::from_slice(&buf[..len]).unwrap();
877 assert_eq!(event.text.as_deref(), Some("••••••••"));
878 let _ = std::fs::remove_dir_all(&dir);
879 }
880
881 #[tokio::test]
882 async fn keycaps_mask_secret_focus_and_the_hide_text_setting() {
883 let dir = scratch_dir("key-mask");
884 let socket = dir.join(SOCKET_NAME);
885 let overlay = UnixDatagram::bind(&socket).unwrap();
886 overlay
887 .set_read_timeout(Some(Duration::from_secs(2)))
888 .unwrap();
889 let mut sender = sender_for(&socket, true);
890 for (secret, hide_text, expected) in [
891 (true, false, vec!["••••••••"]),
892 (false, true, vec!["••••••••"]),
893 (false, false, vec!["Shift", "a"]),
894 ] {
895 sender.mask_text = hide_text;
896 sender.keys("Shift+a", secret).await;
897 let mut buf = [0u8; 512];
898 let len = overlay.recv(&mut buf).unwrap();
899 let event: IndicatorEvent = serde_json::from_slice(&buf[..len]).unwrap();
900 assert_eq!(event.keys, expected);
901 }
902 let _ = std::fs::remove_dir_all(&dir);
903 }
904
905 #[tokio::test]
906 async fn captures_fail_when_the_capture_lock_cannot_be_taken() {
907 let dir = scratch_dir("unavailable-lock");
908 let socket = dir.join(SOCKET_NAME);
909 let lock_path = capture_lock_path(&socket);
910 std::fs::create_dir(&lock_path).unwrap();
911 let sender = sender_for(&socket, true);
912 assert!(sender.hold_for_capture().await.is_err());
913 std::fs::remove_dir(&lock_path).unwrap();
914 let lock = File::create(&lock_path).unwrap();
915 lock.try_lock().unwrap();
916 assert!(sender.hold_for_capture().await.is_err());
917 let _ = std::fs::remove_dir_all(&dir);
918 }
919
920 #[tokio::test]
921 async fn captures_fail_without_a_client_socket_in_a_live_runtime() {
922 let dir = scratch_dir("no-client-socket");
923 let socket = dir.join(SOCKET_NAME);
924 let mut sender = sender_for(&socket, true);
925 sender.socket = None;
926 assert!(sender.hold_for_capture().await.is_err());
927 assert!(!capture_held(&socket));
928 let _ = std::fs::remove_dir_all(&dir);
929 }
930
931 #[tokio::test]
932 async fn concurrent_captures_do_not_consume_each_others_replies() {
933 let dir = scratch_dir("concurrent");
934 let socket = dir.join(SOCKET_NAME);
935 let overlay = UnixDatagram::bind(&socket).unwrap();
936 overlay
937 .set_read_timeout(Some(Duration::from_secs(2)))
938 .unwrap();
939 let sender = sender_for(&socket, true);
940 let (ack_tx, ack_rx) = std::sync::mpsc::channel();
941 let answer = std::thread::spawn(move || {
942 let mut buf = [0u8; 64];
943 for n in 0..2 {
944 let (len, from) = overlay.recv_from(&mut buf).unwrap();
945 assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
946 overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
947 if n == 0 {
948 ack_tx.send(()).unwrap();
949 }
950 }
951 });
952 let mut first = Box::pin(sender.hold_for_capture());
953 assert!(futures_util::poll!(first.as_mut()).is_pending());
954 ack_rx.recv_timeout(Duration::from_secs(2)).unwrap();
955 let (second, first) = tokio::join!(biased; sender.hold_for_capture(), first);
957 let (first, second) = (first.unwrap(), second.unwrap());
958 assert!(capture_held(&socket));
959 drop(first);
960 assert!(capture_held(&socket), "the second capture is still running");
961 drop(second);
962 assert!(!capture_held(&socket));
963 answer.join().unwrap();
964 let _ = std::fs::remove_dir_all(&dir);
965 }
966
967 #[tokio::test]
968 async fn disabled_servers_still_hold_captures() {
969 let dir = scratch_dir("disabled");
970 let socket = dir.join(SOCKET_NAME);
971 let overlay = UnixDatagram::bind(&socket).unwrap();
972 overlay.set_nonblocking(true).unwrap();
973 let sender = sender_for(&socket, false);
974 let hold = sender.hold_for_capture();
975 let acknowledge = async {
976 let mut buf = [0u8; 64];
977 let deadline = Instant::now() + Duration::from_secs(2);
978 loop {
979 assert!(Instant::now() < deadline, "capture begin was not received");
980 match overlay.recv_from(&mut buf) {
981 Ok((len, from)) => {
982 assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
983 overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
984 break;
985 }
986 Err(error) if error.kind() == ErrorKind::WouldBlock => {
987 tokio::time::sleep(Duration::from_millis(1)).await
988 }
989 Err(error) => panic!("{error}"),
990 }
991 }
992 };
993 let (hold, ()) = tokio::join!(hold, acknowledge);
994 let hold = hold.unwrap();
995 assert!(capture_held(&socket));
996 let mut buf = [0u8; 64];
997 sender.action("click").await;
999 assert!(overlay.recv(&mut buf).is_err());
1000 drop(hold);
1001 let len = overlay.recv(&mut buf).unwrap();
1002 assert_eq!(&buf[..len], br#"{"capture":"end"}"#);
1003 assert!(!capture_held(&socket));
1004 let _ = std::fs::remove_dir_all(&dir);
1005 }
1006
1007 #[test]
1008 fn overlays_can_answer_the_client_socket() {
1009 let dir = scratch_dir("answer");
1010 let overlay_path = dir.join(SOCKET_NAME);
1011 let overlay = UnixDatagram::bind(&overlay_path).unwrap();
1012 let client = client_socket().unwrap();
1013 client.send_to(b"ping", &overlay_path).unwrap();
1014 let mut buf = [0u8; 64];
1015 let (_, from) = overlay.recv_from(&mut buf).unwrap();
1016 overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
1017 let len = client.recv(&mut buf).unwrap();
1018 assert_eq!(&buf[..len], CAPTURE_REPLY_EMPTY);
1019 let _ = std::fs::remove_dir_all(&dir);
1020 }
1021
1022 #[test]
1023 fn opt_out_values() {
1024 for value in ["0", "false", "OFF", "no"] {
1025 assert!(disabled_value(Some(value)), "{value}");
1026 }
1027 for value in [None, Some("1"), Some("")] {
1028 assert!(!disabled_value(value), "{value:?}");
1029 }
1030 }
1031
1032 #[test]
1033 fn text_is_trimmed_to_its_tail_and_masked() {
1034 let long = "a".repeat(80) + "end";
1035 assert_eq!(shown_text(&long, false).chars().count(), TEXT_LIMIT);
1036 assert!(shown_text(&long, false).ends_with("end"));
1037 assert_eq!(shown_text("пароль", true), shown_text("x", true));
1038 assert!(!shown_text("пароль", true).contains("пароль"));
1039 }
1040
1041 #[test]
1042 fn events_serialize_compactly() {
1043 let event = IndicatorEvent {
1044 agent: "Claude".into(),
1045 tool: "click".into(),
1046 x: Some(10),
1047 y: Some(20),
1048 ..Default::default()
1049 };
1050 let json = serde_json::to_string(&event).unwrap();
1051 assert_eq!(json, r#"{"agent":"Claude","tool":"click","x":10,"y":20}"#);
1052 assert_eq!(
1053 serde_json::from_str::<IndicatorEvent>(&json).unwrap(),
1054 event
1055 );
1056 let scaled = IndicatorEvent {
1057 space: Some((3840, 2160)),
1058 ..event
1059 };
1060 let json = serde_json::to_string(&scaled).unwrap();
1061 assert!(json.ends_with(r#""space":[3840,2160]}"#), "{json}");
1062 assert_eq!(
1063 serde_json::from_str::<IndicatorEvent>(&json).unwrap(),
1064 scaled
1065 );
1066 let begin = serde_json::to_string(&IndicatorEvent {
1067 capture: Some(Capture::Begin),
1068 ..Default::default()
1069 });
1070 assert_eq!(begin.unwrap(), r#"{"capture":"begin"}"#);
1071 }
1072}