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(300);
60const SPAWN_WAIT: Duration = Duration::from_secs(1);
61const TEXT_LIMIT: usize = 64;
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "lowercase")]
66pub enum Capture {
67 Begin,
68 End,
69}
70
71#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
73pub struct IndicatorEvent {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub capture: Option<Capture>,
77 #[serde(default, skip_serializing_if = "String::is_empty")]
79 pub agent: String,
80 #[serde(default, skip_serializing_if = "String::is_empty")]
82 pub tool: String,
83 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub space: Option<(u32, u32)>,
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub keys: Vec<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub text: Option<String>,
99}
100
101pub 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
108pub fn capture_lock_path(socket: &Path) -> PathBuf {
110 socket.with_extension("capture")
111}
112
113pub 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
121pub 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
135pub 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
141pub 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 let runtime_bus = env::var_os("XDG_RUNTIME_DIR")
163 .map(PathBuf::from)
164 .unwrap_or_else(|| {
165 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
203pub 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 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
241fn 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 Unavailable,
260}
261
262#[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 path: Option<PathBuf>,
273 overlay: Mutex<Overlay>,
274 capture_handshake: tokio::sync::Mutex<()>,
276}
277
278impl Default for Indicator {
279 fn default() -> Self {
280 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 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 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 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 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 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 pub(crate) async fn text(&self, tool: &str, text: &str, secret: bool) {
389 let event = IndicatorEvent {
390 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 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 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 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 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 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 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 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 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
570pub(crate) struct CaptureHold<'a> {
573 indicator: &'a Indicator,
574 lock: Option<File>,
575}
576
577impl Drop for CaptureHold<'_> {
578 fn drop(&mut self) {
579 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
591fn 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
606pub(crate) fn is_secret_role(role: &str) -> bool {
610 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 .process_group(0)
642 .spawn()?;
643 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 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 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 assert!(!is_secret_role("entry"));
701 }
702
703 fn sender_for(path: &Path, enabled: bool) -> Indicator {
705 let socket = client_socket().unwrap();
706 socket.set_nonblocking(true).unwrap();
707 Indicator {
708 enabled,
709 mask_text: false,
710 agent_override: None,
711 peer: OnceLock::new(),
712 socket: Some(socket),
713 path: Some(path.to_path_buf()),
714 overlay: Mutex::new(Overlay::Unavailable),
715 capture_handshake: tokio::sync::Mutex::new(()),
716 }
717 }
718
719 fn scratch_dir(name: &str) -> PathBuf {
720 let dir = std::env::temp_dir().join(format!("cul-{}-{name}", std::process::id()));
721 let _ = std::fs::remove_dir_all(&dir);
722 std::fs::create_dir_all(&dir).unwrap();
723 dir
724 }
725
726 fn capture_held(socket: &Path) -> bool {
728 let file = File::open(capture_lock_path(socket)).unwrap();
729 match file.try_lock() {
730 Ok(()) => false,
731 Err(TryLockError::WouldBlock) => true,
732 Err(TryLockError::Error(error)) => panic!("{error}"),
733 }
734 }
735
736 async fn hold_with_reply(
740 name: &str,
741 reply: &'static [u8],
742 spoofed: bool,
743 ) -> anyhow::Result<Duration> {
744 let dir = scratch_dir(name);
745 let socket = dir.join(SOCKET_NAME);
746 let overlay = UnixDatagram::bind(&socket).unwrap();
747 overlay
748 .set_read_timeout(Some(Duration::from_secs(2)))
749 .unwrap();
750 let impostor = UnixDatagram::bind(dir.join("impostor.sock")).unwrap();
751 let sender = sender_for(&socket, true);
752 let answer = std::thread::spawn(move || {
753 let mut buf = [0u8; 64];
754 let (len, from) = overlay.recv_from(&mut buf).unwrap();
755 assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
756 let via = if spoofed { &impostor } else { &overlay };
757 via.send_to_addr(reply, &from).unwrap();
758 let len = overlay.recv(&mut buf).unwrap();
759 assert_eq!(&buf[..len], br#"{"capture":"end"}"#);
760 });
761 let started = Instant::now();
762 let hold = sender.hold_for_capture().await;
763 let took = started.elapsed();
764 let result = hold.map(|hold| {
765 assert!(capture_held(&socket));
766 drop(hold);
767 took
768 });
769 assert!(!capture_held(&socket));
770 answer.join().unwrap();
771 let _ = std::fs::remove_dir_all(&dir);
772 result
773 }
774
775 #[tokio::test]
776 async fn captures_wait_only_while_the_overlay_was_on_screen() {
777 assert!(
778 hold_with_reply("empty", CAPTURE_REPLY_EMPTY, false)
779 .await
780 .unwrap()
781 < HIDE_SETTLE
782 );
783 assert!(
784 hold_with_reply("shown", CAPTURE_REPLY_SHOWN, false)
785 .await
786 .unwrap()
787 >= HIDE_SETTLE
788 );
789 assert!(hold_with_reply("spoofed", CAPTURE_REPLY_EMPTY, true)
791 .await
792 .is_err());
793 assert!(hold_with_reply("invalid", b"garbage", false).await.is_err());
794 }
795
796 #[tokio::test]
797 async fn captures_hold_the_lock_without_an_overlay() {
798 let dir = scratch_dir("none");
800 let socket = dir.join(SOCKET_NAME);
801 let sender = sender_for(&socket, true);
802 let started = Instant::now();
803 let hold = sender.hold_for_capture().await.unwrap();
804 assert!(started.elapsed() < HIDE_SETTLE);
805 assert!(capture_held(&socket));
806 drop(hold);
807 assert!(!capture_held(&socket));
808 let _ = std::fs::remove_dir_all(&dir);
809 }
810
811 #[tokio::test]
812 async fn capture_release_unlocks_an_inherited_descriptor() {
813 let dir = scratch_dir("inherited-lock");
814 let socket = dir.join(SOCKET_NAME);
815 let sender = sender_for(&socket, true);
816 let hold = sender.hold_for_capture().await.unwrap();
817 let _inherited = hold.lock.as_ref().unwrap().try_clone().unwrap();
818 assert!(capture_held(&socket));
819 drop(hold);
820 assert!(!capture_held(&socket));
821 let _ = std::fs::remove_dir_all(&dir);
822 }
823
824 #[tokio::test]
825 async fn captures_fail_while_a_stalled_overlay_cannot_be_told_to_hide() {
826 let dir = scratch_dir("stalled");
827 let socket = dir.join(SOCKET_NAME);
828 let _overlay = UnixDatagram::bind(&socket).unwrap();
829 let sender = sender_for(&socket, true);
830 let socket_sender = sender.socket.as_ref().unwrap();
832 let deadline = Instant::now() + Duration::from_secs(2);
833 loop {
834 assert!(Instant::now() < deadline, "sender never became blocked");
835 match socket_sender.send_to(b"{}", &socket) {
836 Ok(_) => {}
837 Err(error) => {
838 assert_eq!(error.kind(), ErrorKind::WouldBlock);
839 break;
840 }
841 }
842 }
843 let error = sender.hold_for_capture().await.err().unwrap();
844 assert!(error.to_string().contains("could not hide"), "{error}");
845 assert!(!capture_held(&socket));
846 let _ = std::fs::remove_dir_all(&dir);
847 }
848
849 #[tokio::test]
850 async fn captures_fail_when_a_running_overlay_does_not_reply() {
851 let dir = scratch_dir("silent");
852 let socket = dir.join(SOCKET_NAME);
853 let overlay = UnixDatagram::bind(&socket).unwrap();
854 overlay.set_nonblocking(true).unwrap();
855 let sender = sender_for(&socket, true);
856 let error = sender.hold_for_capture().await.err().unwrap();
857 assert!(error.to_string().contains("did not acknowledge"), "{error}");
858 let mut buf = [0u8; 64];
859 let len = overlay.recv(&mut buf).unwrap();
860 assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
861 assert!(!capture_held(&socket));
862 let _ = std::fs::remove_dir_all(&dir);
863 }
864
865 #[tokio::test]
866 async fn set_values_are_masked_even_when_the_cached_role_was_public() {
867 let dir = scratch_dir("set-value-mask");
868 let socket = dir.join(SOCKET_NAME);
869 let overlay = UnixDatagram::bind(&socket).unwrap();
870 overlay
871 .set_read_timeout(Some(Duration::from_secs(2)))
872 .unwrap();
873 let sender = sender_for(&socket, true);
874 sender.text("set_value", "secret", false).await;
875 let mut buf = [0u8; 512];
876 let len = overlay.recv(&mut buf).unwrap();
877 let event: IndicatorEvent = serde_json::from_slice(&buf[..len]).unwrap();
878 assert_eq!(event.text.as_deref(), Some("••••••••"));
879 let _ = std::fs::remove_dir_all(&dir);
880 }
881
882 #[tokio::test]
883 async fn keycaps_mask_secret_focus_and_the_hide_text_setting() {
884 let dir = scratch_dir("key-mask");
885 let socket = dir.join(SOCKET_NAME);
886 let overlay = UnixDatagram::bind(&socket).unwrap();
887 overlay
888 .set_read_timeout(Some(Duration::from_secs(2)))
889 .unwrap();
890 let mut sender = sender_for(&socket, true);
891 for (secret, hide_text, expected) in [
892 (true, false, vec!["••••••••"]),
893 (false, true, vec!["••••••••"]),
894 (false, false, vec!["Shift", "a"]),
895 ] {
896 sender.mask_text = hide_text;
897 sender.keys("Shift+a", secret).await;
898 let mut buf = [0u8; 512];
899 let len = overlay.recv(&mut buf).unwrap();
900 let event: IndicatorEvent = serde_json::from_slice(&buf[..len]).unwrap();
901 assert_eq!(event.keys, expected);
902 }
903 let _ = std::fs::remove_dir_all(&dir);
904 }
905
906 #[tokio::test]
907 async fn captures_fail_when_the_capture_lock_cannot_be_taken() {
908 let dir = scratch_dir("unavailable-lock");
909 let socket = dir.join(SOCKET_NAME);
910 let lock_path = capture_lock_path(&socket);
911 std::fs::create_dir(&lock_path).unwrap();
912 let sender = sender_for(&socket, true);
913 assert!(sender.hold_for_capture().await.is_err());
914 std::fs::remove_dir(&lock_path).unwrap();
915 let lock = File::create(&lock_path).unwrap();
916 lock.try_lock().unwrap();
917 assert!(sender.hold_for_capture().await.is_err());
918 let _ = std::fs::remove_dir_all(&dir);
919 }
920
921 #[tokio::test]
922 async fn captures_fail_without_a_client_socket_in_a_live_runtime() {
923 let dir = scratch_dir("no-client-socket");
924 let socket = dir.join(SOCKET_NAME);
925 let mut sender = sender_for(&socket, true);
926 sender.socket = None;
927 assert!(sender.hold_for_capture().await.is_err());
928 assert!(!capture_held(&socket));
929 let _ = std::fs::remove_dir_all(&dir);
930 }
931
932 #[tokio::test]
933 async fn concurrent_captures_do_not_consume_each_others_replies() {
934 let dir = scratch_dir("concurrent");
935 let socket = dir.join(SOCKET_NAME);
936 let overlay = UnixDatagram::bind(&socket).unwrap();
937 overlay
938 .set_read_timeout(Some(Duration::from_secs(2)))
939 .unwrap();
940 let sender = sender_for(&socket, true);
941 let (ack_tx, ack_rx) = std::sync::mpsc::channel();
942 let answer = std::thread::spawn(move || {
943 let mut buf = [0u8; 64];
944 for n in 0..2 {
945 let (len, from) = overlay.recv_from(&mut buf).unwrap();
946 assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
947 overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
948 if n == 0 {
949 ack_tx.send(()).unwrap();
950 }
951 }
952 });
953 let mut first = Box::pin(sender.hold_for_capture());
954 assert!(futures_util::poll!(first.as_mut()).is_pending());
955 ack_rx.recv_timeout(Duration::from_secs(2)).unwrap();
956 let (second, first) = tokio::join!(biased; sender.hold_for_capture(), first);
958 let (first, second) = (first.unwrap(), second.unwrap());
959 assert!(capture_held(&socket));
960 drop(first);
961 assert!(capture_held(&socket), "the second capture is still running");
962 drop(second);
963 assert!(!capture_held(&socket));
964 answer.join().unwrap();
965 let _ = std::fs::remove_dir_all(&dir);
966 }
967
968 #[tokio::test]
969 async fn disabled_servers_still_hold_captures() {
970 let dir = scratch_dir("disabled");
971 let socket = dir.join(SOCKET_NAME);
972 let overlay = UnixDatagram::bind(&socket).unwrap();
973 overlay.set_nonblocking(true).unwrap();
974 let sender = sender_for(&socket, false);
975 let hold = sender.hold_for_capture();
976 let acknowledge = async {
977 let mut buf = [0u8; 64];
978 let deadline = Instant::now() + Duration::from_secs(2);
979 loop {
980 assert!(Instant::now() < deadline, "capture begin was not received");
981 match overlay.recv_from(&mut buf) {
982 Ok((len, from)) => {
983 assert_eq!(&buf[..len], br#"{"capture":"begin"}"#);
984 overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
985 break;
986 }
987 Err(error) if error.kind() == ErrorKind::WouldBlock => {
988 tokio::time::sleep(Duration::from_millis(1)).await
989 }
990 Err(error) => panic!("{error}"),
991 }
992 }
993 };
994 let (hold, ()) = tokio::join!(hold, acknowledge);
995 let hold = hold.unwrap();
996 assert!(capture_held(&socket));
997 let mut buf = [0u8; 64];
998 sender.action("click").await;
1000 assert!(overlay.recv(&mut buf).is_err());
1001 drop(hold);
1002 let len = overlay.recv(&mut buf).unwrap();
1003 assert_eq!(&buf[..len], br#"{"capture":"end"}"#);
1004 assert!(!capture_held(&socket));
1005 let _ = std::fs::remove_dir_all(&dir);
1006 }
1007
1008 #[test]
1009 fn overlays_can_answer_the_client_socket() {
1010 let dir = scratch_dir("answer");
1011 let overlay_path = dir.join(SOCKET_NAME);
1012 let overlay = UnixDatagram::bind(&overlay_path).unwrap();
1013 let client = client_socket().unwrap();
1014 client.send_to(b"ping", &overlay_path).unwrap();
1015 let mut buf = [0u8; 64];
1016 let (_, from) = overlay.recv_from(&mut buf).unwrap();
1017 overlay.send_to_addr(CAPTURE_REPLY_EMPTY, &from).unwrap();
1018 let len = client.recv(&mut buf).unwrap();
1019 assert_eq!(&buf[..len], CAPTURE_REPLY_EMPTY);
1020 let _ = std::fs::remove_dir_all(&dir);
1021 }
1022
1023 #[test]
1024 fn opt_out_values() {
1025 for value in ["0", "false", "OFF", "no"] {
1026 assert!(disabled_value(Some(value)), "{value}");
1027 }
1028 for value in [None, Some("1"), Some("")] {
1029 assert!(!disabled_value(value), "{value:?}");
1030 }
1031 }
1032
1033 #[test]
1034 fn text_is_trimmed_to_its_tail_and_masked() {
1035 let long = "a".repeat(80) + "end";
1036 assert_eq!(shown_text(&long, false).chars().count(), TEXT_LIMIT);
1037 assert!(shown_text(&long, false).ends_with("end"));
1038 assert_eq!(shown_text("пароль", true), shown_text("x", true));
1039 assert!(!shown_text("пароль", true).contains("пароль"));
1040 }
1041
1042 #[test]
1043 fn events_serialize_compactly() {
1044 let event = IndicatorEvent {
1045 agent: "Claude".into(),
1046 tool: "click".into(),
1047 x: Some(10),
1048 y: Some(20),
1049 ..Default::default()
1050 };
1051 let json = serde_json::to_string(&event).unwrap();
1052 assert_eq!(json, r#"{"agent":"Claude","tool":"click","x":10,"y":20}"#);
1053 assert_eq!(
1054 serde_json::from_str::<IndicatorEvent>(&json).unwrap(),
1055 event
1056 );
1057 let scaled = IndicatorEvent {
1058 space: Some((3840, 2160)),
1059 ..event
1060 };
1061 let json = serde_json::to_string(&scaled).unwrap();
1062 assert!(json.ends_with(r#""space":[3840,2160]}"#), "{json}");
1063 assert_eq!(
1064 serde_json::from_str::<IndicatorEvent>(&json).unwrap(),
1065 scaled
1066 );
1067 let begin = serde_json::to_string(&IndicatorEvent {
1068 capture: Some(Capture::Begin),
1069 ..Default::default()
1070 });
1071 assert_eq!(begin.unwrap(), r#"{"capture":"begin"}"#);
1072 }
1073}