Skip to main content

download_cef/
lib.rs

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() // Safe here due to File::open check above
495        .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    // Cleanup whatever is left in the extracted directory.
550    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";