// filepath: src-tauri/src/binary_manager.rs use std::fs; use std::path::PathBuf; use tauri::AppHandle; use anyhow::{Result, anyhow}; #[cfg(target_family = "unix")] use std::os::unix::fs::PermissionsExt; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use zip::ZipArchive; use std::io::Cursor; use crate::process_utils::first_non_empty_line; use crate::storage::{self}; const YT_DLP_REPO_URL: &str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download"; // Bellard's QuickJS binary releases URL const QJS_REPO_URL: &str = "https://bellard.org/quickjs/binary_releases"; // FFmpeg builds const FFMPEG_GITHUB_API: &str = "https://api.github.com/repos/BtbN/FFmpeg-Builds/releases/latest"; const FFMPEG_EVERMEET_BASE: &str = "https://evermeet.cx/ffmpeg"; #[derive(serde::Serialize, Clone, Debug)] pub struct RuntimeStatus { pub ffmpeg_source: String, pub ffmpeg_version: String, pub js_runtime_name: String, pub js_runtime_source: String, } #[derive(Clone, Debug)] pub enum FfmpegLocation { System, Managed(PathBuf), } #[derive(Clone, Debug)] pub enum JsRuntime { Deno, Node, ManagedQuickJs(PathBuf), } pub fn get_ytdlp_binary_name() -> &'static str { if cfg!(target_os = "windows") { "yt-dlp.exe" } else if cfg!(target_os = "macos") { "yt-dlp_macos" } else { "yt-dlp" } } // Target name on disk (for yt-dlp usage) pub fn get_qjs_binary_name() -> &'static str { if cfg!(target_os = "windows") { "quickjs.exe" } else { "quickjs" } } // Source name inside the zip archive (standard QuickJS naming) fn get_qjs_source_name_in_zip() -> &'static str { if cfg!(target_os = "windows") { "qjs.exe" } else { "qjs" } } // Get base directory for all binaries pub fn get_bin_dir(app: &AppHandle) -> Result { let app_data = storage::get_app_data_dir(app)?; let bin_dir = app_data.join("bin"); if !bin_dir.exists() { fs::create_dir_all(&bin_dir)?; } Ok(bin_dir) } pub fn get_ytdlp_path(app: &AppHandle) -> Result { Ok(get_bin_dir(app)?.join(get_ytdlp_binary_name())) } pub fn get_qjs_path(app: &AppHandle) -> Result { Ok(get_bin_dir(app)?.join(get_qjs_binary_name())) } pub fn get_ffmpeg_binary_name() -> &'static str { if cfg!(target_os = "windows") { "ffmpeg.exe" } else { "ffmpeg" } } pub fn get_ffmpeg_path(app: &AppHandle) -> Result { Ok(get_bin_dir(app)?.join(get_ffmpeg_binary_name())) } pub fn check_binaries(app: &AppHandle) -> bool { get_ytdlp_path(app).map(|p| p.exists()).unwrap_or(false) } // --- yt-dlp Logic --- pub async fn download_ytdlp(app: &AppHandle) -> Result { let binary_name = get_ytdlp_binary_name(); let url = format!("{}/{}", YT_DLP_REPO_URL, binary_name); let response = reqwest::get(&url).await?; if !response.status().is_success() { return Err(anyhow!("下载 yt-dlp 失败:状态 {}", response.status())); } let bytes = response.bytes().await?; let path = get_ytdlp_path(app)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } fs::write(&path, bytes)?; #[cfg(target_family = "unix")] { let mut perms = fs::metadata(&path)?.permissions(); perms.set_mode(0o755); fs::set_permissions(&path, perms)?; } #[cfg(target_os = "macos")] { // Remove quarantine attribute to allow execution on macOS std::process::Command::new("xattr") .arg("-d") .arg("com.apple.quarantine") .arg(&path) .output() .ok(); } Ok(path) } pub async fn update_ytdlp(app: &AppHandle) -> Result { let path = get_ytdlp_path(app)?; if !path.exists() { download_ytdlp(app).await?; return Ok("yt-dlp 已全新下载".to_string()); } // Use built-in update for yt-dlp let mut cmd = std::process::Command::new(&path); cmd.arg("-U"); #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); let output = cmd.output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); return Err(anyhow!("yt-dlp 更新失败:{}", stderr)); } // Update settings timestamp let mut settings = storage::load_settings(app)?; settings.last_updated = Some(chrono::Utc::now()); storage::save_settings(app, &settings)?; Ok("yt-dlp 已更新".to_string()) } pub fn get_ytdlp_version(app: &AppHandle) -> Result { let path = get_ytdlp_path(app)?; if !path.exists() { return Ok("未安装".to_string()); } let mut cmd = std::process::Command::new(&path); cmd.arg("--version"); #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); let output = cmd.output()?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } else { Ok("未知".to_string()) } } // --- QuickJS Logic --- #[derive(serde::Deserialize)] struct LatestInfo { version: String, } pub async fn download_qjs(app: &AppHandle) -> Result { // 1. Fetch LATEST.json to get version info (though filenames seem predictable-ish, version helps) // Actually, looking at the file list, Bellard uses date-based versions. // Format: quickjs-win-x86_64-YYYY-MM-DD.zip // We need to find the correct filename dynamically or parse LATEST.json if it gave filenames. // The LATEST.json content was {"version":"2024-01-13"} (example from prompt context). // So we can construct the filename: quickjs-{platform}-{version}.zip let latest_url = format!("{}/LATEST.json", QJS_REPO_URL); let latest_resp = reqwest::get(&latest_url).await?; if !latest_resp.status().is_success() { return Err(anyhow!("获取 QuickJS 版本信息失败")); } let latest_info: LatestInfo = latest_resp.json().await?; let version = latest_info.version; // Construct filename based on OS/Arch let filename = if cfg!(target_os = "windows") { format!("quickjs-win-x86_64-{}.zip", version) } else if cfg!(target_os = "macos") { // NOTE: Cosmo builds are universal/portable for Linux/Mac usually? // Based on prompt instruction: "macos download quickjs-cosmo marked file" // Bellard lists: quickjs-cosmo-YYYY-MM-DD.zip format!("quickjs-cosmo-{}.zip", version) } else { return Err(anyhow!("不支持当前操作系统的 QuickJS 自动下载")); }; let download_url = format!("{}/{}", QJS_REPO_URL, filename); let response = reqwest::get(&download_url).await?; if !response.status().is_success() { return Err(anyhow!("下载 QuickJS 失败:状态 {}", response.status())); } let bytes = response.bytes().await?; let cursor = Cursor::new(bytes); let mut archive = ZipArchive::new(cursor)?; let bin_dir = get_bin_dir(app)?; // Extract logic: The zip from Bellard usually contains a folder or just binaries. // We need to find the `qjs` binary. // Windows zip usually has `qjs.exe` // Cosmo zip usually has `qjs`? Let's search for it. let source_name = get_qjs_source_name_in_zip(); let target_name = get_qjs_binary_name(); // quickjs.exe or quickjs let mut found_exe = false; for i in 0..archive.len() { let mut file = archive.by_index(i)?; // Filenames in zip might be like "quickjs-win-x86_64-2024-01-13/qjs.exe" let name = file.name().to_string(); // Skip directories if file.is_dir() { continue; } let filename_only = name.split('/').last().unwrap_or(""); if filename_only == source_name { let mut out_file = fs::File::create(bin_dir.join(target_name))?; std::io::copy(&mut file, &mut out_file)?; found_exe = true; } else if filename_only.ends_with(".dll") { // Extract DLLs (needed for Windows MinGW builds, e.g. libwinpthread-1.dll) let mut out_file = fs::File::create(bin_dir.join(filename_only))?; std::io::copy(&mut file, &mut out_file)?; } } if !found_exe { return Err(anyhow!("在下载的压缩包中找不到 {}", source_name)); } let final_path = get_qjs_path(app)?; #[cfg(target_family = "unix")] { let mut perms = fs::metadata(&final_path)?.permissions(); perms.set_mode(0o755); fs::set_permissions(&final_path, perms)?; } #[cfg(target_os = "macos")] { // Remove quarantine attribute to allow execution on macOS std::process::Command::new("xattr") .arg("-d") .arg("com.apple.quarantine") .arg(&final_path) .output() .ok(); } Ok(final_path) } pub async fn update_qjs(app: &AppHandle) -> Result { // QuickJS doesn't have self-update, so we just re-download download_qjs(app).await?; let mut settings = storage::load_settings(app)?; settings.last_updated = Some(chrono::Utc::now()); storage::save_settings(app, &settings)?; Ok("QuickJS 已更新/安装".to_string()) } // --- FFmpeg Logic --- pub async fn download_ffmpeg(app: &AppHandle) -> Result { let bin_dir = get_bin_dir(app)?; if cfg!(target_os = "windows") { // Query GitHub releases API to find a suitable win64 zip asset let client = reqwest::Client::new(); let resp = client.get(FFMPEG_GITHUB_API) .header(reqwest::header::USER_AGENT, "stream-capture") .send() .await?; if !resp.status().is_success() { return Err(anyhow!("无法获取 FFmpeg releases 信息")); } let json: serde_json::Value = resp.json().await?; let mut download_url: Option = None; if let Some(assets) = json.get("assets").and_then(|a| a.as_array()) { for asset in assets { if let (Some(name), Some(url)) = (asset.get("name").and_then(|n| n.as_str()), asset.get("browser_download_url").and_then(|u| u.as_str())) { let lname = name.to_lowercase(); // Prefer GPL static build, avoid shared to get a single exe if lname.contains("win64") && lname.contains("gpl") && !lname.contains("shared") && lname.ends_with(".zip") { download_url = Some(url.to_string()); break; } } } if download_url.is_none() { // fallback: choose first zip asset that is NOT shared if possible for asset in assets { if let (Some(url), Some(name)) = (asset.get("browser_download_url").and_then(|u| u.as_str()), asset.get("name").and_then(|n| n.as_str())) { let lname = name.to_lowercase(); if lname.ends_with(".zip") && !lname.contains("shared") { download_url = Some(url.to_string()); break; } } } } } let url = download_url.ok_or(anyhow!("未找到适合 Windows 的 FFmpeg 发行包"))?; let resp = client.get(&url).header(reqwest::header::USER_AGENT, "stream-capture").send().await?; if !resp.status().is_success() { return Err(anyhow!("下载 FFmpeg 失败")); } let bytes = resp.bytes().await?; let mut archive = ZipArchive::new(Cursor::new(bytes))?; let mut found = false; for i in 0..archive.len() { let mut file = archive.by_index(i)?; if file.is_dir() { continue; } let name = file.name().to_string(); let filename_only = name.split('/').last().unwrap_or(""); // Only extract the executable, ignore DLLs if filename_only.eq_ignore_ascii_case("ffmpeg.exe") { let mut out_file = fs::File::create(bin_dir.join(filename_only))?; std::io::copy(&mut file, &mut out_file)?; found = true; } } if !found { return Err(anyhow!("在 FFmpeg 压缩包中未找到 ffmpeg 可执行文件")); } let final_path = get_ffmpeg_path(app)?; Ok(final_path) } else if cfg!(target_os = "macos") { // Fetch listing page and find latest ffmpeg-*.zip let resp = reqwest::get(FFMPEG_EVERMEET_BASE).await?; if !resp.status().is_success() { return Err(anyhow!("无法获取 evermeet.ffmpeg 列表")); } let body = resp.text().await?; let re = regex::Regex::new(r#"href="(ffmpeg-[0-9][^"]*\.zip)"#)?; let mut candidates: Vec<&str> = Vec::new(); for cap in re.captures_iter(&body) { if let Some(m) = cap.get(1) { candidates.push(m.as_str()); } } let filename = candidates.last().ok_or(anyhow!("未在 evermeet 找到 ffmpeg zip 文件"))?; let url = format!("{}/{}", FFMPEG_EVERMEET_BASE, filename); let resp = reqwest::get(&url).await?; if !resp.status().is_success() { return Err(anyhow!("下载 FFmpeg macOS 版本失败")); } let bytes = resp.bytes().await?; let mut archive = ZipArchive::new(Cursor::new(bytes))?; let mut found = false; for i in 0..archive.len() { let mut file = archive.by_index(i)?; if file.is_dir() { continue; } let name = file.name().to_string(); let filename_only = name.split('/').last().unwrap_or(""); if filename_only == "ffmpeg" { let mut out_file = fs::File::create(bin_dir.join("ffmpeg"))?; std::io::copy(&mut file, &mut out_file)?; found = true; } } if !found { return Err(anyhow!("在 FFmpeg 压缩包中未找到 ffmpeg 可执行文件")); } let final_path = get_ffmpeg_path(app)?; #[cfg(target_family = "unix")] { let mut perms = fs::metadata(&final_path)?.permissions(); perms.set_mode(0o755); fs::set_permissions(&final_path, perms)?; } #[cfg(target_os = "macos")] { std::process::Command::new("xattr").arg("-d").arg("com.apple.quarantine").arg(&final_path).output().ok(); } Ok(final_path) } else { Err(anyhow!("当前操作系统不支持自动下载 FFmpeg")) } } pub async fn update_ffmpeg(app: &AppHandle) -> Result { let path = get_ffmpeg_path(app)?; if !path.exists() { download_ffmpeg(app).await?; return Ok("FFmpeg 已安装".to_string()); } // Re-download to update download_ffmpeg(app).await?; // Update settings timestamp let mut settings = storage::load_settings(app)?; settings.last_updated = Some(chrono::Utc::now()); storage::save_settings(app, &settings)?; Ok("FFmpeg 已更新".to_string()) } pub fn get_ffmpeg_version(app: &AppHandle) -> Result { if let Some(version) = run_version_command("ffmpeg", "-version") { return Ok(version); } let path = get_ffmpeg_path(app)?; if !path.exists() { return Ok("未安装".to_string()); } let mut cmd = std::process::Command::new(&path); cmd.arg("-version"); #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); let output = cmd.output()?; if output.status.success() { if let Some(line) = first_non_empty_line(&output) { return Ok(line); } } // If we couldn't obtain a usable version string, treat as not installed Ok("未安装".to_string()) } pub fn get_qjs_version(app: &AppHandle) -> Result { let path = get_qjs_path(app)?; if !path.exists() { return Ok("未安装".to_string()); } let mut version_cmd = std::process::Command::new(&path); version_cmd.arg("--version"); #[cfg(target_os = "windows")] version_cmd.creation_flags(0x08000000); if let Ok(output) = version_cmd.output() { if output.status.success() { if let Some(line) = first_non_empty_line(&output) { return Ok(line); } } } let mut help_cmd = std::process::Command::new(&path); help_cmd.arg("-h"); #[cfg(target_os = "windows")] help_cmd.creation_flags(0x08000000); if let Ok(output) = help_cmd.output() { if let Some(line) = first_non_empty_line(&output) { return Ok(line); } } Ok("已安装".to_string()) } pub async fn ensure_binaries(app: &AppHandle) -> Result<()> { let ytdlp = get_ytdlp_path(app)?; if !ytdlp.exists() { download_ytdlp(app).await?; } else { #[cfg(target_os = "macos")] { std::process::Command::new("xattr") .arg("-d") .arg("com.apple.quarantine") .arg(&ytdlp) .output() .ok(); } } Ok(()) } fn run_version_command(command: &str, arg: &str) -> Option { let mut cmd = std::process::Command::new(command); cmd.arg(arg); #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); cmd.output() .ok() .filter(|output| output.status.success()) .and_then(|output| first_non_empty_line(&output)) } pub fn resolve_ffmpeg(app: &AppHandle, allow_download: bool) -> Result> { if run_version_command("ffmpeg", "-version").is_some() { return Ok(Some(FfmpegLocation::System)); } let managed = get_ffmpeg_path(app)?; if managed.exists() { return Ok(Some(FfmpegLocation::Managed(managed))); } if allow_download { return Ok(Some(FfmpegLocation::Managed(managed))); } Ok(None) } pub async fn ensure_ffmpeg_available(app: &AppHandle) -> Result> { if let Some(location) = resolve_ffmpeg(app, false)? { return Ok(Some(location)); } let path = download_ffmpeg(app).await?; Ok(Some(FfmpegLocation::Managed(path))) } pub fn resolve_js_runtime(app: &AppHandle, allow_download: bool) -> Result> { if run_version_command("deno", "--version").is_some() { return Ok(Some(JsRuntime::Deno)); } if run_version_command("node", "--version").is_some() { return Ok(Some(JsRuntime::Node)); } let managed = get_qjs_path(app)?; if managed.exists() { return Ok(Some(JsRuntime::ManagedQuickJs(managed))); } if allow_download { return Ok(Some(JsRuntime::ManagedQuickJs(managed))); } Ok(None) } pub async fn ensure_js_runtime_available(app: &AppHandle) -> Result> { if let Some(runtime) = resolve_js_runtime(app, false)? { return Ok(Some(runtime)); } let path = download_qjs(app).await?; Ok(Some(JsRuntime::ManagedQuickJs(path))) } impl FfmpegLocation { pub fn source_label(&self) -> &'static str { match self { FfmpegLocation::System => "system", FfmpegLocation::Managed(_) => "managed", } } pub fn version(&self, app: &AppHandle) -> Result { match self { FfmpegLocation::System => Ok(run_version_command("ffmpeg", "-version").unwrap_or_else(|| "未知".to_string())), FfmpegLocation::Managed(_) => get_ffmpeg_version(app), } } } impl JsRuntime { pub fn source_label(&self) -> &'static str { match self { JsRuntime::Deno | JsRuntime::Node => "system", JsRuntime::ManagedQuickJs(_) => "managed", } } pub fn display_name(&self, app: &AppHandle) -> Result { match self { JsRuntime::Deno => Ok(run_version_command("deno", "--version").unwrap_or_else(|| "deno".to_string())), JsRuntime::Node => Ok(run_version_command("node", "--version").unwrap_or_else(|| "node".to_string())), JsRuntime::ManagedQuickJs(_) => get_qjs_version(app), } } pub fn yt_dlp_argument(&self) -> String { match self { JsRuntime::Deno => "deno".to_string(), JsRuntime::Node => "node".to_string(), JsRuntime::ManagedQuickJs(path) => format!("quickjs:{}", path.to_string_lossy()), } } } pub async fn get_runtime_status(app: &AppHandle) -> Result { let ffmpeg = resolve_ffmpeg(app, false)?; let js_runtime = resolve_js_runtime(app, false)?; let (ffmpeg_source, ffmpeg_version) = match ffmpeg { Some(location) => (location.source_label().to_string(), location.version(app)?), None => ("unavailable".to_string(), "未安装".to_string()), }; let (js_runtime_name, js_runtime_source) = match js_runtime { Some(runtime) => (runtime.display_name(app)?, runtime.source_label().to_string()), None => ("未安装".to_string(), "unavailable".to_string()), }; Ok(RuntimeStatus { ffmpeg_source, ffmpeg_version, js_runtime_name, js_runtime_source, }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_binary_names() { if cfg!(target_os = "windows") { assert_eq!(get_ytdlp_binary_name(), "yt-dlp.exe"); assert_eq!(get_qjs_binary_name(), "quickjs.exe"); assert_eq!(get_qjs_source_name_in_zip(), "qjs.exe"); } else if cfg!(target_os = "macos") { assert_eq!(get_ytdlp_binary_name(), "yt-dlp_macos"); assert_eq!(get_qjs_binary_name(), "quickjs"); assert_eq!(get_qjs_source_name_in_zip(), "qjs"); } } }