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::{
36 model::{Implementation, RequestMetaObject},
37 Peer, RoleServer,
38};
39use serde::{Deserialize, Serialize};
40
41pub const INDICATOR_BINARY: &str = "computer-use-linux-indicator";
43pub const SOCKET_NAME: &str = "computer-use-linux-indicator.sock";
45pub const CAPTURE_REPLY_SHOWN: &[u8] = b"shown";
47pub 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
55pub const GLIDE: Duration = Duration::from_millis(350);
58pub const HIDE_SETTLE: Duration = Duration::from_millis(120);
60const CAPTURE_REPLY_WAIT: Duration = Duration::from_millis(300);
63const SPAWN_WAIT: Duration = Duration::from_secs(1);
64const TEXT_LIMIT: usize = 64;
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum Capture {
70 Begin,
71 End,
72}
73
74#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
76pub struct IndicatorEvent {
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub capture: Option<Capture>,
80 #[serde(default, skip_serializing_if = "String::is_empty")]
82 pub agent: String,
83 #[serde(default, skip_serializing_if = "String::is_empty")]
85 pub tool: String,
86 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub space: Option<(u32, u32)>,
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub keys: Vec<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub text: Option<String>,
102}
103
104pub 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
111pub fn capture_lock_path(socket: &Path) -> PathBuf {
113 socket.with_extension("capture")
114}
115
116pub 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
124pub 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
138pub 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
144pub 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 let runtime_bus = env::var_os("XDG_RUNTIME_DIR")
166 .map(PathBuf::from)
167 .unwrap_or_else(|| {
168 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
206pub 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 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
244fn 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 Unavailable,
263}
264
265#[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 path: Option<PathBuf>,
276 overlay: Mutex<Overlay>,
277 capture_handshake: tokio::sync::Mutex<()>,
279}
280
281impl Default for Indicator {
282 fn default() -> Self {
283 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 static REQUEST_AGENT: String;
307}
308
309pub(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
320pub(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 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 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 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 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 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 pub(crate) async fn text(&self, tool: &str, text: &str, secret: bool) {
427 let event = IndicatorEvent {
428 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 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 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 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 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 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 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 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 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
608pub(crate) struct CaptureHold<'a> {
611 indicator: &'a Indicator,
612 lock: Option<File>,
613}
614
615impl Drop for CaptureHold<'_> {
616 fn drop(&mut self) {
617 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
629fn 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
644pub(crate) fn is_secret_role(role: &str) -> bool {
648 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 .process_group(0)
680 .spawn()?;
681 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 assert_eq!(request_agent_name(&stateless, placeholder.clone()), None);
737 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 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 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 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 assert!(!is_secret_role("entry"));
788 }
789
790 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 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 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 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 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 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 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 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}