1use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::ComputerError;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum AppSelector {
15 Pid(i32),
17 BundleId(String),
19 Name(String),
21 Path(PathBuf),
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum WindowSelector {
31 Index(u32),
33 Id(u32),
35 Title(String),
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
43#[serde(rename_all = "snake_case")]
44pub enum ActivationPolicy {
45 #[default]
47 Regular,
48 Accessory,
50 Prohibited,
52 Unknown,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct AppInfo {
59 pub name: String,
61 pub bundle_id: Option<String>,
63 pub pid: i32,
65 pub path: Option<String>,
67 pub active: bool,
69 pub hidden: bool,
71 #[serde(default, skip_serializing)]
73 pub activation_policy: ActivationPolicy,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78pub struct WindowBounds {
79 pub x: i32,
81 pub y: i32,
83 pub width: u32,
85 pub height: u32,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct WindowInfo {
92 pub id: u32,
94 pub pid: i32,
96 pub app: String,
98 pub title: String,
100 pub z: u32,
102 pub bounds: WindowBounds,
104 pub layer: i32,
106 pub on_screen: bool,
108 pub ax_matched: bool,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ListedWindow {
115 pub id: u32,
117 pub pid: i32,
119 pub app: String,
121 pub title: String,
123 pub bounds: WindowBounds,
125 pub layer: i32,
127 pub on_screen: bool,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
133pub struct WindowList {
134 pub windows: Vec<WindowInfo>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub note: Option<String>,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct ResolvedTarget {
144 pub pid: i32,
146 pub bundle_id: Option<String>,
148 pub display_name: String,
150 pub exe_path: Option<PathBuf>,
152 pub window_id: Option<u32>,
154 pub window_title: Option<String>,
156 pub bounds: Option<WindowBounds>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct LaunchResult {
163 pub name: String,
165 pub bundle_id: Option<String>,
167 pub pid: i32,
169 pub path: Option<String>,
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub window_id: Option<u32>,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub window_title: Option<String>,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
181pub struct QuitResult {
182 pub name: String,
184 pub bundle_id: Option<String>,
186 pub pid: i32,
188 pub accepted: bool,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
194pub struct ResponsibleApp {
195 pub name: String,
197 pub bundle_id: Option<String>,
199 pub pid: i32,
201 pub path: Option<String>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
207pub struct PermissionFix {
208 pub permission: String,
210 pub url: String,
212 pub target: String,
214 pub instruction: String,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
220pub struct DoctorIdentity {
221 pub path: Option<String>,
223 pub bundle_id: Option<String>,
225 pub bundled: bool,
227 pub signature: Option<String>,
229 pub team_id: Option<String>,
231 pub responsible: Option<ResponsibleApp>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
240pub struct DoctorReport {
241 pub permissions_ok: bool,
243 pub missing: Vec<String>,
245 pub accessibility: Option<bool>,
247 pub screen_capture: Option<bool>,
249 pub screen_recording: Option<bool>,
251 pub post_events: Option<bool>,
253 pub input_monitoring: Option<bool>,
255 pub input_monitor: Option<bool>,
257 pub bundle_id: Option<String>,
259 pub identity: DoctorIdentity,
261 #[serde(skip_serializing_if = "Option::is_none")]
263 pub responsible_app: Option<ResponsibleApp>,
264 pub fix: Vec<PermissionFix>,
266 pub fixes: Vec<String>,
268 pub session: Option<String>,
270 pub atspi_bus: Option<bool>,
272 pub portal: Option<bool>,
275 pub uiaccess: Option<bool>,
277 pub integrity: Option<String>,
279 pub text: String,
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub note: Option<String>,
284}
285
286impl DoctorIdentity {
287 pub(crate) fn blank() -> Self {
288 Self {
289 path: None,
290 bundle_id: None,
291 bundled: false,
292 signature: None,
293 team_id: None,
294 responsible: None,
295 }
296 }
297}
298
299impl DoctorReport {
300 pub(crate) fn blank() -> Self {
301 Self {
302 permissions_ok: true,
303 missing: Vec::new(),
304 accessibility: None,
305 screen_capture: None,
306 screen_recording: None,
307 post_events: None,
308 input_monitoring: None,
309 input_monitor: None,
310 bundle_id: None,
311 identity: DoctorIdentity::blank(),
312 responsible_app: None,
313 fix: Vec::new(),
314 fixes: Vec::new(),
315 session: None,
316 atspi_bus: None,
317 portal: None,
318 uiaccess: None,
319 integrity: None,
320 text: String::new(),
321 note: None,
322 }
323 }
324
325 pub fn pending(phase: &str) -> Self {
327 let mut report = Self::blank();
328 let note = format!("accessibility and screen-capture probes land in {phase}");
329 report.text = note.clone();
330 report.note = Some(note);
331 report
332 }
333}
334
335pub fn parse_app_selector(text: &str) -> Result<AppSelector, ComputerError> {
337 let text = text.trim();
338 if text.is_empty() {
339 return Err(ComputerError::InvalidArguments(
340 "app selector is empty".into(),
341 ));
342 }
343 if looks_like_path(text) {
344 return Ok(AppSelector::Path(PathBuf::from(expand_tilde(text))));
345 }
346 if text.chars().all(|ch| ch.is_ascii_digit()) {
347 let pid = text
348 .parse::<i32>()
349 .map_err(|_| ComputerError::InvalidArguments(format!("pid is out of range: {text}")))?;
350 return Ok(AppSelector::Pid(pid));
351 }
352 if is_bundle_id(text) {
353 return Ok(AppSelector::BundleId(text.to_string()));
354 }
355 Ok(AppSelector::Name(text.to_string()))
356}
357
358pub fn parse_window_selector(text: &str) -> Result<WindowSelector, ComputerError> {
360 let text = text.trim();
361 if text.is_empty() {
362 return Err(ComputerError::InvalidArguments(
363 "window selector is empty".into(),
364 ));
365 }
366 if let Some(rest) = text
367 .strip_prefix("id:")
368 .or_else(|| text.strip_prefix("ID:"))
369 {
370 return Ok(WindowSelector::Id(parse_u32(rest, "window id")?));
371 }
372 if let Some(rest) = text
373 .strip_prefix("index:")
374 .or_else(|| text.strip_prefix("INDEX:"))
375 {
376 return Ok(WindowSelector::Index(parse_u32(rest, "window index")?));
377 }
378 if text.chars().all(|ch| ch.is_ascii_digit()) {
379 return Ok(WindowSelector::Index(parse_u32(text, "window index")?));
380 }
381 Ok(WindowSelector::Title(text.to_string()))
382}
383
384pub fn bounds_match_frame(bounds: WindowBounds, frame: crate::tree::NodeFrame) -> bool {
386 (f64::from(bounds.x) - frame.x).abs() <= 2.0
387 && (f64::from(bounds.y) - frame.y).abs() <= 2.0
388 && (f64::from(bounds.width) - frame.width).abs() <= 2.0
389 && (f64::from(bounds.height) - frame.height).abs() <= 2.0
390}
391
392pub fn assign_z_order(entries: Vec<ListedWindow>) -> Vec<WindowInfo> {
394 entries
395 .into_iter()
396 .enumerate()
397 .map(|(z, entry)| WindowInfo {
398 id: entry.id,
399 pid: entry.pid,
400 app: entry.app,
401 title: entry.title,
402 z: z as u32,
403 bounds: entry.bounds,
404 layer: entry.layer,
405 on_screen: entry.on_screen,
406 ax_matched: false,
407 })
408 .collect()
409}
410
411pub fn filter_windows_for_pid(windows: &[WindowInfo], pid: i32) -> Vec<WindowInfo> {
413 windows
414 .iter()
415 .filter(|window| window.pid == pid)
416 .enumerate()
417 .map(|(z, window)| {
418 let mut copy = window.clone();
419 copy.z = z as u32;
420 copy
421 })
422 .collect()
423}
424
425pub fn resolve_app<'a>(
427 apps: &'a [AppInfo],
428 selector: &AppSelector,
429) -> Result<&'a AppInfo, ComputerError> {
430 if let AppSelector::Name(name) = selector {
431 let exact: Vec<&AppInfo> = apps
432 .iter()
433 .filter(|app| app.name.eq_ignore_ascii_case(name))
434 .collect();
435 if !exact.is_empty() {
436 return pick_one(&exact, name);
437 }
438 }
439 let matches: Vec<&AppInfo> = apps
440 .iter()
441 .filter(|app| app_matches(app, selector))
442 .collect();
443 if matches.is_empty() {
444 return Err(ComputerError::AppNotFound(selector_label(selector)));
445 }
446 pick_one(&matches, &selector_label(selector))
447}
448
449pub fn resolve_window<'a>(
451 windows: &'a [WindowInfo],
452 selector: &WindowSelector,
453) -> Result<&'a WindowInfo, ComputerError> {
454 let found = match selector {
455 WindowSelector::Index(index) => windows.iter().find(|window| window.z == *index),
456 WindowSelector::Id(id) => windows.iter().find(|window| window.id == *id),
457 WindowSelector::Title(title) => {
458 let exact = windows
459 .iter()
460 .find(|window| window.title.eq_ignore_ascii_case(title));
461 if exact.is_some() {
462 exact
463 } else {
464 let needle = title.to_ascii_lowercase();
465 windows
466 .iter()
467 .find(|window| window.title.to_ascii_lowercase().contains(&needle))
468 }
469 }
470 };
471 found.ok_or_else(|| ComputerError::WindowNotFound(window_label(selector)))
472}
473
474pub fn resolve(
476 apps: &[AppInfo],
477 windows: &[WindowInfo],
478 app: &AppSelector,
479 window: Option<&WindowSelector>,
480) -> Result<ResolvedTarget, ComputerError> {
481 let app = resolve_app(apps, app)?;
482 let owned = filter_windows_for_pid(windows, app.pid);
483 let chosen = match window {
484 Some(selector) => Some(resolve_window(&owned, selector)?.clone()),
485 None => None,
486 };
487 Ok(ResolvedTarget {
488 pid: app.pid,
489 bundle_id: app.bundle_id.clone(),
490 display_name: app.name.clone(),
491 exe_path: app.path.as_ref().map(PathBuf::from),
492 window_id: chosen.as_ref().map(|item| item.id),
493 window_title: chosen.as_ref().map(|item| item.title.clone()),
494 bounds: chosen.as_ref().map(|item| item.bounds),
495 })
496}
497
498pub fn canonical_exe_path(path: &Path) -> PathBuf {
500 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
501}
502
503fn pick_one<'a>(matches: &[&'a AppInfo], label: &str) -> Result<&'a AppInfo, ComputerError> {
504 let regular: Vec<&AppInfo> = matches
505 .iter()
506 .copied()
507 .filter(|app| app.activation_policy == ActivationPolicy::Regular)
508 .collect();
509 let pool: &[&AppInfo] = if regular.is_empty() {
510 matches
511 } else {
512 ®ular
513 };
514 if pool.len() == 1 {
515 return Ok(pool[0]);
516 }
517 let active: Vec<&AppInfo> = pool.iter().copied().filter(|app| app.active).collect();
518 if active.len() == 1 {
519 return Ok(active[0]);
520 }
521 let pids: Vec<String> = pool.iter().map(|app| app.pid.to_string()).collect();
522 Err(ComputerError::InvalidArguments(format!(
523 "app selector {label:?} matched {} processes ({})",
524 pool.len(),
525 pids.join(", ")
526 )))
527}
528
529fn app_matches(app: &AppInfo, selector: &AppSelector) -> bool {
530 match selector {
531 AppSelector::Pid(pid) => app.pid == *pid,
532 AppSelector::BundleId(bundle_id) => app
533 .bundle_id
534 .as_deref()
535 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(bundle_id)),
536 AppSelector::Name(name) => {
537 app.name.eq_ignore_ascii_case(name) || path_names_app(app.path.as_deref(), name)
538 }
539 AppSelector::Path(path) => paths_match(app.path.as_deref(), path),
540 }
541}
542
543fn path_names_app(path: Option<&str>, name: &str) -> bool {
544 let Some(path) = path else {
545 return false;
546 };
547 let wanted_app = format!("{name}.app");
548 Path::new(path).components().any(|component| {
549 let text = component.as_os_str().to_string_lossy();
550 text.eq_ignore_ascii_case(&wanted_app)
551 || text.eq_ignore_ascii_case(name)
552 && Path::new(path)
553 .file_name()
554 .and_then(|file| file.to_str())
555 .is_some_and(|file| file.eq_ignore_ascii_case(name))
556 })
557}
558
559fn paths_match(stored: Option<&str>, wanted: &Path) -> bool {
560 let Some(stored) = stored else {
561 return false;
562 };
563 let stored_path = Path::new(stored);
564 stored_path == wanted || canonical_exe_path(stored_path) == canonical_exe_path(wanted)
565}
566
567fn looks_like_path(text: &str) -> bool {
568 text.contains('/')
569 || text.contains('\\')
570 || text.to_ascii_lowercase().ends_with(".app")
571 || text.to_ascii_lowercase().ends_with(".exe")
572}
573
574fn is_bundle_id(text: &str) -> bool {
575 let mut labels = text.split('.');
576 let Some(first) = labels.next() else {
577 return false;
578 };
579 if !is_bundle_label(first) {
580 return false;
581 }
582 let mut count = 1;
583 for label in labels {
584 if !is_bundle_label(label) {
585 return false;
586 }
587 count += 1;
588 }
589 count >= 2
590}
591
592fn is_bundle_label(text: &str) -> bool {
593 !text.is_empty()
594 && text
595 .chars()
596 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
597}
598
599fn expand_tilde(text: &str) -> String {
600 let Some(rest) = text.strip_prefix("~/") else {
601 return text.to_string();
602 };
603 let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else {
604 return text.to_string();
605 };
606 Path::new(&home).join(rest).to_string_lossy().into_owned()
607}
608
609fn parse_u32(text: &str, name: &str) -> Result<u32, ComputerError> {
610 text.parse::<u32>()
611 .map_err(|_| ComputerError::InvalidArguments(format!("{name} is not an integer: {text}")))
612}
613
614fn selector_label(selector: &AppSelector) -> String {
615 match selector {
616 AppSelector::Pid(pid) => pid.to_string(),
617 AppSelector::BundleId(bundle_id) => bundle_id.clone(),
618 AppSelector::Name(name) => name.clone(),
619 AppSelector::Path(path) => path.display().to_string(),
620 }
621}
622
623fn window_label(selector: &WindowSelector) -> String {
624 match selector {
625 WindowSelector::Index(index) => index.to_string(),
626 WindowSelector::Id(id) => format!("id:{id}"),
627 WindowSelector::Title(title) => title.clone(),
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 fn apps() -> Vec<AppInfo> {
636 vec![
637 AppInfo {
638 name: "Notes".into(),
639 bundle_id: Some("com.apple.Notes".into()),
640 pid: 10,
641 path: Some("/System/Applications/Notes.app/Contents/MacOS/Notes".into()),
642 active: true,
643 hidden: false,
644 activation_policy: ActivationPolicy::Regular,
645 },
646 AppInfo {
647 name: "Notes".into(),
648 bundle_id: Some("com.example.Notes".into()),
649 pid: 11,
650 path: Some("/Applications/Notes.app/Contents/MacOS/Notes".into()),
651 active: false,
652 hidden: false,
653 activation_policy: ActivationPolicy::Regular,
654 },
655 AppInfo {
656 name: r#"Foo "Bar""#.into(),
657 bundle_id: Some("dev.example.foo-bar".into()),
658 pid: 412,
659 path: Some(r#"/Applications/Foo "Bar".app/Contents/MacOS/Foo"#.into()),
660 active: false,
661 hidden: false,
662 activation_policy: ActivationPolicy::Regular,
663 },
664 AppInfo {
665 name: "Terminal".into(),
666 bundle_id: Some("com.apple.Terminal".into()),
667 pid: 20,
668 path: Some("/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal".into()),
669 active: false,
670 hidden: false,
671 activation_policy: ActivationPolicy::Regular,
672 },
673 ]
674 }
675
676 fn windows() -> Vec<WindowInfo> {
677 assign_z_order(vec![
678 ListedWindow {
679 id: 100,
680 pid: 10,
681 app: "Notes".into(),
682 title: "Shopping".into(),
683 bounds: WindowBounds {
684 x: 0,
685 y: 0,
686 width: 800,
687 height: 600,
688 },
689 layer: 0,
690 on_screen: true,
691 },
692 ListedWindow {
693 id: 200,
694 pid: 20,
695 app: "Terminal".into(),
696 title: "zsh".into(),
697 bounds: WindowBounds {
698 x: 10,
699 y: 10,
700 width: 400,
701 height: 200,
702 },
703 layer: 0,
704 on_screen: true,
705 },
706 ListedWindow {
707 id: 101,
708 pid: 10,
709 app: "Notes".into(),
710 title: "Hello".into(),
711 bounds: WindowBounds {
712 x: 20,
713 y: 30,
714 width: 100,
715 height: 50,
716 },
717 layer: 3,
718 on_screen: true,
719 },
720 ])
721 }
722
723 #[test]
724 fn parser_accepts_pid_bundle_path_and_a_name_with_a_quote() {
725 assert_eq!(parse_app_selector("412").unwrap(), AppSelector::Pid(412));
726 assert_eq!(
727 parse_app_selector("com.apple.TextEdit").unwrap(),
728 AppSelector::BundleId("com.apple.TextEdit".into())
729 );
730 assert_eq!(
731 parse_app_selector(r#"Foo "Bar""#).unwrap(),
732 AppSelector::Name(r#"Foo "Bar""#.into())
733 );
734 assert!(matches!(
735 parse_app_selector("/Applications/Notes.app").unwrap(),
736 AppSelector::Path(_)
737 ));
738 assert!(parse_app_selector(" ").is_err());
739 assert!(parse_app_selector("com..apple").is_err() || !is_bundle_id("com..apple"));
740 assert!(!is_bundle_id(r#"Foo "Bar""#));
741 }
742
743 #[test]
744 fn resolve_matches_quote_name_bundle_and_pid_against_a_fake_table() {
745 let apps = apps();
746 let quoted = parse_app_selector(r#"Foo "Bar""#).unwrap();
747 let resolved = resolve_app(&apps, "ed).unwrap();
748 assert_eq!(resolved.pid, 412);
749 assert_eq!(resolved.name, r#"Foo "Bar""#);
750
751 let bundle = parse_app_selector("com.apple.Terminal").unwrap();
752 assert_eq!(resolve_app(&apps, &bundle).unwrap().pid, 20);
753
754 let pid = parse_app_selector("10").unwrap();
755 assert_eq!(
756 resolve_app(&apps, &pid).unwrap().bundle_id.as_deref(),
757 Some("com.apple.Notes")
758 );
759
760 assert!(resolve_app(&apps, &AppSelector::Pid(999)).is_err());
761 }
762
763 #[test]
764 fn ambiguous_name_prefers_the_active_app() {
765 let apps = apps();
766 let notes = AppSelector::Name("Notes".into());
767 assert_eq!(resolve_app(&apps, ¬es).unwrap().pid, 10);
768
769 let mut both_inactive = apps.clone();
770 both_inactive[0].active = false;
771 let error = resolve_app(&both_inactive, ¬es).unwrap_err();
772 assert_eq!(error.code(), "invalid_arguments");
773 assert!(error.to_string().contains("10"));
774 }
775
776 #[test]
777 fn ambiguous_name_prefers_a_regular_app_over_a_helper() {
778 let mut table = apps();
779 table.push(AppInfo {
780 name: "Google Chrome".into(),
781 bundle_id: Some("com.google.Chrome".into()),
782 pid: 1,
783 path: None,
784 active: false,
785 hidden: false,
786 activation_policy: ActivationPolicy::Regular,
787 });
788 table.push(AppInfo {
789 name: "Google Chrome".into(),
790 bundle_id: Some("com.google.Chrome.helper".into()),
791 pid: 3,
792 path: None,
793 active: true,
794 hidden: false,
795 activation_policy: ActivationPolicy::Prohibited,
796 });
797 let resolved = resolve_app(&table, &AppSelector::Name("Google Chrome".into())).unwrap();
798 assert_eq!(resolved.pid, 1);
799 }
800
801 #[test]
802 fn bounds_match_allows_two_points() {
803 let bounds = WindowBounds {
804 x: 10,
805 y: 20,
806 width: 100,
807 height: 80,
808 };
809 assert!(bounds_match_frame(
810 bounds,
811 crate::tree::NodeFrame {
812 x: 12.0,
813 y: 18.0,
814 width: 101.0,
815 height: 80.0,
816 }
817 ));
818 assert!(!bounds_match_frame(
819 bounds,
820 crate::tree::NodeFrame {
821 x: 14.0,
822 y: 20.0,
823 width: 100.0,
824 height: 80.0,
825 }
826 ));
827 }
828
829 #[test]
830 fn z_order_is_the_array_index_and_filters_renumber() {
831 let listed = windows();
832 assert_eq!(listed[0].z, 0);
833 assert_eq!(listed[0].id, 100);
834 assert_eq!(listed[1].z, 1);
835 assert_eq!(listed[2].z, 2);
836 assert!(!listed[0].ax_matched);
837
838 let notes = filter_windows_for_pid(&listed, 10);
839 assert_eq!(notes.len(), 2);
840 assert_eq!(notes[0].z, 0);
841 assert_eq!(notes[0].id, 100);
842 assert_eq!(notes[1].z, 1);
843 assert_eq!(notes[1].id, 101);
844
845 let target = resolve(
846 &apps(),
847 &listed,
848 &AppSelector::Name("Notes".into()),
849 Some(&WindowSelector::Index(1)),
850 )
851 .unwrap();
852 assert_eq!(target.pid, 10);
853 assert_eq!(target.window_id, Some(101));
854 assert_eq!(target.window_title.as_deref(), Some("Hello"));
855 }
856
857 #[test]
858 fn window_selector_distinguishes_index_id_and_title() {
859 assert_eq!(
860 parse_window_selector("0").unwrap(),
861 WindowSelector::Index(0)
862 );
863 assert_eq!(
864 parse_window_selector("id:200").unwrap(),
865 WindowSelector::Id(200)
866 );
867 assert_eq!(
868 parse_window_selector("index:2").unwrap(),
869 WindowSelector::Index(2)
870 );
871 let listed = windows();
872 assert_eq!(
873 resolve_window(&listed, &WindowSelector::Id(200))
874 .unwrap()
875 .title,
876 "zsh"
877 );
878 assert_eq!(
879 resolve_window(&listed, &WindowSelector::Title("hello".into()))
880 .unwrap()
881 .id,
882 101
883 );
884 assert!(resolve_window(&listed, &WindowSelector::Title("missing".into())).is_err());
885 assert!(parse_window_selector("id:nope").is_err());
886 }
887
888 #[cfg(unix)]
889 #[test]
890 fn canonical_exe_path_resolves_a_symlink() {
891 let dir = std::env::temp_dir().join(format!(
892 "computer-use-target-{}-{}",
893 std::process::id(),
894 std::time::SystemTime::now()
895 .duration_since(std::time::UNIX_EPOCH)
896 .map(|duration| duration.as_nanos())
897 .unwrap_or(0)
898 ));
899 let _ = std::fs::remove_dir_all(&dir);
900 std::fs::create_dir_all(&dir).unwrap();
901 let real = dir.join("Notes");
902 std::fs::write(&real, b"x").unwrap();
903 let link = dir.join("alias");
904 std::os::unix::fs::symlink(&real, &link).unwrap();
905 let canonical = canonical_exe_path(&link);
906 assert_eq!(
907 canonical.file_name().and_then(|name| name.to_str()),
908 Some("Notes")
909 );
910 let _ = std::fs::remove_dir_all(&dir);
911 }
912
913 #[test]
914 fn pending_doctor_names_the_phase() {
915 let report = DoctorReport::pending("P13");
916 assert!(report.accessibility.is_none());
917 assert!(report.screen_capture.is_none());
918 assert!(report.input_monitor.is_none());
919 assert!(report.note.unwrap().contains("P13"));
920 }
921}