proofsheet_core/
install.rs1use std::path::{Path, PathBuf};
26use std::process::Command;
27
28use crate::error::{Error, Result};
29
30const VERSIONS_URL: &str =
31 "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json";
32const DOWNLOAD_BASE: &str = "https://storage.googleapis.com/chrome-for-testing-public";
33
34pub fn managed_root() -> PathBuf {
38 if let Some(h) = std::env::var_os("PROOFSHEET_HOME").filter(|v| !v.is_empty()) {
39 return PathBuf::from(h).join("browser");
40 }
41 let home = std::env::var_os("HOME")
42 .or_else(|| std::env::var_os("USERPROFILE"))
43 .map(PathBuf::from)
44 .unwrap_or_else(|| PathBuf::from("."));
45 home.join(".proofsheet").join("browser")
46}
47
48fn platform_slug() -> Result<&'static str> {
50 Ok(match (std::env::consts::OS, std::env::consts::ARCH) {
51 ("linux", "x86_64") => "linux64",
52 ("macos", "x86_64") => "mac-x64",
53 ("macos", "aarch64") => "mac-arm64",
54 ("windows", "x86_64") => "win64",
55 (os, arch) => {
56 return Err(Error::Browser(format!(
57 "Chrome for Testing publishes no build for {os}/{arch}. \
58 Install a Chromium yourself and set PROOFSHEET_CHROME."
59 )))
60 }
61 })
62}
63
64fn binary_name() -> &'static str {
66 if cfg!(windows) {
67 "chrome-headless-shell.exe"
68 } else {
69 "chrome-headless-shell"
70 }
71}
72
73fn run(cmd: &mut Command) -> Result<std::process::Output> {
74 let out = cmd
75 .output()
76 .map_err(|e| Error::Browser(format!("could not run {:?}: {e}", cmd.get_program())))?;
77 if !out.status.success() {
78 return Err(Error::Browser(format!(
79 "{:?} failed: {}",
80 cmd.get_program(),
81 String::from_utf8_lossy(&out.stderr).trim()
82 )));
83 }
84 Ok(out)
85}
86
87fn curl_to(url: &str, dest: &Path) -> Result<()> {
88 run(Command::new("curl")
91 .args(["-sSL", "--fail", "--retry", "3", "-o"])
92 .arg(dest)
93 .arg(url))
94 .map_err(|e| Error::Browser(format!("downloading {url}: {e}")))?;
95 Ok(())
96}
97
98pub fn latest_stable_version() -> Result<String> {
100 let out = run(Command::new("curl").args(["-sSL", "--fail", "--retry", "3", VERSIONS_URL]))?;
101 let json: serde_json::Value = serde_json::from_slice(&out.stdout)
102 .map_err(|e| Error::Browser(format!("version manifest is not JSON: {e}")))?;
103 json["channels"]["Stable"]["version"]
104 .as_str()
105 .map(str::to_string)
106 .ok_or_else(|| Error::Browser("version manifest has no Stable channel".into()))
107}
108
109fn extract_zip(zip: &Path, into: &Path) -> Result<()> {
114 std::fs::create_dir_all(into)?;
115
116 let attempts: Vec<(&str, Vec<String>)> = vec![
117 (
118 "unzip",
119 vec![
120 "-q".into(),
121 zip.display().to_string(),
122 "-d".into(),
123 into.display().to_string(),
124 ],
125 ),
126 (
127 "python3",
128 vec![
129 "-c".into(),
130 format!(
131 "import zipfile;zipfile.ZipFile(r'{}').extractall(r'{}')",
132 zip.display(),
133 into.display()
134 ),
135 ],
136 ),
137 (
140 "tar",
141 vec![
142 "-xf".into(),
143 zip.display().to_string(),
144 "-C".into(),
145 into.display().to_string(),
146 ],
147 ),
148 (
149 "powershell",
150 vec![
151 "-NoProfile".into(),
152 "-Command".into(),
153 format!(
154 "Expand-Archive -Force -LiteralPath '{}' -DestinationPath '{}'",
155 zip.display(),
156 into.display()
157 ),
158 ],
159 ),
160 ];
161
162 let mut tried = Vec::new();
163 for (prog, args) in &attempts {
164 match Command::new(prog).args(args).output() {
165 Ok(out) if out.status.success() => return Ok(()),
166 Ok(out) => tried.push(format!(
167 "{prog}: {}",
168 String::from_utf8_lossy(&out.stderr).trim()
169 )),
170 Err(e) => tried.push(format!("{prog}: {e}")),
171 }
172 }
173 Err(Error::Browser(format!(
174 "could not unpack the archive. Tried unzip, python3, tar and \
175 PowerShell:\n {}",
176 tried.join("\n ")
177 )))
178}
179
180pub fn install_browser(version: Option<&str>, force: bool) -> Result<PathBuf> {
185 let root = managed_root();
186
187 if !force {
188 if let Some(existing) = super::cdp::find_in_managed(&root) {
189 return Ok(existing);
190 }
191 }
192
193 let slug = platform_slug()?;
194 let version = match version {
195 Some(v) => v.to_string(),
196 None => latest_stable_version()?,
197 };
198 let url = format!("{DOWNLOAD_BASE}/{version}/{slug}/chrome-headless-shell-{slug}.zip");
199
200 let dest = root.join(&version);
201 if force && dest.exists() {
202 std::fs::remove_dir_all(&dest)?;
203 }
204 std::fs::create_dir_all(&dest)?;
205
206 let zip = dest.join("chrome-headless-shell.zip");
207 curl_to(&url, &zip)?;
208 extract_zip(&zip, &dest)?;
209 let _ = std::fs::remove_file(&zip);
210
211 let binary = super::cdp::find_in_managed(&dest).ok_or_else(|| {
212 Error::Browser(format!(
213 "the archive unpacked but contained no {}",
214 binary_name()
215 ))
216 })?;
217
218 #[cfg(unix)]
219 {
220 use std::os::unix::fs::PermissionsExt;
221 let mut perms = std::fs::metadata(&binary)?.permissions();
222 perms.set_mode(0o755);
223 std::fs::set_permissions(&binary, perms)?;
224 }
225
226 let out = Command::new(&binary)
229 .arg("--version")
230 .output()
231 .map_err(|e| Error::Browser(format!("installed binary will not execute: {e}")))?;
232 if !out.status.success() {
233 return Err(Error::Browser(format!(
234 "installed binary exited {} when asked for its version",
235 out.status
236 )));
237 }
238
239 Ok(binary)
240}
241
242pub fn installed_version(binary: &Path) -> Option<String> {
244 let out = Command::new(binary).arg("--version").output().ok()?;
245 Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
254 fn platform_slug_is_known_or_a_clear_error() {
255 match platform_slug() {
256 Ok(s) => assert!(
257 ["linux64", "mac-x64", "mac-arm64", "win64"].contains(&s),
258 "unexpected slug {s}"
259 ),
260 Err(e) => assert!(
261 e.to_string().contains("PROOFSHEET_CHROME"),
262 "an unsupported platform must say what to do instead: {e}"
263 ),
264 }
265 }
266
267 #[test]
269 fn managed_root_honours_proofsheet_home() {
270 let root = managed_root();
273 assert!(
274 root.ends_with("browser"),
275 "managed root should be a browser/ dir, got {}",
276 root.display()
277 );
278 }
279}