webdriver_install/
chromedriver.rs

1/// This module manages version selection of the `chromedriver`,
2/// based on the installed browser version.
3///
4/// See https://chromedriver.chromium.org/downloads/version-selection
5use eyre::{eyre, Result};
6use regex::Regex;
7use tracing::debug;
8use url::Url;
9
10use std::process::{Command, Stdio};
11
12use crate::DriverFetcher;
13
14#[cfg(target_os = "windows")]
15use crate::run_powershell_cmd;
16
17use std::path::PathBuf;
18
19pub struct Chromedriver;
20
21impl DriverFetcher for Chromedriver {
22    const BASE_URL: &'static str = "https://chromedriver.storage.googleapis.com";
23
24    /// Returns the latest version of the driver
25    fn latest_version(&self) -> Result<String> {
26        let latest_release_url = format!(
27            "{}/LATEST_RELEASE_{}",
28            Self::BASE_URL,
29            Version::find()?.build_version()
30        );
31        debug!("latest_release_url: {}", latest_release_url);
32        let resp = reqwest::blocking::get(&latest_release_url)?;
33        Ok(resp.text()?)
34    }
35
36    /// Returns the download url for the driver executable
37    fn direct_download_url(&self, version: &str) -> Result<Url> {
38        Ok(Url::parse(&format!(
39            "{}/{version}/chromedriver_{platform}",
40            Self::BASE_URL,
41            version = version,
42            platform = Self::platform()?
43        ))?)
44    }
45}
46
47impl Chromedriver {
48    pub fn new() -> Self {
49        Self {}
50    }
51
52    /// Returns the platform part to be used in the download URL
53    ///
54    /// The `match` is based on the file contents of, for example
55    /// https://chromedriver.storage.googleapis.com/index.html?path=72.0.3626.69/
56    ///
57    /// If future chromedriver releases have multiple pointer widths per platform,
58    /// we have to change this to work like `Geckodriver::platform`.
59    fn platform() -> Result<String> {
60        match sys_info::os_type()?.as_str() {
61            "Linux" => Ok(String::from("linux64.zip")),
62            "Darwin" => Ok(String::from("mac64.zip")),
63            "Windows" => Ok(String::from("win32.zip")),
64            other => Err(eyre!(
65                "webdriver-install doesn't support '{}' currently",
66                other
67            )),
68        }
69    }
70}
71
72#[derive(Debug, PartialEq)]
73pub struct Version {
74    major: i16,
75    minor: i16,
76    build: i16,
77    patch: i16,
78}
79
80struct Location {}
81
82#[cfg(target_os = "linux")]
83static LINUX_CHROME_DIRS: &[&'static str] = &[
84    "/usr/local/sbin",
85    "/usr/local/bin",
86    "/usr/sbin",
87    "/usr/bin",
88    "/sbin",
89    "/bin",
90    "/opt/google/chrome",
91];
92#[cfg(target_os = "linux")]
93static LINUX_CHROME_FILES: &[&'static str] =
94    &["google-chrome", "chrome", "chromium", "chromium-browser"];
95
96#[cfg(target_os = "windows")]
97static WIN_CHROME_DIRS: &[&'static str] = &["Google\\Chrome\\Application", "Chromium\\Application"];
98
99#[cfg(target_os = "macos")]
100static MAC_CHROME_DIRS: &[&'static str] = &[
101    "/Applications/Chromium.app",
102    "/Applications/Google Chrome.app",
103];
104#[cfg(target_os = "macos")]
105static MAC_CHROME_FILES: &[&'static str] =
106    &["Contents/MacOS/Chromium", "Contents/MacOS/Google Chrome"];
107
108impl Version {
109    /// Returns the version of the currently installed Chrome/Chromium browser
110    pub fn find() -> Result<Self> {
111        #[cfg(target_os = "linux")]
112        return Self::linux_version();
113        #[cfg(target_os = "windows")]
114        return Self::windows_version();
115        #[cfg(target_os = "macos")]
116        return Self::mac_version();
117    }
118
119    /// Returns major.minor.build.patch
120    #[allow(dead_code)]
121    pub fn full_version(&self) -> String {
122        format!(
123            "{}.{}.{}.{}",
124            self.major, self.minor, self.build, self.patch
125        )
126    }
127
128    /// Returns major.minor.build
129    pub fn build_version(&self) -> String {
130        format!("{}.{}.{}", self.major, self.minor, self.build)
131    }
132
133    #[cfg(target_os = "linux")]
134    fn linux_version() -> Result<Self> {
135        // TODO: WSL?
136        let output = Command::new(Location::location()?)
137            .arg("--version")
138            .stdout(Stdio::piped())
139            .output()?
140            .stdout;
141
142        let output = String::from_utf8(output)?;
143        debug!("Chrome --version output: {}", output);
144
145        Ok(Self::version_from_output(&output)?)
146    }
147
148    #[cfg(target_os = "windows")]
149    fn windows_version() -> Result<Self> {
150        let output = run_powershell_cmd(&format!(
151            "(Get-ItemProperty '{}').VersionInfo.ProductVersion",
152            Location::location()?.display()
153        ));
154
155        let stdout = String::from_utf8(output.stdout)?;
156        debug!("chrome version: {}", stdout);
157
158        Ok(Self::version_from_output(&stdout)?)
159    }
160
161    #[cfg(target_os = "macos")]
162    fn mac_version() -> Result<Self> {
163        let output = Command::new(Location::location()?)
164            .arg("--version")
165            .stdout(Stdio::piped())
166            .output()?
167            .stdout;
168
169        let output = String::from_utf8(output)?;
170        debug!("Chrome --version output: {}", output);
171
172        Ok(Self::version_from_output(&output)?)
173    }
174
175    fn version_from_output(output: &str) -> Result<Self> {
176        let version_pattern = Regex::new(r"\d+\.\d+\.\d+\.\d+")?;
177        let version = version_pattern
178            .captures(&output)
179            .ok_or(eyre!(
180                "regex: Could not find 4-part Chrome version string in '{}'",
181                output
182            ))?
183            .get(0)
184            .map_or("", |m| m.as_str());
185        let parts: Vec<i16> = version
186            .split(".")
187            .map(|i| i.parse::<i16>().unwrap())
188            .collect();
189
190        Ok(Self {
191            major: parts[0],
192            minor: parts[1],
193            build: parts[2],
194            patch: parts[3],
195        })
196    }
197}
198
199impl Location {
200    /// Returns the location of the currently installed Chrome/Chromium browser
201    pub fn location() -> Result<PathBuf> {
202        #[cfg(target_os = "linux")]
203        return Self::linux_location();
204        #[cfg(target_os = "windows")]
205        return Self::windows_location();
206        #[cfg(target_os = "macos")]
207        return Self::mac_location();
208    }
209
210    #[cfg(target_os = "linux")]
211    fn linux_location() -> Result<PathBuf> {
212        // TODO: WSL?
213        for dir in LINUX_CHROME_DIRS.into_iter().map(PathBuf::from) {
214            for file in LINUX_CHROME_FILES {
215                let path = dir.join(file);
216                if path.exists() {
217                    return Ok(path);
218                }
219            }
220        }
221        Err(eyre!("Unable to find chrome executable"))
222    }
223
224    #[cfg(target_os = "windows")]
225    fn windows_location() -> Result<PathBuf> {
226        use dirs_sys::known_folder;
227
228        let roots = vec![
229            known_folder(&winapi::um::knownfolders::FOLDERID_ProgramFiles),
230            known_folder(&winapi::um::knownfolders::FOLDERID_ProgramFilesX86),
231            known_folder(&winapi::um::knownfolders::FOLDERID_ProgramFilesX64),
232        ]
233        .into_iter()
234        .flatten()
235        .collect::<Vec<PathBuf>>();
236        for dir in WIN_CHROME_DIRS.into_iter().map(PathBuf::from) {
237            for root in &roots {
238                let path = root.join(&dir).join("chrome.exe");
239                debug!("root: {}", root.display());
240                debug!("checking path {}", &path.display());
241                if path.exists() {
242                    return Ok(path);
243                }
244            }
245        }
246        Err(eyre!("Unable to find chrome executable"))
247    }
248
249    #[cfg(target_os = "macos")]
250    fn mac_location() -> Result<PathBuf> {
251        for dir in MAC_CHROME_DIRS.into_iter().map(PathBuf::from) {
252            for file in MAC_CHROME_FILES {
253                let path = dir.join(file);
254                if path.exists() {
255                    return Ok(path);
256                }
257            }
258        }
259        Err(eyre!("Unable to find chrome executable"))
260    }
261}
262
263#[test]
264fn version_from_output_test() {
265    assert_eq!(
266        Version::version_from_output("Chromium 87.0.4280.141 snap").unwrap(),
267        Version {
268            major: 87,
269            minor: 0,
270            build: 4280,
271            patch: 141
272        }
273    );
274    assert_eq!(
275        Version::version_from_output("127.0.0.1").unwrap(),
276        Version {
277            major: 127,
278            minor: 0,
279            build: 0,
280            patch: 1
281        }
282    );
283}
284
285#[test]
286#[should_panic(expected = "Could not find 4-part Chrome version string in 'a.0.0.1'")]
287fn version_from_output_panic_test() {
288    Version::version_from_output("a.0.0.1").unwrap();
289}
290
291#[test]
292#[should_panic(expected = "Could not find 4-part Chrome version string in 'abc 1.0.1 def'")]
293fn version_from_output_panic_not_4_parts_test() {
294    Version::version_from_output("abc 1.0.1 def").unwrap();
295}
296
297#[test]
298fn direct_download_url_test() {
299    #[cfg(target_os = "linux")]
300    assert_eq!(
301        "https://chromedriver.storage.googleapis.com/v1/chromedriver_linux64.zip",
302        Chromedriver::new()
303            .direct_download_url("v1")
304            .unwrap()
305            .to_string()
306    );
307    #[cfg(target_os = "macos")]
308    assert_eq!(
309        "https://chromedriver.storage.googleapis.com/v1/chromedriver_mac64.zip",
310        Chromedriver::new()
311            .direct_download_url("v1")
312            .unwrap()
313            .to_string()
314    );
315    #[cfg(target_os = "windows")]
316    assert_eq!(
317        "https://chromedriver.storage.googleapis.com/v1/chromedriver_win32.zip",
318        Chromedriver::new()
319            .direct_download_url("v1")
320            .unwrap()
321            .to_string()
322    );
323}