headless_chrome 0.9.0

Control Chrome programatically
Documentation
use std::{
    fs::{self, File, OpenOptions},
    io::{self, BufWriter},
    path::{Path, PathBuf},
    str::FromStr,
};

use directories::ProjectDirs;
use failure::{format_err, Fallible};
use log::*;
use ureq;
use zip;

pub const CUR_REV: &str = "634997";

const APP_NAME: &str = "headless-chrome";
const DEFAULT_HOST: &str = "https://storage.googleapis.com";

#[cfg(target_os = "linux")]
const PLATFORM: &str = "linux";
#[cfg(target_os = "macos")]
const PLATFORM: &str = "mac";
#[cfg(windows)]
const PLATFORM: &str = "win";

pub struct Fetcher<'a> {
    rev: &'a str,
    dirs: ProjectDirs,
}

impl<'a> Fetcher<'a> {
    pub fn new(rev: &'a str) -> Fallible<Self> {
        let dirs = get_project_dirs()?;
        info!(
            "Creating XDG_DATA_DIR if it doesn't exist: {}",
            dirs.data_dir().display()
        );
        fs::create_dir_all(dirs.data_dir())?;
        Ok(Self { rev, dirs })
    }

    fn local_revisions(&self) -> Fallible<Vec<String>> {
        trace!(
            "Enumerating contents of XDG_DATA_DIR: {}",
            self.dirs.data_dir().display()
        );
        let mut revisions = Vec::new();
        for entry in fs::read_dir(self.dirs.data_dir())? {
            let entry = entry?;
            let path = entry.path();
            if path.is_dir() {
                let filename = path
                    .file_name()
                    .ok_or_else(|| format_err!("Failed to turn into OsStr"))?
                    .to_str()
                    .ok_or_else(|| format_err!("Failed conversion to UTF8"))?
                    .split('-')
                    .collect::<Vec<_>>();
                if filename.len() == 2 && filename[0] == PLATFORM {
                    let rev = filename[1].to_string();
                    revisions.push(rev)
                }
            }
        }
        Ok(revisions)
    }

    fn base_path(&self, rev: &str) -> PathBuf {
        let mut path = self.dirs.data_dir().to_path_buf();
        path.push(format!("{}-{}", PLATFORM, rev));
        path
    }

    fn chrome_path(&self, rev: &str) -> Fallible<PathBuf> {
        let mut path = self.base_path(rev);
        path.push(archive_name(rev)?);

        #[cfg(target_os = "linux")]
        {
            path.push("chrome");
        }
        #[cfg(target_os = "macos")]
        {
            path.push("Chromium.app");
            path.push("Contents");
            path.push("MacOS");
            path.push("Chromium");
        }
        #[cfg(windows)]
        {
            path.push("chrome.exe");
        }

        Ok(path)
    }

    pub fn run(&self) -> Fallible<PathBuf> {
        let revisions = self.local_revisions()?;
        if let Some(revision) = revisions.into_iter().find(|r| r == self.rev) {
            info!("No need to download, we have the correct revision");
            return Ok(self.chrome_path(&revision)?);
        }

        let url = dl_url(self.rev)?;
        info!("Chrome download url: {}", url);
        let total = get_size(&url)?;
        info!("Total size of download: {} MiB", total);
        let path = self.base_path(self.rev).with_extension("zip");

        info!("Creating file for download: {}", &path.display());
        let mut file = OpenOptions::new().create(true).write(true).open(&path)?;

        let resp = ureq::get(&url).call();
        io::copy(&mut resp.into_reader(), &mut file)?;

        self.unzip(&path)?;

        Ok(self.chrome_path(self.rev)?)
    }

    pub fn unzip<P: AsRef<Path>>(&self, path: P) -> Fallible<()> {
        let mut archive = zip::ZipArchive::new(File::open(path.as_ref())?)?;
        let extract_path = self.base_path(self.rev);
        fs::create_dir_all(&extract_path)?;

        info!(
            "Extracting (this can take a while): {}",
            extract_path.display()
        );

        for i in 0..archive.len() {
            let mut file = archive.by_index(i)?;
            let mut out_path = extract_path.clone();
            out_path.push(file.sanitized_name().as_path());

            let comment = file.comment();
            if !comment.is_empty() {
                trace!("File {} comment: {}", i, comment);
            }

            if (&*file.name()).ends_with('/') {
                trace!(
                    "File {} extracted to \"{}\"",
                    i,
                    out_path.as_path().display()
                );
                fs::create_dir_all(&out_path)?;
            } else {
                trace!(
                    "File {} extracted to \"{}\" ({} bytes)",
                    i,
                    out_path.as_path().display(),
                    file.size()
                );
                if let Some(p) = out_path.parent() {
                    if !p.exists() {
                        fs::create_dir_all(&p).unwrap();
                    }
                }
                let mut out_file = BufWriter::new(File::create(&out_path)?);
                io::copy(&mut file, &mut out_file)?;
            }
            // Get and Set permissions
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;

                if let Some(mode) = file.unix_mode() {
                    fs::set_permissions(&out_path, fs::Permissions::from_mode(mode)).unwrap();
                }
            }
        }

        info!("Cleaning up");
        if fs::remove_file(&path).is_err() {
            info!("Failed to delete zip");
            return Ok(());
        }

        Ok(())
    }
}

fn get_size<U: AsRef<str>>(url: U) -> Fallible<u64> {
    let resp = ureq::get(url.as_ref()).call();
    match resp.header("Content-Length") {
        Some(len) => Ok(u64::from_str(len)? / 2_u64.pow(20)),
        None => Err(format_err!("response doesn't include the content length")),
    }
}

fn get_project_dirs() -> Fallible<ProjectDirs> {
    info!("Getting project dir");
    match ProjectDirs::from("", "", APP_NAME) {
        Some(dirs) => Ok(dirs),
        None => Err(format_err!("Failed to retrieve project dirs")),
    }
}

fn dl_url<R>(revision: R) -> Fallible<String>
where
    R: AsRef<str>,
{
    #[cfg(target_os = "linux")]
    {
        Ok(format!(
            "{}/chromium-browser-snapshots/Linux_x64/{}/{}.zip",
            DEFAULT_HOST,
            revision.as_ref(),
            archive_name(revision.as_ref())?
        ))
    }

    #[cfg(target_os = "macos")]
    {
        Ok(format!(
            "{}/chromium-browser-snapshots/Mac/{}/{}.zip",
            DEFAULT_HOST,
            revision.as_ref(),
            archive_name(revision.as_ref())?
        ))
    }

    #[cfg(windows)]
    {
        Ok(format!(
            "{}/chromium-browser-snapshots/Win_x64/{}/{}.zip",
            DEFAULT_HOST,
            revision.as_ref(),
            archive_name(revision.as_ref())?
        ))
    }
}

fn archive_name<R: AsRef<str>>(revision: R) -> Fallible<&'static str> {
    #[cfg(target_os = "linux")]
    {
        drop(revision);

        Ok("chrome-linux")
    }

    #[cfg(target_os = "macos")]
    {
        drop(revision);

        Ok("chrome-mac")
    }

    #[cfg(windows)]
    {
        // Windows archive name changed at r591479.
        if revision.as_ref().parse::<u32>()? > 591_479 {
            Ok("chrome-win")
        } else {
            Ok("chrome-win32")
        }
    }
}