1#![doc = include_str!("../README.md")]
2
3use bzip2::bufread::BzDecoder;
4use clap::ValueEnum;
5use regex::Regex;
6use semver::Version;
7use serde::{Deserialize, Serialize};
8use sha1_smol::Sha1;
9use std::{
10 collections::HashMap,
11 env,
12 fmt::{self, Display},
13 fs::{self, File},
14 io::{self, BufReader, IsTerminal, Write},
15 path::{Path, PathBuf},
16 sync::{Mutex, OnceLock},
17 thread,
18 time::Duration,
19};
20
21#[macro_use]
22extern crate thiserror;
23
24#[derive(Debug, Error)]
25pub enum Error {
26 #[error("Unsupported target triplet: {0}")]
27 UnsupportedTarget(String),
28 #[error("HTTP request error: {0}")]
29 Request(#[from] ureq::Error),
30 #[error("Invalid version: {0}")]
31 InvalidVersion(#[from] semver::Error),
32 #[error("Version not found: {0}")]
33 VersionNotFound(String),
34 #[error("Missing Content-Length header")]
35 MissingContentLength,
36 #[error("Opaque Content-Length header: {0}")]
37 OpaqueContentLength(#[from] ureq::http::header::ToStrError),
38 #[error("Invalid Content-Length header: {0}")]
39 InvalidContentLength(String),
40 #[error("File I/O error: {0}")]
41 Io(#[from] std::io::Error),
42 #[error("Unexpected file size: downloaded {downloaded} expected {expected}")]
43 UnexpectedFileSize { downloaded: u64, expected: u64 },
44 #[error("Bad SHA1 file hash: {0}")]
45 CorruptedFile(String),
46 #[error("Invalid archive file path: {0}")]
47 InvalidArchiveFile(String),
48 #[error("JSON serialization error: {0}")]
49 Json(#[from] serde_json::Error),
50 #[error(
51 "Undexpected archive version: location: {location} archive {archive} expected {expected}"
52 )]
53 VersionMismatch {
54 location: String,
55 archive: String,
56 expected: String,
57 },
58 #[error("Invalid regex pattern: {0}")]
59 InvalidRegexPattern(#[from] regex::Error),
60}
61
62pub type Result<T> = std::result::Result<T, Error>;
63
64pub const LINUX_TARGETS: &[&str] = &[
65 "x86_64-unknown-linux-gnu",
66 "arm-unknown-linux-gnueabi",
67 "aarch64-unknown-linux-gnu",
68];
69
70pub const MACOS_TARGETS: &[&str] = &["aarch64-apple-darwin", "x86_64-apple-darwin"];
71
72pub const WINDOWS_TARGETS: &[&str] = &[
73 "x86_64-pc-windows-msvc",
74 "aarch64-pc-windows-msvc",
75 "i686-pc-windows-msvc",
76];
77
78pub fn default_version(version: &str) -> String {
79 unwrap_cef_version(version).unwrap_or_else(|_| version.to_string())
80}
81
82fn unwrap_cef_version(version: &str) -> Result<String> {
83 static VERSIONS: OnceLock<Mutex<HashMap<Version, String>>> = OnceLock::new();
84 let mut versions = VERSIONS
85 .get_or_init(Default::default)
86 .lock()
87 .expect("Lock error");
88 Ok(versions
89 .entry(Version::parse(version)?)
90 .or_insert_with_key(|v| {
91 if v.build.is_empty() {
92 version.to_string()
93 } else {
94 v.build.to_string()
95 }
96 })
97 .clone())
98}
99
100pub fn check_archive_json(version: &str, location: &str) -> Result<()> {
101 let expected = Version::parse(&unwrap_cef_version(version)?)?;
102
103 static PATTERN: OnceLock<core::result::Result<Regex, regex::Error>> = OnceLock::new();
104 let pattern = PATTERN
105 .get_or_init(|| Regex::new(r"^cef_binary_([^+]+)(:?\+.+)?$"))
106 .as_ref()
107 .map_err(Clone::clone)?;
108 let archive_json: CefFile = serde_json::from_reader(File::open(archive_json_path(location))?)?;
109 let archive_version = pattern.replace(&archive_json.name, "$1");
110 let archive = Version::parse(&archive_version)?;
111
112 if archive <= expected {
113 Ok(())
114 } else {
115 Err(Error::VersionMismatch {
116 location: location.to_string(),
117 expected: expected.to_string(),
118 archive: archive.to_string(),
119 })
120 }
121}
122
123fn archive_json_path<P>(location: P) -> PathBuf
124where
125 P: AsRef<Path>,
126{
127 location.as_ref().join("archive.json")
128}
129
130pub const DEFAULT_CDN_URL: &str = "https://cef-builds.spotifycdn.com";
131
132pub fn default_download_url() -> String {
133 env::var("CEF_DOWNLOAD_URL").unwrap_or(DEFAULT_CDN_URL.to_owned())
134}
135
136#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, ValueEnum)]
137#[serde(rename_all = "lowercase")]
138pub enum Channel {
139 Stable,
140 Beta,
141}
142
143impl Display for Channel {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 Channel::Stable => write!(f, "stable"),
147 Channel::Beta => write!(f, "beta"),
148 }
149 }
150}
151
152#[derive(Deserialize, Serialize, Default)]
153pub struct CefIndex {
154 pub macosarm64: CefPlatform,
155 pub macosx64: CefPlatform,
156 pub windows64: CefPlatform,
157 pub windowsarm64: CefPlatform,
158 pub windows32: CefPlatform,
159 pub linux64: CefPlatform,
160 pub linuxarm64: CefPlatform,
161 pub linuxarm: CefPlatform,
162}
163
164impl CefIndex {
165 pub fn download() -> Result<Self> {
166 Self::download_from(DEFAULT_CDN_URL)
167 }
168
169 pub fn download_from(url: &str) -> Result<Self> {
170 Ok(ureq::get(&format!("{url}/index.json"))
171 .call()?
172 .into_body()
173 .read_json()?)
174 }
175
176 pub fn platform(&self, target: &str) -> Result<&CefPlatform> {
177 match target {
178 "aarch64-apple-darwin" => Ok(&self.macosarm64),
179 "x86_64-apple-darwin" => Ok(&self.macosx64),
180 "x86_64-pc-windows-msvc" => Ok(&self.windows64),
181 "aarch64-pc-windows-msvc" => Ok(&self.windowsarm64),
182 "i686-pc-windows-msvc" => Ok(&self.windows32),
183 "x86_64-unknown-linux-gnu" => Ok(&self.linux64),
184 "aarch64-unknown-linux-gnu" => Ok(&self.linuxarm64),
185 "arm-unknown-linux-gnueabi" => Ok(&self.linuxarm),
186 v => Err(Error::UnsupportedTarget(v.to_string())),
187 }
188 }
189}
190
191#[derive(Deserialize, Serialize, Default)]
192pub struct CefPlatform {
193 pub versions: Vec<CefVersion>,
194}
195
196impl CefPlatform {
197 pub fn version(&self, cef_version: &str) -> Result<&CefVersion> {
198 let version_prefix = format!("{cef_version}+");
199 self.versions
200 .iter()
201 .find(|v| v.cef_version.starts_with(&version_prefix))
202 .ok_or_else(|| Error::VersionNotFound(cef_version.to_string()))
203 }
204
205 pub fn latest(&self, channel: Channel) -> Result<&CefVersion> {
206 static PATTERN: OnceLock<core::result::Result<Regex, regex::Error>> = OnceLock::new();
207 let pattern = PATTERN
208 .get_or_init(|| Regex::new(r"^([^+]+)(:?\+.+)?$"))
209 .as_ref()
210 .map_err(Clone::clone)?;
211
212 self.versions
213 .iter()
214 .filter_map(|value| {
215 if value.channel == channel {
216 let key = Version::parse(&pattern.replace(&value.cef_version, "$1")).ok()?;
217 Some((key, value))
218 } else {
219 None
220 }
221 })
222 .max_by(|(a, _), (b, _)| a.cmp(b))
223 .map(|(_, v)| v)
224 .ok_or_else(|| Error::VersionNotFound("latest".to_string()))
225 }
226}
227
228#[derive(Deserialize, Serialize)]
229pub struct CefVersion {
230 pub channel: Channel,
231 pub cef_version: String,
232 pub files: Vec<CefFile>,
233}
234
235impl CefVersion {
236 pub fn download_archive<P>(&self, location: P, show_progress: bool) -> Result<PathBuf>
237 where
238 P: AsRef<Path>,
239 {
240 self.download_archive_from(DEFAULT_CDN_URL, location, show_progress)
241 }
242
243 pub fn download_archive_from<P>(
244 &self,
245 url: &str,
246 location: P,
247 show_progress: bool,
248 ) -> Result<PathBuf>
249 where
250 P: AsRef<Path>,
251 {
252 let file = self.minimal()?;
253 let (file, sha) = (file.name.as_str(), file.sha1.as_str());
254
255 fs::create_dir_all(&location)?;
256 let download_file = location.as_ref().join(file);
257
258 if download_file.exists() {
259 if calculate_file_sha1(&download_file) == sha {
260 if show_progress {
261 println!("Verified archive: {}", download_file.display());
262 }
263 return Ok(download_file);
264 }
265
266 if show_progress {
267 println!("Cleaning corrupted archive: {}", download_file.display());
268 }
269 let corrupted_file = location.as_ref().join(format!("corrupted_{file}"));
270 fs::rename(&download_file, &corrupted_file)?;
271 fs::remove_file(&corrupted_file)?;
272 }
273
274 let cef_url = format!("{url}/{file}");
275 if show_progress {
276 println!("Using archive url: {cef_url}");
277 }
278
279 let mut file = File::create(&download_file)?;
280
281 let resp = ureq::get(&cef_url).call()?;
282 let expected = resp
283 .headers()
284 .get("Content-Length")
285 .ok_or(Error::MissingContentLength)?;
286 let expected = expected.to_str()?;
287 let expected = expected
288 .parse::<u64>()
289 .map_err(|_| Error::InvalidContentLength(expected.to_owned()))?;
290
291 let downloaded = if show_progress && io::stdout().is_terminal() {
292 const DOWNLOAD_TEMPLATE: &str = "{msg} {spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})";
293
294 let bar = indicatif::ProgressBar::new(expected);
295 bar.set_style(
296 indicatif::ProgressStyle::with_template(DOWNLOAD_TEMPLATE)
297 .expect("invalid template")
298 .progress_chars("##-"),
299 );
300 bar.set_message("Downloading");
301 std::io::copy(
302 &mut bar.wrap_read(resp.into_body().into_reader()),
303 &mut file,
304 )
305 } else {
306 let mut reader = resp.into_body().into_reader();
307 std::io::copy(&mut reader, &mut file)
308 }?;
309
310 if downloaded != expected {
311 return Err(Error::UnexpectedFileSize {
312 downloaded,
313 expected,
314 });
315 }
316
317 if show_progress {
318 println!("Verifying SHA1 hash: {sha}...");
319 }
320 if calculate_file_sha1(&download_file) != sha {
321 return Err(Error::CorruptedFile(download_file.display().to_string()));
322 }
323
324 if show_progress {
325 println!("Downloaded archive: {}", download_file.display());
326 }
327 Ok(download_file)
328 }
329
330 pub fn download_archive_with_retry<P>(
331 &self,
332 location: P,
333 show_progress: bool,
334 retry_delay: Duration,
335 max_retries: u32,
336 ) -> Result<PathBuf>
337 where
338 P: AsRef<Path>,
339 {
340 self.download_archive_with_retry_from(
341 DEFAULT_CDN_URL,
342 location,
343 show_progress,
344 retry_delay,
345 max_retries,
346 )
347 }
348
349 pub fn download_archive_with_retry_from<P>(
350 &self,
351 url: &str,
352 location: P,
353 show_progress: bool,
354 retry_delay: Duration,
355 max_retries: u32,
356 ) -> Result<PathBuf>
357 where
358 P: AsRef<Path>,
359 {
360 let mut result = self.download_archive_from(url, &location, show_progress);
361
362 let mut retry = 0;
363 while let Err(Error::Io(_)) = &result {
364 if retry >= max_retries {
365 break;
366 }
367
368 retry += 1;
369 thread::sleep(retry_delay * retry);
370
371 result = self.download_archive_from(url, &location, show_progress);
372 }
373
374 result
375 }
376
377 pub fn minimal(&self) -> Result<&CefFile> {
378 self.files
379 .iter()
380 .find(|f| f.file_type == "minimal")
381 .ok_or_else(|| Error::VersionNotFound(self.cef_version.clone()))
382 }
383
384 pub fn write_archive_json<P>(&self, location: P) -> Result<()>
385 where
386 P: AsRef<Path>,
387 {
388 self.minimal()?.write_archive_json(location)
389 }
390}
391
392#[derive(Clone, Deserialize, Serialize)]
393pub struct CefFile {
394 #[serde(rename = "type")]
395 pub file_type: String,
396 pub name: String,
397 pub sha1: String,
398}
399
400impl CefFile {
401 pub fn write_archive_json<P>(&self, location: P) -> Result<()>
402 where
403 P: AsRef<Path>,
404 {
405 let archive_version = serde_json::to_string_pretty(self)?;
406 let mut archive_json = File::create(archive_json_path(location))?;
407 archive_json.write_all(archive_version.as_bytes())?;
408 Ok(())
409 }
410}
411
412impl TryFrom<&Path> for CefFile {
413 type Error = Error;
414
415 fn try_from(location: &Path) -> Result<Self> {
416 let file_type = "minimal".to_string();
417 let name = location
418 .file_name()
419 .map(|f| f.display().to_string())
420 .ok_or_else(|| Error::InvalidArchiveFile(location.display().to_string()))?;
421 let sha1 = calculate_file_sha1(location);
422 Ok(Self {
423 file_type,
424 name,
425 sha1,
426 })
427 }
428}
429
430pub fn download_target_archive<P>(
431 target: &str,
432 cef_version: &str,
433 location: P,
434 show_progress: bool,
435) -> Result<PathBuf>
436where
437 P: AsRef<Path>,
438{
439 download_target_archive_from(
440 DEFAULT_CDN_URL,
441 target,
442 cef_version,
443 location,
444 show_progress,
445 )
446}
447
448pub fn download_target_archive_from<P>(
449 url: &str,
450 target: &str,
451 cef_version: &str,
452 location: P,
453 show_progress: bool,
454) -> Result<PathBuf>
455where
456 P: AsRef<Path>,
457{
458 if show_progress {
459 println!("Downloading CEF archive for {target}...");
460 }
461
462 let index = CefIndex::download_from(url)?;
463 let platform = index.platform(target)?;
464 let version = platform.version(cef_version)?;
465
466 version.download_archive_with_retry_from(
467 url,
468 location,
469 show_progress,
470 Duration::from_secs(15),
471 3,
472 )
473}
474
475pub fn extract_target_archive<P, Q>(
476 target: &str,
477 archive: P,
478 location: Q,
479 show_progress: bool,
480) -> Result<PathBuf>
481where
482 P: AsRef<Path>,
483 Q: AsRef<Path>,
484{
485 if show_progress {
486 println!("Extracting archive: {}", archive.as_ref().display());
487 }
488 let decoder = BzDecoder::new(BufReader::new(File::open(&archive)?));
489 tar::Archive::new(decoder).unpack(&location)?;
490
491 let extracted_dir = archive
492 .as_ref()
493 .file_name()
494 .unwrap() .display()
496 .to_string();
497 let extracted_dir = extracted_dir
498 .strip_suffix(".tar.bz2")
499 .map(PathBuf::from)
500 .ok_or(Error::InvalidArchiveFile(extracted_dir))?;
501 let extracted_dir = location.as_ref().join(extracted_dir);
502
503 let os_and_arch = OsAndArch::try_from(target)?;
504 let OsAndArch { os, arch } = os_and_arch;
505 let cef_dir = os_and_arch.to_string();
506 let cef_dir = location.as_ref().join(cef_dir);
507
508 if cef_dir.exists() {
509 let old_dir = location.as_ref().join(format!("old_{os}_{arch}"));
510 if show_progress {
511 println!("Cleaning up: {}", old_dir.display());
512 }
513 fs::rename(&cef_dir, &old_dir)?;
514 fs::remove_dir_all(old_dir)?;
515 }
516 const RELEASE_DIR: &str = "Release";
517 fs::rename(extracted_dir.join(RELEASE_DIR), &cef_dir)?;
518
519 if os != "macos" {
520 let resources = extracted_dir.join("Resources");
521
522 for entry in fs::read_dir(&resources)? {
523 let entry = entry?;
524 fs::rename(entry.path(), cef_dir.join(entry.file_name()))?;
525 }
526 }
527
528 const CMAKE_LISTS_TXT: &str = "CMakeLists.txt";
529 fs::rename(
530 extracted_dir.join(CMAKE_LISTS_TXT),
531 cef_dir.join(CMAKE_LISTS_TXT),
532 )?;
533 const CMAKE_DIR: &str = "cmake";
534 fs::rename(extracted_dir.join(CMAKE_DIR), cef_dir.join(CMAKE_DIR))?;
535 const INCLUDE_DIR: &str = "include";
536 fs::rename(extracted_dir.join(INCLUDE_DIR), cef_dir.join(INCLUDE_DIR))?;
537 const LIBCEF_DLL_DIR: &str = "libcef_dll";
538 fs::rename(
539 extracted_dir.join(LIBCEF_DLL_DIR),
540 cef_dir.join(LIBCEF_DLL_DIR),
541 )?;
542 const CREDITS_HTML: &str = "CREDITS.html";
543 fs::rename(extracted_dir.join(CREDITS_HTML), cef_dir.join(CREDITS_HTML))?;
544
545 if show_progress {
546 println!("Moved contents to: {}", cef_dir.display());
547 }
548
549 let old_dir = extracted_dir
551 .parent()
552 .map(|parent| parent.join(format!("extracted_{os}_{arch}")))
553 .ok_or_else(|| Error::InvalidArchiveFile(extracted_dir.display().to_string()))?;
554 if show_progress {
555 println!("Cleaning up: {}", old_dir.display());
556 }
557 fs::rename(&extracted_dir, &old_dir)?;
558 fs::remove_dir_all(old_dir)?;
559
560 Ok(cef_dir)
561}
562
563fn calculate_file_sha1(path: &Path) -> String {
564 use std::io::Read;
565 let mut file = BufReader::new(File::open(path).unwrap());
566 let mut sha1 = Sha1::new();
567 let mut buffer = [0; 8192];
568
569 loop {
570 let count = file.read(&mut buffer).unwrap();
571 if count == 0 {
572 break;
573 }
574 sha1.update(&buffer[..count]);
575 }
576
577 sha1.digest().to_string()
578}
579
580pub struct OsAndArch {
581 pub os: &'static str,
582 pub arch: &'static str,
583}
584
585impl Display for OsAndArch {
586 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
587 let os = self.os;
588 let arch = self.arch;
589 write!(f, "cef_{os}_{arch}")
590 }
591}
592
593impl TryFrom<&str> for OsAndArch {
594 type Error = Error;
595
596 fn try_from(target: &str) -> Result<Self> {
597 match target {
598 "aarch64-apple-darwin" => Ok(OsAndArch {
599 os: "macos",
600 arch: "aarch64",
601 }),
602 "x86_64-apple-darwin" => Ok(OsAndArch {
603 os: "macos",
604 arch: "x86_64",
605 }),
606 "x86_64-pc-windows-msvc" => Ok(OsAndArch {
607 os: "windows",
608 arch: "x86_64",
609 }),
610 "aarch64-pc-windows-msvc" => Ok(OsAndArch {
611 os: "windows",
612 arch: "aarch64",
613 }),
614 "i686-pc-windows-msvc" => Ok(OsAndArch {
615 os: "windows",
616 arch: "x86",
617 }),
618 "x86_64-unknown-linux-gnu" => Ok(OsAndArch {
619 os: "linux",
620 arch: "x86_64",
621 }),
622 "aarch64-unknown-linux-gnu" => Ok(OsAndArch {
623 os: "linux",
624 arch: "aarch64",
625 }),
626 "arm-unknown-linux-gnueabi" => Ok(OsAndArch {
627 os: "linux",
628 arch: "arm",
629 }),
630 v => Err(Error::UnsupportedTarget(v.to_string())),
631 }
632 }
633}
634
635#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
636pub const DEFAULT_TARGET: &str = "x86_64-unknown-linux-gnu";
637#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
638pub const DEFAULT_TARGET: &str = "aarch64-unknown-linux-gnu";
639#[cfg(all(target_os = "linux", target_arch = "arm"))]
640pub const DEFAULT_TARGET: &str = "arm-unknown-linux-gnueabi";
641
642#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
643pub const DEFAULT_TARGET: &str = "x86_64-pc-windows-msvc";
644#[cfg(all(target_os = "windows", target_arch = "x86"))]
645pub const DEFAULT_TARGET: &str = "i686-pc-windows-msvc";
646#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
647pub const DEFAULT_TARGET: &str = "aarch64-pc-windows-msvc";
648
649#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
650pub const DEFAULT_TARGET: &str = "x86_64-apple-darwin";
651#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
652pub const DEFAULT_TARGET: &str = "aarch64-apple-darwin";