LΓͺ Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
20.3 kB
//! Sidecar detection, FFmpeg/ffprobe resolution, and on-demand downloads.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::config::get_effective_region;
#[allow(unused_imports)] // Used in cfg(linux) and cfg(windows) blocks
use crate::config::resolve_github_url;
use crate::bootstrap::{BootstrapStage, set_stage};
// Version of the Astral `uv` binary we download at first run when no system
// uv is on PATH. Pinned for reproducibility β€” bump alongside the uv.lock
// when the toolchain needs a newer uv.
pub const UV_VERSION: &str = "0.11.7";
// Version of BtbN/FFmpeg-Builds we download for Linux/Windows ffmpeg first-
// run setup. The string appears *twice* in each URL (once as the release tag,
// once inside the archive filename) β€” BtbN tags their autobuilds
// `autobuild-YYYY-MM-DD-HH-MM` and the inner filenames use the same datestamp.
// Driving both from one constant means pinning to a specific autobuild is a
// one-line edit: change `"latest"` to e.g. `"autobuild-2026-04-15-12-50"` and
// match the same constant in `.github/workflows/release.yml`
// (FFMPEG_BTBN_VERSION env var). Reproducible installer builds without
// surprise upstream regressions, AV reputation drift, or 2am pages when BtbN
// retags `latest` to a build that fails Windows SmartScreen.
//
// Browse releases: https://github.com/BtbN/FFmpeg-Builds/releases
pub const FFMPEG_BTBN_VERSION: &str = "latest";
// ── Sidecar detection ─────────────────────────────────────────────────────
/// Look for a sidecar binary bundled alongside the app via Tauri's
/// `bundle.externalBin`. Tauri places the per-target sidecar at the same
/// path as the main app executable on Linux/Windows, and inside
/// `Contents/MacOS/` on macOS .app bundles. The bundled file keeps its
/// `<name>-<target-triple>{.exe}` name.
///
/// Returns `None` in dev (`cargo run`) builds where the sidecar wasn't
/// bundled β€” the caller then falls back to PATH lookup or other strategies.
pub fn find_bundled_sidecar(name: &str) -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?;
let triple = match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => "aarch64-apple-darwin",
("macos", "x86_64") => "x86_64-apple-darwin",
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
("windows", "x86_64") => "x86_64-pc-windows-msvc",
_ => return None,
};
let ext = if cfg!(windows) { ".exe" } else { "" };
let candidate = dir.join(format!("{}-{}{}", name, triple, ext));
if !candidate.is_file() {
return None;
}
// build.rs writes a zero-byte placeholder so tauri-build's externalBin
// existence check passes during dev / `cargo check`. Reject it here so
// we don't try to exec an empty file β€” callers fall back to PATH lookup
// or pip-bundled binaries instead.
let len = std::fs::metadata(&candidate).ok().map(|m| m.len()).unwrap_or(0);
if len < 1024 {
return None;
}
Some(candidate)
}
pub fn find_bundled_uv() -> Option<PathBuf> { find_bundled_sidecar("uv") }
pub fn find_bundled_ffmpeg() -> Option<PathBuf> { find_bundled_sidecar("ffmpeg") }
pub fn find_bundled_ffprobe() -> Option<PathBuf> { find_bundled_sidecar("ffprobe") }
// ── On-demand ffmpeg / ffprobe download ───────────────────────────────────
//
// Sources:
// macOS: evermeet.cx β€” individual .zip per binary (x86_64, runs via Rosetta on arm64)
// Linux: BtbN/FFmpeg-Builds β€” single .tar.xz with both binaries
// Windows: BtbN/FFmpeg-Builds β€” single .zip with both binaries
/// Download and cache static ffmpeg + ffprobe binaries into `dest`.
/// Idempotent: skips the download when both binaries already exist.
#[allow(unused_variables)] // `region` only used in linux/windows cfg blocks
pub fn install_ffmpeg_standalone(dest: &Path, region: &str) -> io::Result<()> {
let ffmpeg_bin = dest.join(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" });
let ffprobe_bin = dest.join(if cfg!(windows) { "ffprobe.exe" } else { "ffprobe" });
if ffmpeg_bin.is_file() && ffprobe_bin.is_file() {
return Ok(());
}
fs::create_dir_all(dest)?;
#[cfg(target_os = "macos")]
{
// Prefer native arm64 ffmpeg via Homebrew β€” always latest, includes
// ffprobe, zero Rosetta overhead on Apple Silicon.
let brew_candidates = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
let brew_path = brew_candidates.iter().find(|p| PathBuf::from(p).is_file());
if let Some(brew) = brew_path {
log::info!("Installing ffmpeg via Homebrew (native arm64)");
let status = Command::new(brew)
.args(["install", "ffmpeg"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
if matches!(status, Ok(ref s) if s.success()) {
// brew install succeeded β€” ffmpeg/ffprobe are now on PATH
// at /opt/homebrew/bin/ or /usr/local/bin/. No need to
// cache in tools/ β€” resolve_ffmpeg will find them via PATH.
return Ok(());
}
log::warn!("brew install ffmpeg failed β€” falling back to evermeet.cx");
}
// Fallback: evermeet.cx static binaries (x86_64, runs via Rosetta).
for (tool, url) in [
("ffmpeg", "https://evermeet.cx/ffmpeg/getrelease/zip"),
("ffprobe", "https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip"),
] {
let bin_path = dest.join(tool);
if bin_path.is_file() {
continue;
}
log::info!("Downloading {} from evermeet.cx", tool);
let zip_path = dest.join(format!("{}.zip", tool));
let resp = ureq::get(url)
.timeout(Duration::from_secs(120))
.call()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("{} download: {}", tool, e)))?;
if resp.status() != 200 {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("{} download HTTP {}", tool, resp.status()),
));
}
let mut zip_file = fs::File::create(&zip_path)?;
io::copy(&mut resp.into_reader(), &mut zip_file)?;
drop(zip_file);
let status = Command::new("unzip")
.args(["-o", "-j"])
.arg(&zip_path)
.arg("-d")
.arg(dest)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
let _ = fs::remove_file(&zip_path);
if !status.success() {
return Err(io::Error::new(io::ErrorKind::Other, format!("unzip {} failed", tool)));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&bin_path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&bin_path, perms);
}
}
}
return Ok(());
}
#[cfg(target_os = "linux")]
{
let url = resolve_github_url(
&format!(
"https://github.com/BtbN/FFmpeg-Builds/releases/download/{ver}/ffmpeg-master-{ver}-linux64-gpl.tar.xz",
ver = FFMPEG_BTBN_VERSION,
),
region,
);
log::info!("Downloading ffmpeg from BtbN (linux64) β€” version={}", FFMPEG_BTBN_VERSION);
let archive_path = dest.join("ffmpeg.tar.xz");
let resp = ureq::get(&url)
.timeout(Duration::from_secs(300))
.call()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("ffmpeg download: {}", e)))?;
if resp.status() != 200 {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("ffmpeg download HTTP {}", resp.status()),
));
}
let mut archive_file = fs::File::create(&archive_path)?;
io::copy(&mut resp.into_reader(), &mut archive_file)?;
drop(archive_file);
let status = Command::new("tar")
.args(["-xJf"])
.arg(&archive_path)
.arg("-C")
.arg(dest)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
let _ = fs::remove_file(&archive_path);
if !status.success() {
return Err(io::Error::new(io::ErrorKind::Other, "tar -xJf ffmpeg failed"));
}
for entry in fs::read_dir(dest)? {
let entry = entry?;
let p = entry.path();
if p.is_dir() {
let bin_dir = p.join("bin");
if bin_dir.is_dir() {
for tool in ["ffmpeg", "ffprobe"] {
let src = bin_dir.join(tool);
if src.is_file() {
let dst = dest.join(tool);
let _ = fs::rename(&src, &dst).or_else(|_| {
fs::copy(&src, &dst).map(|_| ())
});
}
}
let _ = fs::remove_dir_all(&p);
break;
}
}
}
for tool in ["ffmpeg", "ffprobe"] {
let bin = dest.join(tool);
if bin.is_file() {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&bin) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&bin, perms);
}
}
}
return Ok(());
}
#[cfg(target_os = "windows")]
{
use std::io::Read;
let url = resolve_github_url(
&format!(
"https://github.com/BtbN/FFmpeg-Builds/releases/download/{ver}/ffmpeg-master-{ver}-win64-gpl.zip",
ver = FFMPEG_BTBN_VERSION,
),
region,
);
log::info!("Downloading ffmpeg from BtbN (win64) β€” version={}", FFMPEG_BTBN_VERSION);
let resp = ureq::get(&url)
.timeout(Duration::from_secs(300))
.call()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("ffmpeg download: {}", e)))?;
if resp.status() != 200 {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("ffmpeg download HTTP {}", resp.status()),
));
}
let mut buf = Vec::new();
resp.into_reader().read_to_end(&mut buf)?;
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(buf))
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("zip: {}", e)))?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("zip entry: {}", e)))?;
let name = file.name().to_string();
let basename = name.rsplit('/').next().unwrap_or(&name);
if basename == "ffmpeg.exe" || basename == "ffprobe.exe" {
let out_path = dest.join(basename);
let mut out_file = fs::File::create(&out_path)?;
io::copy(&mut file, &mut out_file)?;
}
}
return Ok(());
}
// Unsupported platform β€” not an error, caller falls back to PATH / imageio-ffmpeg.
#[allow(unreachable_code)]
Ok(())
}
/// Resolve a usable ffmpeg binary. Order: bundled sidecar β†’ cached download
/// in app_data/tools β†’ system PATH β†’ on-demand download from the internet.
pub fn resolve_ffmpeg<R: tauri::Runtime>(app: &tauri::AppHandle<R>, app_data: &Path) -> Option<PathBuf> {
if let Some(p) = find_bundled_ffmpeg() {
log::info!("Using bundled ffmpeg at {}", p.display());
return Some(p);
}
let tools_dir = app_data.join("tools");
let cached = tools_dir.join(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" });
if cached.is_file() {
log::info!("Using cached ffmpeg at {}", cached.display());
return Some(cached);
}
if Command::new("ffmpeg").arg("-version").stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) {
log::info!("Using system ffmpeg from PATH");
return Some(PathBuf::from("ffmpeg"));
}
log::info!("No ffmpeg found β€” auto-installing");
match install_ffmpeg_standalone(&tools_dir, &get_effective_region(app)) {
Ok(()) => {
if cached.is_file() {
log::info!("Installed ffmpeg to {}", cached.display());
return Some(cached);
}
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"] {
if PathBuf::from(p).is_file() {
log::info!("Installed ffmpeg at {}", p);
return Some(PathBuf::from(p));
}
}
if Command::new("ffmpeg").arg("-version").stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) {
return Some(PathBuf::from("ffmpeg"));
}
log::warn!("ffmpeg install completed but binary not found");
None
}
Err(e) => {
log::warn!("ffmpeg install failed: {} β€” backend will rely on imageio-ffmpeg", e);
None
}
}
}
/// Resolve a usable ffprobe binary. Same cascade as ffmpeg, with one extra
/// step on Linux: probe the relocated .deb path at
/// `/usr/lib/omnivoice-studio/bin/ffprobe` (issue #76, see
/// `frontend/src-tauri/debian/postinst`). The bundled sidecar lookup via
/// `current_exe()` does not find this path because it lives outside the
/// binary's own directory, so we probe it explicitly here.
pub fn resolve_ffprobe<R: tauri::Runtime>(app: &tauri::AppHandle<R>, app_data: &Path) -> Option<PathBuf> {
if let Some(p) = find_bundled_ffprobe() {
log::info!("Using bundled ffprobe at {}", p.display());
return Some(p);
}
// Linux .deb install path (#76 β€” ffprobe relocated out of /usr/bin to
// avoid overwriting the system binary).
#[cfg(target_os = "linux")]
{
let deb_path = PathBuf::from("/usr/lib/omnivoice-studio/bin/ffprobe");
if deb_path.is_file() {
log::info!("Using .deb-bundled ffprobe at {}", deb_path.display());
return Some(deb_path);
}
}
let tools_dir = app_data.join("tools");
let cached = tools_dir.join(if cfg!(windows) { "ffprobe.exe" } else { "ffprobe" });
if cached.is_file() {
log::info!("Using cached ffprobe at {}", cached.display());
return Some(cached);
}
if Command::new("ffprobe").arg("-version").stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) {
log::info!("Using system ffprobe from PATH");
return Some(PathBuf::from("ffprobe"));
}
if let Ok(()) = install_ffmpeg_standalone(&tools_dir, &get_effective_region(app)) {
if cached.is_file() {
log::info!("Installed ffprobe to {}", cached.display());
return Some(cached);
}
for p in ["/opt/homebrew/bin/ffprobe", "/usr/local/bin/ffprobe"] {
if PathBuf::from(p).is_file() {
log::info!("Installed ffprobe at {}", p);
return Some(PathBuf::from(p));
}
}
if Command::new("ffprobe").arg("-version").stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) {
return Some(PathBuf::from("ffprobe"));
}
}
None
}
// ── uv resolution ─────────────────────────────────────────────────────────
/// Resolve a usable `uv` binary. Order: bundled sidecar (shipped with the
/// release installer via `bundle.externalBin`), system PATH (dev / power
/// users), or β€” last resort β€” download via the official Astral installer.
pub fn resolve_uv<R: tauri::Runtime>(
_app: &tauri::AppHandle<R>,
app_data: &Path,
progress: Option<&Arc<Mutex<BootstrapStage>>>,
) -> Result<PathBuf, String> {
if let Some(p) = find_bundled_uv() {
log::info!("Using bundled uv at {}", p.display());
return Ok(p);
}
if Command::new("uv").arg("--version").output().is_ok() {
log::info!("Using system uv from PATH");
return Ok(PathBuf::from("uv"));
}
if let Some(p) = progress {
set_stage(p, BootstrapStage::DownloadingUv { percent: None });
}
install_uv_standalone(&app_data.join("tools"), &get_effective_region(_app))
.map_err(|e| format!("uv install failed: {}", e))
}
/// Install `uv` using the **official Astral installer scripts**.
///
/// Unix: `curl -LsSf https://astral.sh/uv/{version}/install.sh | sh`
/// Windows: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/{version}/install.ps1 | iex"`
///
/// The installer handles platform detection, checksums, and extraction
/// automatically. We control the install directory via `UV_INSTALL_DIR`.
/// Idempotent: if the binary is already present, returns its path immediately.
fn install_uv_standalone(dest: &Path, _region: &str) -> io::Result<PathBuf> {
let uv_bin = dest.join(if cfg!(windows) { "uv.exe" } else { "uv" });
if uv_bin.is_file() {
return Ok(uv_bin);
}
fs::create_dir_all(dest)?;
log::info!("Installing uv {} via official installer into {}", UV_VERSION, dest.display());
#[cfg(unix)]
{
let status = Command::new("sh")
.args([
"-c",
&format!(
"curl -LsSf https://astral.sh/uv/{}/install.sh | sh -s -- --no-modify-path",
UV_VERSION
),
])
.env("UV_INSTALL_DIR", dest)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.status()
.map_err(|e| io::Error::new(
io::ErrorKind::Other,
format!("uv installer launch failed (is curl installed?): {}", e),
))?;
if !status.success() {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("uv installer exited with code {:?}", status.code()),
));
}
}
#[cfg(windows)]
{
let script = format!(
"irm https://astral.sh/uv/{}/install.ps1 | iex",
UV_VERSION
);
let status = Command::new("powershell")
.args(["-ExecutionPolicy", "ByPass", "-c", &script])
.env("UV_INSTALL_DIR", dest)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.status()
.map_err(|e| io::Error::new(
io::ErrorKind::Other,
format!("uv PowerShell installer failed: {}", e),
))?;
if !status.success() {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("uv installer exited with code {:?}", status.code()),
));
}
}
if uv_bin.is_file() {
log::info!("uv installed successfully at {}", uv_bin.display());
Ok(uv_bin)
} else {
let alt = dest.join("bin").join(if cfg!(windows) { "uv.exe" } else { "uv" });
if alt.is_file() {
fs::rename(&alt, &uv_bin)?;
log::info!("uv moved from bin/ to {}", uv_bin.display());
return Ok(uv_bin);
}
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("uv binary not found at {} after installer completed", uv_bin.display()),
))
}
}