use std::{
env,
fs::{self, File},
io::{BufWriter, Write},
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use ffmpeg_sidecar::command::FfmpegCommand;
use tracing::{debug, info, warn};
use zip::ZipArchive;
use crate::prompter::RUNTIME;
pub fn is_chromium_installed() -> bool {
BrowserConfig::default().is_some()
}
pub fn is_ffmpeg_installed() -> bool {
let cache_path = get_cache_path();
let mut ffmpeg_path = cache_path.join("ffmpeg").join("ffmpeg");
if cfg!(windows) {
ffmpeg_path.set_extension("exe");
}
if ffmpeg_path.exists() {
return true;
}
ffmpeg_sidecar::command::ffmpeg_is_installed()
}
pub fn fetch_chromium() -> Result<()> {
let cr = ChromeRevision::default();
match cr {
Some(cr) => cr.download(),
None => anyhow::bail!("Platform isn't supported for fetching chromium"),
}
}
fn ffmpeg_download_url() -> Result<&'static str> {
if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
Ok("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip")
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
Ok("https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz")
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
Ok("https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz")
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
Ok("https://evermeet.cx/ffmpeg/getrelease/zip")
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
Ok("https://www.osxexperts.net/ffmpeg80arm.zip")
} else {
anyhow::bail!("Unsupported platform")
}
}
fn download_ffmpeg_package(url: &str, download_dir: &Path) -> Result<PathBuf> {
let filename = Path::new(url)
.file_name()
.context("Failed to get filename")?;
let archive_path = download_dir.join(filename);
let mut file = File::create(&archive_path).context("Failed to create file")?;
let mut writer = BufWriter::new(&mut file);
RUNTIME.block_on(async {
let mut res = reqwest::get(url)
.await
.context("Failed to download ffmpeg")?;
while let Some(chunk) = res.chunk().await.context("Failed to read chunk")? {
writer.write_all(&chunk).context("Failed to write chunk")?;
}
writer.flush().context("Failed to flush")?;
Ok(archive_path)
})
}
fn unpack_ffmpeg(from_archive: &Path, binary_folder: &Path) -> Result<()> {
let temp_folder = binary_folder.join("_unpack_tmp");
fs::create_dir_all(&temp_folder)?;
let file = File::open(from_archive).context("Failed to open archive")?;
#[cfg(target_os = "linux")]
{
let tar_xz = lzma_rust2::XzReader::new(file, true);
let mut archive = tar::Archive::new(tar_xz);
archive
.unpack(&temp_folder)
.context("Failed to unpack ffmpeg")?;
}
#[cfg(not(target_os = "linux"))]
{
let mut archive = zip::ZipArchive::new(file).context("Failed to read ZIP archive")?;
archive
.extract(&temp_folder)
.context("Failed to unpack ffmpeg")?;
}
let (ffmpeg, ffplay, ffprobe) = if cfg!(target_os = "windows") {
let inner_folder = fs::read_dir(&temp_folder)?
.next()
.context("Failed to get inner folder")??
.path();
(
inner_folder.join("bin/ffmpeg.exe"),
inner_folder.join("bin/ffplay.exe"),
inner_folder.join("bin/ffprobe.exe"),
)
} else if cfg!(target_os = "linux") {
let inner_folder = fs::read_dir(&temp_folder)?
.next()
.context("Failed to get inner folder")??
.path();
(
inner_folder.join("ffmpeg"),
inner_folder.join("ffplay"),
inner_folder.join("ffprobe"),
)
} else {
(
temp_folder.join("ffmpeg"),
temp_folder.join("ffplay"),
temp_folder.join("ffprobe"),
)
};
let move_bin = |path: &Path| -> Result<()> {
if path.exists() {
let dest = binary_folder.join(path.file_name().context("No filename")?);
fs::rename(path, dest)?;
}
Ok(())
};
move_bin(&ffmpeg)?;
move_bin(&ffprobe)?;
move_bin(&ffplay)?;
if temp_folder.exists() {
fs::remove_dir_all(&temp_folder)?;
}
if from_archive.exists() {
fs::remove_file(from_archive)?;
}
Ok(())
}
pub fn fetch_ffmpeg() -> Result<()> {
let cache_path = get_cache_path();
let des = cache_path.join("ffmpeg");
if !des.exists() {
fs::create_dir_all(des.clone())?;
}
let download_url = ffmpeg_download_url()?;
eprintln!(
"downloading ffmpeg into {}, it may take a while..",
des.display()
);
let archive_path = download_ffmpeg_package(download_url, &des)?;
eprintln!("unpacking ffmpeg");
unpack_ffmpeg(&archive_path, &des)?;
eprintln!("done!");
Ok(())
}
pub fn clean() -> Result<()> {
let cache_path = get_cache_path();
eprintln!("deleting: {}", cache_path.display());
if cache_path.exists() {
fs::remove_dir_all(cache_path)?;
}
eprintln!("done!");
Ok(())
}
pub fn get_cache_path() -> PathBuf {
let base_dir = dirs::cache_dir()
.or_else(dirs::data_dir)
.unwrap_or_else(std::env::temp_dir);
base_dir.join("mcat")
}
pub fn get_ffmpeg() -> Option<FfmpegCommand> {
if ffmpeg_sidecar::command::ffmpeg_is_installed() {
info!("using system ffmpeg");
return Some(FfmpegCommand::new());
}
let cache_path = get_cache_path();
let mut ffmpeg_path = cache_path.join("ffmpeg").join("ffmpeg");
if cfg!(windows) {
ffmpeg_path.set_extension("exe");
}
if ffmpeg_path.exists() {
info!(path = %ffmpeg_path.display(), "using cached ffmpeg");
return Some(FfmpegCommand::new_with_path(ffmpeg_path));
}
debug!("ffmpeg not found");
None
}
pub struct BrowserConfig {
pub path: PathBuf,
}
impl BrowserConfig {
pub fn default() -> Option<Self> {
let path = BrowserConfig::auto_detect_path()?;
Some(BrowserConfig { path })
}
fn auto_detect_path() -> Option<PathBuf> {
if let Some(path) = get_by_env_var() {
info!(path = %path.display(), "found browser via CHROME env var");
return Some(path);
}
if let Some(path) = get_by_name() {
info!(path = %path.display(), "found browser in PATH");
return Some(path);
}
#[cfg(windows)]
if let Some(path) = get_by_registry() {
info!(path = %path.display(), "found browser via registry");
return Some(path);
}
if let Some(path) = get_by_path() {
info!(path = %path.display(), "found browser at known path");
return Some(path);
}
let cr = ChromeRevision::default()?;
let p = cr.path();
if p.exists() {
info!(path = %p.display(), "found cached chromium");
return Some(p);
}
warn!("no browser found");
None
}
}
fn get_by_env_var() -> Option<PathBuf> {
if let Ok(path) = env::var("CHROME")
&& Path::new(&path).exists()
{
return Some(path.into());
}
None
}
fn get_by_name() -> Option<PathBuf> {
let default_apps = [
("chrome"),
("chrome-browser"),
("google-chrome-stable"),
("google-chrome-beta"),
("google-chrome-dev"),
("google-chrome-unstable"),
("chromium"),
("chromium-browser"),
("msedge"),
("microsoft-edge"),
("microsoft-edge-stable"),
("microsoft-edge-beta"),
("microsoft-edge-dev"),
];
for app in default_apps {
if let Ok(path) = which::which(app) {
return Some(path);
}
}
None
}
fn get_by_path() -> Option<PathBuf> {
#[cfg(all(unix, not(target_os = "macos")))]
let default_paths: [&str; 2] = ["/opt/chromium.org/chromium", "/opt/google/chrome"];
#[cfg(windows)]
let default_paths = [r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"];
#[cfg(target_os = "macos")]
let default_paths = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
"/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
"/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
"/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
];
for path in default_paths {
if Path::new(path).exists() {
return Some(path.into());
}
}
None
}
#[cfg(windows)]
fn get_by_registry() -> Option<PathBuf> {
winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE)
.open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe")
.or_else(|_| {
winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER)
.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe")
})
.and_then(|key| key.get_value::<String, _>(""))
.map(PathBuf::from)
.ok()
}
struct ChromeRevision {
revision: u32,
platform: Platform,
host: String,
}
impl ChromeRevision {
pub fn default() -> Option<Self> {
Some(ChromeRevision {
revision: 1045629,
platform: Platform::current()?,
host: "https://storage.googleapis.com".to_owned(),
})
}
pub fn download(&self) -> Result<()> {
let url = self.platform.as_url(&self.host, self.revision);
eprintln!("Downloading {url}");
let cache_path = get_cache_path();
let mut folder_path = cache_path.join("chromium");
folder_path.push(self.platform.as_folder(self.revision));
let archive_path = folder_path.with_extension("zip");
eprintln!("Location: {}", archive_path.display());
if !folder_path.exists() {
fs::create_dir_all(&folder_path)?;
}
let file = File::create(&archive_path).context("Failed to create archive file")?;
let mut file = BufWriter::new(file);
let url = url.parse::<reqwest::Url>().context("Invalid archive url")?;
RUNTIME.block_on(async {
let mut res = reqwest::get(url)
.await
.context("Failed to send request to host")?;
if res.status() != reqwest::StatusCode::OK {
anyhow::bail!("Invalid archive url");
}
while let Some(chunk) = res.chunk().await.context("Failed to read response chunk")? {
file.write(&chunk)
.context("Failed to write to archive file")?;
}
file.flush().context("Failed to flush to disk")?;
eprintln!("Finished Downloading!");
eprintln!("Unziping");
fs::create_dir_all(&folder_path).context("Failed to create folder")?;
let file = fs::File::open(&archive_path).context("Failed to open archive")?;
let mut archive = ZipArchive::new(file).context("Failed to unzip archive")?;
archive.extract(folder_path)?;
let _ = fs::remove_file(archive_path);
eprintln!("Done!");
Ok(())
})
}
pub fn path(&self) -> PathBuf {
let cache_path = get_cache_path();
let mut download_path = cache_path.join("chromium");
download_path.push(self.platform.as_folder(self.revision));
download_path.push(self.platform.as_archive(self.revision));
self.platform.as_executable(&download_path)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Platform {
Linux,
Mac,
MacArm,
Win32,
Win64,
}
impl Platform {
pub fn current() -> Option<Self> {
let os = env::consts::OS;
let arch = env::consts::ARCH;
match (os, arch) {
("linux", "x86_64") => Some(Self::Linux),
("macos", "x86_64") => Some(Self::Mac),
("macos", "aarch64") => Some(Self::MacArm),
("windows", "x86") => Some(Self::Win32),
("windows", "x86_64") => Some(Self::Win64),
_ => None,
}
}
fn as_archive(&self, revision: u32) -> String {
match self {
Self::Linux => "chrome-linux".to_string(),
Self::Mac | Self::MacArm => "chrome-mac".to_string(),
Self::Win32 | Self::Win64 => {
if revision > 591_479 {
"chrome-win".to_string()
} else {
"chrome-win32".to_string()
}
}
}
}
pub fn as_executable(&self, folder_path: &Path) -> PathBuf {
let mut path = folder_path.to_path_buf();
match self {
Self::Linux => path.push("chrome"),
Self::Mac | Self::MacArm => {
path.push("Chromium.app");
path.push("Contents");
path.push("MacOS");
path.push("Chromium")
}
Self::Win32 | Self::Win64 => path.push("chrome.exe"),
}
path
}
pub fn as_url(&self, host: &str, revision: u32) -> String {
let name = match self {
Self::Linux => "Linux_x64",
Self::Mac => "Mac",
Self::MacArm => "Mac_Arm",
Self::Win32 => "Win",
Self::Win64 => "Win_x64",
};
let archive = self.as_archive(revision);
format!(
"{}/chromium-browser-snapshots/{}/{}/{}.zip",
host, name, revision, archive
)
}
pub fn as_folder(&self, revision: u32) -> String {
let platform = match self {
Self::Linux => "linux",
Self::Mac => "mac",
Self::MacArm => "mac_arm",
Self::Win32 => "win32",
Self::Win64 => "win64",
};
format!("{platform}-{revision}")
}
}