Files
stream-capture/src-tauri/src/binary_manager.rs
Julian Freeman bcadf36b71 op2
2026-04-19 10:26:07 -04:00

681 lines
22 KiB
Rust

// 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<PathBuf> {
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<PathBuf> {
Ok(get_bin_dir(app)?.join(get_ytdlp_binary_name()))
}
pub fn get_qjs_path(app: &AppHandle) -> Result<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<String> {
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<String> {
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<PathBuf> {
// 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<String> {
// 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<PathBuf> {
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<String> = 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<String> {
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<String> {
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<String> {
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<String> {
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<Option<FfmpegLocation>> {
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<Option<FfmpegLocation>> {
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<Option<JsRuntime>> {
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<Option<JsRuntime>> {
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<String> {
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<String> {
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<RuntimeStatus> {
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");
}
}
}