Skip to main content

computer_use/
app.rs

1use std::path::PathBuf;
2
3/// An application that is running or installed.
4#[derive(Debug, Clone, PartialEq, Eq, Default)]
5#[non_exhaustive]
6pub struct AppInfo {
7    /// Human-readable name, e.g. `TextEdit`.
8    pub name: String,
9    /// Platform identifier: a bundle ID on macOS, an AUMID or executable name on Windows,
10    /// a desktop-file ID on Linux.
11    pub bundle_id: Option<String>,
12    /// Process ID, when running.
13    pub pid: Option<u32>,
14    /// Path to the app bundle or executable.
15    pub path: Option<PathBuf>,
16    /// Whether the app currently has a process.
17    pub is_running: bool,
18    /// Whether the app is the frontmost (focused) app.
19    pub is_active: bool,
20    /// Title of the app's main window, when the platform reports one. Queries also match it.
21    pub window_title: Option<String>,
22    /// The app is running but does not expose its UI to accessibility, so it cannot be
23    /// inspected or controlled until it is quit and started again.
24    ///
25    /// Only set on Linux, for browser-engine apps started before desktop accessibility was
26    /// turned on.
27    pub needs_restart: bool,
28}
29
30impl AppInfo {
31    /// Creates a record for an app with only a name; fill the rest with the builder methods.
32    #[must_use]
33    pub fn new(name: impl Into<String>) -> Self {
34        Self { name: name.into(), ..Self::default() }
35    }
36
37    /// Sets the platform identifier.
38    #[must_use]
39    pub fn with_bundle_id(mut self, bundle_id: impl Into<String>) -> Self {
40        self.bundle_id = Some(bundle_id.into());
41        self
42    }
43
44    /// Marks the app as running with `pid`.
45    #[must_use]
46    pub const fn with_pid(mut self, pid: u32) -> Self {
47        self.pid = Some(pid);
48        self.is_running = true;
49        self
50    }
51
52    /// Sets the bundle or executable path.
53    #[must_use]
54    pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
55        self.path = Some(path.into());
56        self
57    }
58
59    /// Marks the app as frontmost.
60    #[must_use]
61    pub const fn with_active(mut self, active: bool) -> Self {
62        self.is_active = active;
63        self
64    }
65
66    /// Marks the app as running without an accessibility tree (see [`AppInfo::needs_restart`]).
67    #[must_use]
68    pub const fn with_needs_restart(mut self, needs_restart: bool) -> Self {
69        self.needs_restart = needs_restart;
70        self
71    }
72
73    /// Sets the main window title.
74    #[must_use]
75    pub fn with_window_title(mut self, title: impl Into<String>) -> Self {
76        self.window_title = Some(title.into()).filter(|t: &String| !t.is_empty());
77        self
78    }
79
80    /// The identifier shown to callers: bundle ID when known, otherwise the name.
81    #[must_use]
82    pub fn reference(&self) -> &str {
83        self.bundle_id.as_deref().unwrap_or(&self.name)
84    }
85
86    /// How well this app matches a user query; `None` means no match.
87    ///
88    /// Queries may be a name, bundle ID, path, or `pid:<n>`. Lower scores are better.
89    #[must_use]
90    pub fn match_score(&self, query: &str) -> Option<u8> {
91        let query = query.trim();
92        if query.is_empty() {
93            return None;
94        }
95        if let Some(pid) = query.strip_prefix("pid:") {
96            return (pid.trim().parse::<u32>().ok() == self.pid && self.pid.is_some()).then_some(0);
97        }
98        let q = query.to_lowercase();
99        let q_stem = q.strip_suffix(".app").or_else(|| q.strip_suffix(".exe")).unwrap_or(&q);
100        let name = self.name.to_lowercase();
101        let bundle = self.bundle_id.as_deref().map(str::to_lowercase);
102
103        if name == q || name == q_stem || bundle.as_deref() == Some(q.as_str()) {
104            return Some(0);
105        }
106        if let Some(path) = &self.path {
107            let path_str = path.to_string_lossy().to_lowercase();
108            if path_str == q {
109                return Some(1);
110            }
111            let stem = path.file_stem().map(|s| s.to_string_lossy().to_lowercase());
112            if stem.as_deref() == Some(q_stem) {
113                return Some(2);
114            }
115        }
116        if bundle.as_deref().and_then(|b| b.rsplit('.').next()).is_some_and(|last| last == q_stem) {
117            return Some(3);
118        }
119        if name.starts_with(q_stem) || name.split_whitespace().any(|word| word == q_stem) {
120            return Some(4);
121        }
122        let title = self.window_title.as_deref().map(str::to_lowercase);
123        if title.as_deref() == Some(q.as_str()) {
124            return Some(5);
125        }
126        if title.is_some_and(|t| t.contains(q.as_str())) {
127            return Some(6);
128        }
129        None
130    }
131}
132
133pub(crate) fn best_match<'a>(apps: &'a [AppInfo], query: &str) -> Option<&'a AppInfo> {
134    apps.iter()
135        .filter_map(|app| app.match_score(query).map(|score| (score, !app.is_running, app)))
136        .min_by_key(|(score, not_running, _)| (*score, *not_running))
137        .map(|(_, _, app)| app)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn apps() -> Vec<AppInfo> {
145        vec![
146            AppInfo::new("TextEdit")
147                .with_bundle_id("com.apple.TextEdit")
148                .with_path("/System/Applications/TextEdit.app"),
149            AppInfo::new("TextEdit").with_bundle_id("com.apple.TextEdit").with_pid(42),
150            AppInfo::new("Safari").with_bundle_id("com.apple.Safari").with_pid(7),
151        ]
152    }
153
154    #[test]
155    fn matches_name_bundle_and_pid() {
156        let apps = apps();
157        assert_eq!(best_match(&apps, "textedit").and_then(|a| a.pid), Some(42));
158        assert_eq!(best_match(&apps, "com.apple.Safari").map(|a| a.name.as_str()), Some("Safari"));
159        assert_eq!(best_match(&apps, "pid:7").map(|a| a.name.as_str()), Some("Safari"));
160        assert_eq!(best_match(&apps, "Safari.app").map(|a| a.name.as_str()), Some("Safari"));
161        assert!(best_match(&apps, "Xcode").is_none());
162    }
163
164    #[test]
165    fn matches_window_titles_last() {
166        let apps = vec![
167            AppInfo::new("notepad").with_pid(1).with_window_title("todo.txt - Notepad"),
168            AppInfo::new("todo").with_pid(2),
169        ];
170        assert_eq!(best_match(&apps, "todo.txt - notepad").and_then(|a| a.pid), Some(1));
171        assert_eq!(best_match(&apps, "todo").and_then(|a| a.pid), Some(2));
172        assert_eq!(best_match(&apps, "todo.txt").and_then(|a| a.pid), Some(1));
173    }
174
175    #[test]
176    fn prefix_match_is_weaker_than_exact() {
177        let apps = vec![AppInfo::new("Notes Helper").with_pid(1), AppInfo::new("Notes").with_pid(2)];
178        assert_eq!(best_match(&apps, "notes").and_then(|a| a.pid), Some(2));
179    }
180}