Skip to main content

agentchrome/chrome/
platform.rs

1use std::path::PathBuf;
2
3use super::ChromeError;
4
5/// Chrome release channel.
6#[derive(Debug, Clone, Copy)]
7pub enum Channel {
8    Stable,
9    Canary,
10    Beta,
11    Dev,
12}
13
14/// Result of a process liveness probe.
15///
16/// Used to classify connection losses as `chrome_terminated` (definitively
17/// dead) versus `transient` (alive or unknown).
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ProbeResult {
20    /// The process exists and accepts signals from the current user.
21    Alive,
22    /// The process does not exist (`ESRCH` on Unix, missing in `tasklist` on Windows).
23    Dead,
24    /// We could not determine liveness (e.g. permission denied, OS error).
25    Unknown,
26}
27
28/// Probe whether a process with the given PID is currently alive.
29///
30/// On Unix, sends signal `0` via `libc::kill`, which performs an existence/permission
31/// check without actually delivering a signal. On Windows, shells out to `tasklist`.
32/// When the result is ambiguous we return [`ProbeResult::Unknown`], so callers can
33/// fall back to the conservative "transient" classification.
34#[must_use]
35pub fn is_process_alive(pid: u32) -> ProbeResult {
36    #[cfg(unix)]
37    {
38        // PID values fit in i32 on all supported platforms.
39        #[allow(clippy::cast_possible_wrap)]
40        let pid_i32 = pid as i32;
41        // SAFETY: signal 0 is the documented null-signal; it never delivers a
42        // signal, only validates existence + permission for the target PID.
43        let rc = unsafe { libc::kill(pid_i32, 0) };
44        if rc == 0 {
45            return ProbeResult::Alive;
46        }
47        // SAFETY: libc::__errno_location / __error are sound to read after a
48        // failing libc call. std::io::Error::last_os_error wraps that for us.
49        let err = std::io::Error::last_os_error();
50        match err.raw_os_error() {
51            Some(libc::ESRCH) => ProbeResult::Dead,
52            Some(libc::EPERM) => ProbeResult::Alive, // EPERM means the process exists
53            _ => ProbeResult::Unknown,
54        }
55    }
56    #[cfg(windows)]
57    {
58        let output = std::process::Command::new("tasklist")
59            .args(["/FI", &format!("PID eq {pid}"), "/NH"])
60            .output();
61        match output {
62            Ok(out) if out.status.success() => {
63                let stdout = String::from_utf8_lossy(&out.stdout);
64                // tasklist prints "INFO: No tasks are running..." when no match;
65                // otherwise the PID appears in the output.
66                if stdout.contains(&pid.to_string()) {
67                    ProbeResult::Alive
68                } else {
69                    ProbeResult::Dead
70                }
71            }
72            _ => ProbeResult::Unknown,
73        }
74    }
75    #[cfg(not(any(unix, windows)))]
76    {
77        let _ = pid;
78        ProbeResult::Unknown
79    }
80}
81
82/// Find a Chrome executable for the given release channel.
83///
84/// Checks the `CHROME_PATH` environment variable first, then falls back
85/// to platform-specific well-known paths.
86///
87/// # Errors
88///
89/// Returns `ChromeError::NotFound` if no Chrome executable can be located.
90pub fn find_chrome_executable(channel: Channel) -> Result<PathBuf, ChromeError> {
91    let env_override = std::env::var("CHROME_PATH").ok().map(PathBuf::from);
92    find_chrome_from(channel, env_override.as_deref())
93}
94
95/// Find a Chrome executable, optionally checking an explicit override path first.
96///
97/// This is the testable core of [`find_chrome_executable`]: it accepts the
98/// environment override as a parameter instead of reading `CHROME_PATH` directly.
99fn find_chrome_from(
100    channel: Channel,
101    env_override: Option<&std::path::Path>,
102) -> Result<PathBuf, ChromeError> {
103    if let Some(p) = env_override
104        && p.exists()
105    {
106        return Ok(p.to_path_buf());
107    }
108
109    for candidate in chrome_candidates(channel) {
110        if candidate.exists() {
111            return Ok(candidate);
112        }
113    }
114
115    Err(ChromeError::NotFound(format!(
116        "could not find Chrome ({channel:?} channel). Use --chrome-path to specify the executable"
117    )))
118}
119
120/// Returns the default Chrome user data directory for the current platform.
121#[must_use]
122pub fn default_user_data_dir() -> Option<PathBuf> {
123    #[cfg(target_os = "macos")]
124    {
125        home_dir().map(|h| h.join("Library/Application Support/Google/Chrome"))
126    }
127
128    #[cfg(target_os = "linux")]
129    {
130        home_dir().map(|h| h.join(".config/google-chrome"))
131    }
132
133    #[cfg(target_os = "windows")]
134    {
135        std::env::var("LOCALAPPDATA").ok().map(|d| {
136            PathBuf::from(d)
137                .join("Google")
138                .join("Chrome")
139                .join("User Data")
140        })
141    }
142
143    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
144    {
145        None
146    }
147}
148
149#[cfg(any(target_os = "macos", target_os = "linux"))]
150fn home_dir() -> Option<PathBuf> {
151    std::env::var("HOME").ok().map(PathBuf::from)
152}
153
154/// Returns all candidate executable paths for the given channel on the current platform.
155fn chrome_candidates(channel: Channel) -> Vec<PathBuf> {
156    #[cfg(target_os = "macos")]
157    {
158        macos_candidates(channel)
159    }
160
161    #[cfg(target_os = "linux")]
162    {
163        linux_candidates(channel)
164    }
165
166    #[cfg(target_os = "windows")]
167    {
168        windows_candidates(channel)
169    }
170
171    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
172    {
173        let _ = channel;
174        vec![]
175    }
176}
177
178#[cfg(target_os = "macos")]
179fn macos_candidates(channel: Channel) -> Vec<PathBuf> {
180    match channel {
181        Channel::Stable => vec![
182            PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
183            PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
184        ],
185        Channel::Canary => vec![PathBuf::from(
186            "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
187        )],
188        Channel::Beta => vec![PathBuf::from(
189            "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
190        )],
191        Channel::Dev => vec![PathBuf::from(
192            "/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
193        )],
194    }
195}
196
197#[cfg(target_os = "linux")]
198fn linux_candidates(channel: Channel) -> Vec<PathBuf> {
199    let path_dirs: Vec<PathBuf> = std::env::var("PATH")
200        .unwrap_or_default()
201        .split(':')
202        .map(PathBuf::from)
203        .collect();
204
205    let names: &[&str] = match channel {
206        Channel::Stable => &[
207            "google-chrome",
208            "google-chrome-stable",
209            "chromium-browser",
210            "chromium",
211        ],
212        Channel::Canary => &["google-chrome-canary"],
213        Channel::Beta => &["google-chrome-beta"],
214        Channel::Dev => &["google-chrome-unstable"],
215    };
216
217    let mut candidates = Vec::new();
218    for name in names {
219        for dir in &path_dirs {
220            candidates.push(dir.join(name));
221        }
222    }
223    candidates
224}
225
226#[cfg(target_os = "windows")]
227fn windows_candidates(channel: Channel) -> Vec<PathBuf> {
228    let program_files = std::env::var("ProgramFiles").unwrap_or_default();
229    let program_files_x86 = std::env::var("ProgramFiles(x86)").unwrap_or_default();
230    let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default();
231
232    match channel {
233        Channel::Stable => vec![
234            PathBuf::from(&program_files).join("Google/Chrome/Application/chrome.exe"),
235            PathBuf::from(&program_files_x86).join("Google/Chrome/Application/chrome.exe"),
236        ],
237        Channel::Canary => {
238            vec![PathBuf::from(&local_app_data).join("Google/Chrome SxS/Application/chrome.exe")]
239        }
240        Channel::Beta => vec![
241            PathBuf::from(&program_files).join("Google/Chrome Beta/Application/chrome.exe"),
242            PathBuf::from(&program_files_x86).join("Google/Chrome Beta/Application/chrome.exe"),
243        ],
244        Channel::Dev => vec![
245            PathBuf::from(&program_files).join("Google/Chrome Dev/Application/chrome.exe"),
246            PathBuf::from(&program_files_x86).join("Google/Chrome Dev/Application/chrome.exe"),
247        ],
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn default_user_data_dir_returns_some() {
257        // On CI or dev machines, home dir should exist
258        let dir = default_user_data_dir();
259        assert!(dir.is_some(), "Expected a default user data directory");
260    }
261
262    #[test]
263    fn chrome_candidates_is_not_empty() {
264        let candidates = chrome_candidates(Channel::Stable);
265        assert!(
266            !candidates.is_empty(),
267            "Expected at least one candidate path"
268        );
269    }
270
271    #[test]
272    fn chrome_path_override_existing_file() {
273        // Use the test binary itself as a known-existing file
274        let exe = std::env::current_exe().unwrap();
275        let result = find_chrome_from(Channel::Stable, Some(&exe));
276        assert_eq!(result.unwrap(), exe);
277    }
278
279    #[test]
280    fn is_process_alive_self_is_alive() {
281        let me = std::process::id();
282        assert_eq!(is_process_alive(me), ProbeResult::Alive);
283    }
284
285    #[cfg(unix)]
286    #[test]
287    fn is_process_alive_high_pid_is_dead() {
288        // u32::MAX wraps to -1 as i32, which targets a process group instead
289        // of a single process; use a high but valid-i32 PID so libc::kill
290        // returns ESRCH.
291        assert_eq!(is_process_alive(999_999_999), ProbeResult::Dead);
292    }
293
294    #[test]
295    fn chrome_path_override_nonexistent_is_skipped() {
296        let fake = std::path::Path::new("/nonexistent/chrome-test-binary");
297        let result = find_chrome_from(Channel::Stable, Some(fake));
298        // Should fall through to candidates (which may or may not find Chrome)
299        // — the point is that the nonexistent override is skipped, not returned.
300        if let Ok(path) = &result {
301            assert_ne!(path.as_path(), fake);
302        }
303    }
304}