refactor(backend): migrate to tauri v2 sidecars and resolve pipe deadlocks

- Replace tokio::process with tauri_plugin_shell sidecar API for native cross-compilation bundle execution.
- Implement non-blocking stdout/stderr multiplexing in yt-dlp spawn to prevent OS pipe buffer deadlocks.
- Refactor capabilities to restrict execution explicitly to sidecars instead of wildcards.
- Rename local binaries to strictly adhere to target-triple architecture suffixes.
- Drop manual get_binary_name path resolutions in favor of Tauri's externalBin bundler.
This commit is contained in:
NimBold
2026-06-16 11:08:26 +03:30
parent fde5eaacba
commit a40e6cfef8
6 changed files with 259 additions and 151 deletions
+57
View File
@@ -0,0 +1,57 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
// Maps Node's os.arch() to Rust's target_arch
const archMap = {
'x64': 'x86_64',
'arm64': 'aarch64'
};
// Maps Node's os.platform() to Rust's target_os/target_env
const platformMap = {
'darwin': 'apple-darwin',
'win32': 'pc-windows-msvc',
'linux': 'unknown-linux-gnu'
};
const currentArch = archMap[os.arch()];
const currentPlatform = platformMap[os.platform()];
if (!currentArch || !currentPlatform) {
console.error(`Unsupported architecture or platform: ${os.arch()} / ${os.platform()}`);
process.exit(1);
}
const targetTriple = `${currentArch}-${currentPlatform}`;
const isWindows = os.platform() === 'win32';
const ext = isWindows ? '.exe' : '';
const suffix = `-${targetTriple}${ext}`;
const binariesDir = path.join(__dirname, '..', 'src-tauri', 'binaries');
const requiredBinaries = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
console.log(`Verifying target sidecars for: ${targetTriple}`);
let missing = false;
for (const bin of requiredBinaries) {
const expectedName = `${bin}${suffix}`;
const binPath = path.join(binariesDir, expectedName);
if (!fs.existsSync(binPath)) {
console.error(`[ERROR] Missing strictly required sidecar: ${expectedName} in src-tauri/binaries/`);
missing = true;
} else {
console.log(`[OK] Found sidecar: ${expectedName}`);
}
}
if (missing) {
console.error('\nPlease download or build the missing target triple binaries and place them in the src-tauri/binaries directory.');
console.error('Build blocked due to missing architecture-aware sidecars.');
process.exit(1);
}
console.log('All required sidecars are present.');
process.exit(0);
+64
View File
@@ -1256,6 +1256,7 @@ dependencies = [
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-shell",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
@@ -3032,6 +3033,16 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -4065,12 +4076,44 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "shared_child"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
"sigchld",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "sigchld"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
dependencies = [
"libc",
"os_pipe",
"signal-hook",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.8" version = "1.4.8"
@@ -4631,6 +4674,27 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b"
dependencies = [
"encoding_rs",
"log 0.4.32",
"open",
"os_pipe",
"regex 1.12.4",
"schemars 0.8.22",
"serde",
"serde_json",
"shared_child",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
]
[[package]] [[package]]
name = "tauri-plugin-single-instance" name = "tauri-plugin-single-instance"
version = "2.4.2" version = "2.4.2"
+1
View File
@@ -26,6 +26,7 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] } tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] } tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
+23 -1
View File
@@ -9,6 +9,28 @@
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"notification:default", "notification:default",
"notification:allow-is-permission-granted" "notification:allow-is-permission-granted",
"shell:allow-execute",
{
"identifier": "shell:allow-execute",
"allow": [
{
"cmd": "yt-dlp",
"args": true
},
{
"cmd": "aria2c",
"args": true
},
{
"cmd": "ffmpeg",
"args": true
},
{
"cmd": "deno",
"args": true
}
]
}
] ]
} }
+107 -149
View File
@@ -2,9 +2,6 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use tauri::{Manager, Emitter}; use tauri::{Manager, Emitter};
use tokio::process::Command as AsyncCommand;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use regex::Regex; use regex::Regex;
use serde::Serialize; use serde::Serialize;
use ts_rs::TS; use ts_rs::TS;
@@ -20,13 +17,7 @@ pub struct MetadataResponse {
size_bytes: u64, size_bytes: u64,
} }
fn get_binary_name(base: &str) -> String {
if cfg!(target_os = "windows") {
format!("{}.exe", base)
} else {
base.to_string()
}
}
#[tauri::command] #[tauri::command]
@@ -177,24 +168,20 @@ async fn fetch_metadata(url: String, user_agent: Option<String>, username: Optio
#[tauri::command] #[tauri::command]
async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_browser: Option<String>, username: Option<String>, password: Option<String>) -> Result<String, String> { async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_browser: Option<String>, username: Option<String>, password: Option<String>) -> Result<String, String> {
println!("fetch_media_metadata called for: {}", url); println!("fetch_media_metadata called for: {}", url);
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; use tauri_plugin_shell::ShellExt;
let ytdlp_path = resource_dir.join("binaries").join(get_binary_name("yt-dlp")); let mut cmd = app_handle.shell().sidecar("yt-dlp").map_err(|e| format!("Failed to create sidecar yt-dlp: {}", e))?;
cmd = cmd.arg("-J")
let deno_path = resource_dir.join("binaries").join(get_binary_name("deno"));
let mut cmd = AsyncCommand::new(&ytdlp_path);
cmd.arg("-J")
.arg("--no-warnings") .arg("--no-warnings")
.arg("--no-playlist") .arg("--no-playlist")
.arg("--no-check-formats") .arg("--no-check-formats")
.arg("--socket-timeout").arg("20") .arg("--socket-timeout").arg("20")
.arg("--retries").arg("3") .arg("--retries").arg("3")
.arg("--extractor-retries").arg("3") .arg("--extractor-retries").arg("3")
.arg("--compat-options").arg("no-youtube-unavailable-videos") .arg("--compat-options").arg("no-youtube-unavailable-videos");
.arg("--js-runtimes").arg(format!("deno:{}", deno_path.display()));
if let Some(browser) = cookie_browser { if let Some(browser) = cookie_browser {
if !browser.is_empty() { if !browser.is_empty() {
cmd.arg("--cookies-from-browser").arg(&browser); cmd = cmd.arg("--cookies-from-browser").arg(&browser);
} }
} }
@@ -214,10 +201,10 @@ async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_
config_file.write_all(config_content.as_bytes()).map_err(|e| e.to_string())?; config_file.write_all(config_content.as_bytes()).map_err(|e| e.to_string())?;
let config_path = config_file.into_temp_path(); let config_path = config_file.into_temp_path();
if !config_content.is_empty() { if !config_content.is_empty() {
cmd.arg("--config-location").arg(&config_path); cmd = cmd.arg("--config-location").arg(&config_path);
} }
cmd.arg("--").arg(&url); cmd = cmd.arg("--").arg(&url);
// We use tokio AsyncCommand so it doesn't block the async thread // We use tokio AsyncCommand so it doesn't block the async thread
let output = cmd.output() let output = cmd.output()
@@ -236,11 +223,10 @@ async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_
#[tauri::command] #[tauri::command]
async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result<String, String> { async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result<String, String> {
println!("test_ytdlp called!"); println!("test_ytdlp called!");
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; use tauri_plugin_shell::ShellExt;
let ytdlp_path = resource_dir.join("binaries").join(get_binary_name("yt-dlp"));
println!("Resolved yt-dlp path: {:?}", ytdlp_path); let output = app_handle.shell().sidecar("yt-dlp")
.map_err(|e| e.to_string())?
let output = AsyncCommand::new(&ytdlp_path)
.arg("--version") .arg("--version")
.output() .output()
.await .await
@@ -249,7 +235,7 @@ async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result<String, String> {
format!("Failed to execute yt-dlp: {}", e) format!("Failed to execute yt-dlp: {}", e)
})?; })?;
println!("yt-dlp execution finished with status: {}", output.status); println!("yt-dlp execution finished with status: {:?}", output.status);
if output.status.success() { if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!("yt-dlp output: {}", text); println!("yt-dlp output: {}", text);
@@ -264,11 +250,10 @@ async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result<String, String> {
#[tauri::command] #[tauri::command]
async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result<String, String> { async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result<String, String> {
println!("test_ffmpeg called!"); println!("test_ffmpeg called!");
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; use tauri_plugin_shell::ShellExt;
let ffmpeg_path = resource_dir.join("binaries").join(get_binary_name("ffmpeg"));
println!("Resolved ffmpeg path: {:?}", ffmpeg_path);
let output = AsyncCommand::new(&ffmpeg_path) let output = app_handle.shell().sidecar("ffmpeg")
.map_err(|e| e.to_string())?
.arg("-version") .arg("-version")
.output() .output()
.await .await
@@ -277,7 +262,7 @@ async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result<String, String> {
format!("Failed to execute ffmpeg: {}", e) format!("Failed to execute ffmpeg: {}", e)
})?; })?;
println!("ffmpeg execution finished with status: {}", output.status); println!("ffmpeg execution finished with status: {:?}", output.status);
if output.status.success() { if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
let first_line = text.lines().next().unwrap_or("").to_string(); let first_line = text.lines().next().unwrap_or("").to_string();
@@ -295,11 +280,10 @@ async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result<String, String> {
#[tauri::command] #[tauri::command]
async fn test_deno(app_handle: tauri::AppHandle) -> Result<String, String> { async fn test_deno(app_handle: tauri::AppHandle) -> Result<String, String> {
println!("test_deno called!"); println!("test_deno called!");
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; use tauri_plugin_shell::ShellExt;
let deno_path = resource_dir.join("binaries").join(get_binary_name("deno"));
println!("Resolved deno path: {:?}", deno_path);
let output = AsyncCommand::new(&deno_path) let output = app_handle.shell().sidecar("deno")
.map_err(|e| e.to_string())?
.arg("--version") .arg("--version")
.output() .output()
.await .await
@@ -308,7 +292,7 @@ async fn test_deno(app_handle: tauri::AppHandle) -> Result<String, String> {
format!("Failed to execute deno: {}", e) format!("Failed to execute deno: {}", e)
})?; })?;
println!("deno execution finished with status: {}", output.status); println!("deno execution finished with status: {:?}", output.status);
if output.status.success() { if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
let re = regex::Regex::new(r"deno\s+(\d+\.\d+\.\d+)").unwrap(); let re = regex::Regex::new(r"deno\s+(\d+\.\d+\.\d+)").unwrap();
@@ -383,49 +367,19 @@ async fn show_in_folder(app: tauri::AppHandle, path: String) -> Result<(), Strin
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
struct Aria2DaemonGuard(std::sync::Mutex<Option<std::process::Child>>); struct Aria2DaemonGuard(std::sync::Mutex<Option<tauri_plugin_shell::process::CommandChild>>);
impl Drop for Aria2DaemonGuard { impl Drop for Aria2DaemonGuard {
fn drop(&mut self) { fn drop(&mut self) {
if let Ok(mut lock) = self.0.lock() { if let Ok(mut lock) = self.0.lock() {
if let Some(mut child) = lock.take() { if let Some(child) = lock.take() {
let _ = child.kill(); let _ = child.kill();
} }
} }
} }
} }
#[cfg(target_os = "macos")]
fn resign_aria2_debug_bundle(aria2c_path: &std::path::Path) -> Result<(), String> {
let lib_dir = aria2c_path
.parent()
.ok_or_else(|| "aria2 resource directory is missing".to_string())?
.join("aria2-libs");
let mut libraries = std::fs::read_dir(&lib_dir)
.map_err(|error| format!("Failed to read aria2 libraries: {error}"))?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|extension| extension.to_str()) == Some("dylib"))
.collect::<Vec<_>>();
libraries.sort();
for path in libraries.into_iter().chain(std::iter::once(aria2c_path.to_path_buf())) {
let output = std::process::Command::new("/usr/bin/codesign")
.args(["--force", "--sign", "-"])
.arg(&path)
.output()
.map_err(|error| format!("Failed to run codesign for {}: {error}", path.display()))?;
if !output.status.success() {
return Err(format!(
"Failed to sign {}: {}",
path.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
}
Ok(())
}
pub mod download; pub mod download;
#[allow(dead_code)] #[allow(dead_code)]
@@ -800,9 +754,6 @@ pub(crate) async fn start_media_download_internal(
.unwrap_or("download") .unwrap_or("download")
.to_string(); .to_string();
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
let ytdlp_path = resource_dir.join("binaries").join(get_binary_name("yt-dlp"));
let ffmpeg_path = resource_dir.join("binaries").join(get_binary_name("ffmpeg"));
let resolved_dest = resolve_path(&destination, &app_handle); let resolved_dest = resolve_path(&destination, &app_handle);
@@ -822,11 +773,9 @@ pub(crate) async fn start_media_download_internal(
1.0 1.0
}; };
let deno_path = resource_dir.join("binaries").join(get_binary_name("deno")); use tauri_plugin_shell::ShellExt;
let mut cmd = AsyncCommand::new(&ytdlp_path); let mut cmd = app_handle.shell().sidecar("yt-dlp").map_err(|e| e.to_string())?
cmd.arg("--newline") .arg("--newline")
.arg("--ffmpeg-location")
.arg(&ffmpeg_path)
.arg("--no-check-formats") .arg("--no-check-formats")
.arg("--socket-timeout").arg("20") .arg("--socket-timeout").arg("20")
.arg("--retries").arg("3") .arg("--retries").arg("3")
@@ -836,37 +785,35 @@ pub(crate) async fn start_media_download_internal(
.arg("--concurrent-fragments").arg("4") .arg("--concurrent-fragments").arg("4")
.arg("--no-warnings") .arg("--no-warnings")
.arg("--compat-options").arg("no-youtube-unavailable-videos") .arg("--compat-options").arg("no-youtube-unavailable-videos")
.arg("--js-runtimes").arg(format!("deno:{},node", deno_path.display()))
.arg("--progress-template").arg("download:[%(progress.downloaded_bytes)s/%(progress.total_bytes)s]")
.arg("-o").arg(out_path.to_string_lossy().to_string()); .arg("-o").arg(out_path.to_string_lossy().to_string());
if let Some(limit) = speed_limit { if let Some(limit) = speed_limit {
if !limit.is_empty() { if !limit.is_empty() {
cmd.arg("--limit-rate").arg(limit); cmd = cmd.arg("--limit-rate").arg(limit);
} }
} }
if let Some(p) = proxy { if let Some(p) = proxy {
if !p.is_empty() { if !p.is_empty() {
cmd.arg("--proxy").arg(p); cmd = cmd.arg("--proxy").arg(p);
} }
} }
if let Some(mut cs) = cookie_source { if let Some(mut cs) = cookie_source {
if !cs.is_empty() && cs != "none" { if !cs.is_empty() && cs != "none" {
if cs == "safari" { cs = "safari:".to_string() } if cs == "safari" { cs = "safari:".to_string() }
cmd.arg("--cookies-from-browser").arg(cs); cmd = cmd.arg("--cookies-from-browser").arg(cs);
} }
} }
if let Some(ua) = user_agent { if let Some(ua) = user_agent {
if !ua.is_empty() { if !ua.is_empty() {
cmd.arg("--user-agent").arg(ua); cmd = cmd.arg("--user-agent").arg(ua);
} }
} }
if let Some(tries) = max_tries { if let Some(tries) = max_tries {
cmd.arg("--retries").arg(tries.to_string()); cmd = cmd.arg("--retries").arg(tries.to_string());
} }
let mut config_file = tempfile::Builder::new().prefix("ytdlp-").suffix(".conf").tempfile().map_err(|e| e.to_string())?; let mut config_file = tempfile::Builder::new().prefix("ytdlp-").suffix(".conf").tempfile().map_err(|e| e.to_string())?;
@@ -890,36 +837,33 @@ pub(crate) async fn start_media_download_internal(
config_file.write_all(config_content.as_bytes()).map_err(|e| e.to_string())?; config_file.write_all(config_content.as_bytes()).map_err(|e| e.to_string())?;
let config_path = config_file.into_temp_path(); let config_path = config_file.into_temp_path();
if !config_content.is_empty() { if !config_content.is_empty() {
cmd.arg("--config-location").arg(&config_path); cmd = cmd.arg("--config-location").arg(config_path.to_string_lossy().to_string());
} }
if let Some(format) = format_selector { if let Some(format) = format_selector {
cmd.arg("-f").arg(format); cmd = cmd.arg("-f").arg(format);
// If the filename implies an audio format, use it as audio output // If the filename implies an audio format, use it as audio output
if safe_filename.ends_with(".mp3") { if safe_filename.ends_with(".mp3") {
cmd.arg("-x").arg("--audio-format").arg("mp3"); cmd = cmd.arg("-x").arg("--audio-format").arg("mp3");
} else if safe_filename.ends_with(".m4a") { } else if safe_filename.ends_with(".m4a") {
cmd.arg("-x").arg("--audio-format").arg("m4a"); cmd = cmd.arg("-x").arg("--audio-format").arg("m4a");
} else if safe_filename.ends_with(".opus") { } else if safe_filename.ends_with(".opus") {
cmd.arg("-x").arg("--audio-format").arg("opus"); cmd = cmd.arg("-x").arg("--audio-format").arg("opus");
} else { } else {
// Otherwise attempt to merge into mp4 or mkv based on filename // Otherwise attempt to merge into mp4 or mkv based on filename
if safe_filename.ends_with(".mp4") { if safe_filename.ends_with(".mp4") {
cmd.arg("--merge-output-format").arg("mp4"); cmd = cmd.arg("--merge-output-format").arg("mp4");
} else if safe_filename.ends_with(".webm") { } else if safe_filename.ends_with(".webm") {
cmd.arg("--merge-output-format").arg("webm"); cmd = cmd.arg("--merge-output-format").arg("webm");
} else { } else {
cmd.arg("--merge-output-format").arg("mkv"); cmd = cmd.arg("--merge-output-format").arg("mkv");
} }
} }
} }
cmd.arg("--").arg(&url); cmd = cmd.arg("--").arg(&url);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting
let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?; let (mut rx, child) = cmd.spawn().map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?;
let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
// yt-dlp parsing regex // yt-dlp parsing regex
static PCT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new(); static PCT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
@@ -931,27 +875,28 @@ pub(crate) async fn start_media_download_internal(
let eta_re = ETA_RE.get_or_init(|| Regex::new(r"ETA\s+([^\s]+)").unwrap()); let eta_re = ETA_RE.get_or_init(|| Regex::new(r"ETA\s+([^\s]+)").unwrap());
let _keep_alive = config_path; let _keep_alive = config_path;
let mut reader = BufReader::new(stdout).lines();
let mut current_track: f64 = 0.0; let mut current_track: f64 = 0.0;
let mut last_fraction: f64 = 0.0; let mut last_fraction: f64 = 0.0;
let mut last_progress_at = std::time::Instant::now() let mut last_progress_at = std::time::Instant::now()
.checked_sub(std::time::Duration::from_millis(150)) .checked_sub(std::time::Duration::from_millis(200))
.unwrap_or_else(std::time::Instant::now); .unwrap_or_else(std::time::Instant::now);
loop { loop {
tokio::select! { tokio::select! {
_ = cancel_rx.changed() => { _ = cancel_rx.changed() => {
let _ = child.kill().await; let _ = child.kill();
return Ok(()); return Ok(());
} }
line_result = reader.next_line() => { event = rx.recv() => {
match line_result { match event {
Ok(Some(line)) => { Some(tauri_plugin_shell::process::CommandEvent::Stdout(line_bytes)) => {
let line = String::from_utf8_lossy(&line_bytes);
if line.contains("[download]") && line.contains("%") { if line.contains("[download]") && line.contains("%") {
let fraction = pct_re.captures(&line) let fraction = if let Some(cap) = pct_re.captures(&line) {
.and_then(|cap| cap.get(1)) cap.get(1).and_then(|m| m.as_str().parse::<f64>().ok()).unwrap_or(0.0) / 100.0
.and_then(|m| m.as_str().parse::<f64>().ok()) } else {
.unwrap_or(0.0) / 100.0; 0.0
};
if fraction < last_fraction && (last_fraction - fraction) > 0.5 { if fraction < last_fraction && (last_fraction - fraction) > 0.5 {
current_track += 1.0; current_track += 1.0;
@@ -960,18 +905,20 @@ pub(crate) async fn start_media_download_internal(
let overall_fraction = ((current_track + fraction) / total_tracks).min(1.0); let overall_fraction = ((current_track + fraction) / total_tracks).min(1.0);
let speed = spd_re.captures(&line) let speed = if let Some(cap) = spd_re.captures(&line) {
.and_then(|cap| cap.get(1)) cap.get(1).map(|m| m.as_str().to_string()).unwrap_or_else(|| "-".to_string())
.map(|m| m.as_str().to_string()) } else {
.unwrap_or_else(|| "-".to_string()); "-".to_string()
};
let eta = eta_re.captures(&line) let eta = if let Some(cap) = eta_re.captures(&line) {
.and_then(|cap| cap.get(1)) cap.get(1).map(|m| m.as_str().to_string()).unwrap_or_else(|| "-".to_string())
.map(|m| m.as_str().to_string()) } else {
.unwrap_or_else(|| "-".to_string()); "-".to_string()
};
let now = std::time::Instant::now(); let now = std::time::Instant::now();
if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(1000) { if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) {
let _ = app_handle.emit("download-progress", DownloadProgressEvent { let _ = app_handle.emit("download-progress", DownloadProgressEvent {
id: id.to_string(), id: id.to_string(),
fraction: overall_fraction, fraction: overall_fraction,
@@ -983,22 +930,30 @@ pub(crate) async fn start_media_download_internal(
} }
} }
} }
_ => break, Some(tauri_plugin_shell::process::CommandEvent::Stderr(_line_bytes)) => {
// Consume stderr to avoid blocking
}
Some(tauri_plugin_shell::process::CommandEvent::Error(err)) => {
eprintln!("yt-dlp shell error: {}", err);
let _ = app_handle.emit("download-failed", id.to_string());
break;
}
Some(tauri_plugin_shell::process::CommandEvent::Terminated(payload)) => {
println!("child exit status: {:?}", payload.code);
if payload.code == Some(0) {
let _ = app_handle.emit("download-complete", id.to_string());
} else {
let _ = app_handle.emit("download-failed", id.to_string());
}
break;
}
Some(_) => {}
None => break,
} }
} }
} }
} }
let status = child.wait().await;
println!("child exit status: {:?}", status);
if let Ok(exit_status) = status {
if exit_status.success() {
let _ = app_handle.emit("download-complete", id.to_string());
} else {
let _ = app_handle.emit("download-failed", id.to_string());
}
}
Ok(()) Ok(())
} }
@@ -1436,34 +1391,36 @@ pub fn run() {
crate::scheduler::spawn_scheduler(app.handle().clone()); crate::scheduler::spawn_scheduler(app.handle().clone());
let resource_dir = app.path().resource_dir().unwrap(); use tauri_plugin_shell::ShellExt;
let aria2c_path = resource_dir.join("binaries").join(get_binary_name("aria2c")); let aria2_process = match app.handle().shell().sidecar("aria2c") {
Ok(cmd) => {
#[cfg(all(target_os = "macos", debug_assertions))] cmd.arg("--enable-rpc=true")
if let Err(error) = resign_aria2_debug_bundle(&aria2c_path) { .arg(format!("--rpc-listen-port={}", aria2_port))
eprintln!("{error}"); .arg(format!("--rpc-secret={}", aria2_secret))
} .arg("--rpc-listen-all=false")
.arg("--continue=true")
let aria2_process = std::process::Command::new(&aria2c_path) .arg("--allow-overwrite=false")
.arg("--enable-rpc=true") .arg("--summary-interval=1")
.arg(format!("--rpc-listen-port={}", aria2_port)) .arg("--console-log-level=warn")
.arg(format!("--rpc-secret={}", aria2_secret)) .arg("--download-result=hide")
.arg("--rpc-listen-all=false") .arg("--check-certificate=true")
.arg("--continue=true") .spawn()
.arg("--allow-overwrite=false") .map(|(_, child)| child)
.arg("--summary-interval=1") .ok()
.arg("--console-log-level=warn") }
.arg("--download-result=hide") Err(e) => {
.arg("--check-certificate=true") eprintln!("Failed to create aria2c sidecar: {}", e);
.spawn(); None
}
};
match aria2_process { match aria2_process {
Ok(process) => { Some(process) => {
println!("Spawned global aria2c daemon on port {}", aria2_port); println!("Spawned global aria2c daemon on port {}", aria2_port);
let guard = app.state::<Aria2DaemonGuard>(); let guard = app.state::<Aria2DaemonGuard>();
*guard.0.lock().unwrap() = Some(process); *guard.0.lock().unwrap() = Some(process);
} }
Err(e) => eprintln!("Failed to spawn aria2c daemon: {}", e), None => eprintln!("Failed to spawn aria2c daemon"),
} }
let app_handle_ws = app.handle().clone(); let app_handle_ws = app.handle().clone();
@@ -1574,6 +1531,7 @@ pub fn run() {
Ok(()) Ok(())
}) })
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.on_window_event(|window, event| { .on_window_event(|window, event| {
+7 -1
View File
@@ -6,7 +6,7 @@
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build", "beforeBuildCommand": "node ../scripts/verify-binaries.js && npm run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
@@ -38,6 +38,12 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"externalBin": [
"binaries/yt-dlp",
"binaries/aria2c",
"binaries/ffmpeg",
"binaries/deno"
],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",