Skip to main content

mangofetch_core/core/
dependencies.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4use std::process::Stdio;
5use sha2::{Digest, Sha256};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ResolvedDependencies {
9    pub ytdlp: Option<PathBuf>,
10    pub ffmpeg: Option<PathBuf>,
11}
12
13pub async fn ensure_dependencies(
14    force: bool,
15    reporter: Option<crate::core::traits::SharedReporter>,
16) -> Result<ResolvedDependencies> {
17    let rep_ref = reporter.as_ref().map(|r| r.as_ref());
18
19    if let Some(r) = rep_ref {
20        r.on_system_progress("Checking dependencies", 0.0, "Starting...");
21    }
22
23    if force {
24        tracing::info!("Force updating dependencies...");
25        // Reset caches to ensure we don't return old paths
26        crate::core::ytdlp::reset_ytdlp_cache();
27        crate::core::ytdlp::reset_ffmpeg_location_cache();
28        crate::core::ytdlp::reset_js_runtime_cache();
29
30        // Re-download yt-dlp
31        let ytdlp = crate::core::ytdlp::force_update_ytdlp(rep_ref).await.ok();
32
33        // Re-download ffmpeg
34        let ffmpeg = download_ffmpeg(rep_ref).await.ok();
35
36        if let Some(r) = rep_ref {
37            r.on_system_progress("Update complete", 100.0, "Ready");
38        }
39
40        return Ok(ResolvedDependencies { ytdlp, ffmpeg });
41    }
42
43    let ytdlp = crate::core::ytdlp::ensure_ytdlp(rep_ref).await.ok();
44    let ffmpeg = ensure_ffmpeg(rep_ref).await.ok();
45
46    if let Some(r) = rep_ref {
47        r.on_system_progress("Dependencies ready", 100.0, "Ready");
48    }
49
50    Ok(ResolvedDependencies { ytdlp, ffmpeg })
51}
52
53/// Return true when the runtime is expected to avoid network auto-downloads.
54/// This is driven by the MANGOFETCH_OFFLINE env var (set to "1" or "true").
55pub fn is_offline_mode() -> bool {
56    std::env::var("MANGOFETCH_OFFLINE")
57        .map(|v| {
58            let s = v.to_ascii_lowercase();
59            s == "1" || s == "true"
60        })
61        .unwrap_or(false)
62}
63
64/// Verify file at `path` matches expected sha256 hex string.
65pub fn verify_sha256(path: &PathBuf, expected_hex: &str) -> anyhow::Result<bool> {
66    use std::fs::File;
67    use std::io::Read;
68
69    let mut file = File::open(path)?;
70    let mut hasher = Sha256::new();
71    let mut buf = [0u8; 8192];
72    loop {
73        let n = file.read(&mut buf)?;
74        if n == 0 {
75            break;
76        }
77        hasher.update(&buf[..n]);
78    }
79    let hash = hasher.finalize();
80    let hex = hex::encode(hash);
81    Ok(hex.eq_ignore_ascii_case(expected_hex))
82}
83
84/// Read expected hash for a tool from app_data_dir/tool_hashes.json
85pub fn read_expected_hash(tool: &str) -> Option<String> {
86    let data_dir = crate::core::paths::app_data_dir()?;
87    let file = data_dir.join("tool_hashes.json");
88    let s = std::fs::read_to_string(&file).ok()?;
89    let map: serde_json::Value = serde_json::from_str(&s).ok()?;
90    map.get(tool).and_then(|v| v.as_str()).map(|s| s.to_string())
91}
92
93use anyhow::anyhow;
94
95pub fn is_flatpak() -> bool {
96    std::path::Path::new("/.flatpak-info").exists() || std::env::var("FLATPAK_ID").is_ok()
97}
98
99fn managed_bin_dir() -> Option<PathBuf> {
100    Some(crate::core::paths::app_data_dir()?.join("bin"))
101}
102
103pub fn bin_name(tool: &str) -> String {
104    if cfg!(target_os = "windows") {
105        format!("{}.exe", tool)
106    } else {
107        tool.to_string()
108    }
109}
110
111pub async fn find_tool(tool: &str) -> Option<PathBuf> {
112    let _timer_start = std::time::Instant::now();
113    let name = bin_name(tool);
114    let version_flag = version_flag_for(tool);
115
116    #[cfg(target_os = "linux")]
117    {
118        let flatpak_path = PathBuf::from("/app/bin").join(&name);
119        if flatpak_path.exists() {
120            tracing::debug!(
121                "[perf] find_tool({}) took {:?}",
122                tool,
123                _timer_start.elapsed()
124            );
125            return Some(flatpak_path);
126        }
127    }
128
129    // Check managed bin dir first — managed binaries are known-good.
130    let managed = managed_bin_dir().map(|d| d.join(&name));
131    if let Some(ref managed_path) = managed {
132        if managed_path.exists() {
133            let check = {
134                let managed = managed_path.clone();
135                let vf = version_flag.to_string();
136                tokio::task::spawn_blocking(move || {
137                    crate::core::process::std_command(&managed)
138                        .arg(&vf)
139                        .stdout(Stdio::null())
140                        .stderr(Stdio::null())
141                        .status()
142                        .ok()
143                        .filter(|s| s.success())
144                })
145                .await
146                .ok()
147                .flatten()
148            };
149
150            if check.is_some() {
151                tracing::debug!(
152                    "[perf] find_tool({}) took {:?}",
153                    tool,
154                    _timer_start.elapsed()
155                );
156                return Some(managed_path.clone());
157            }
158            tracing::warn!(
159                "find_tool({}): binary exists at {} but failed to execute",
160                tool,
161                managed_path.display()
162            );
163        }
164    }
165
166    // Fall back to system PATH. Resolve to an absolute path so callers
167    // (e.g. find_ffmpeg_location) can derive the parent directory.
168    let result = {
169        let name = name.clone();
170        let vf = version_flag.to_string();
171        tokio::task::spawn_blocking(move || {
172            crate::core::process::std_command(&name)
173                .arg(&vf)
174                .stdout(Stdio::null())
175                .stderr(Stdio::null())
176                .status()
177                .ok()
178                .filter(|s| s.success())
179        })
180        .await
181        .ok()
182        .flatten()
183    };
184
185    if result.is_some() {
186        let abs = resolve_absolute_path(&name);
187        tracing::debug!(
188            "[perf] find_tool({}) took {:?}",
189            tool,
190            _timer_start.elapsed()
191        );
192        return Some(abs);
193    }
194
195    tracing::debug!(
196        "[perf] find_tool({}) took {:?}",
197        tool,
198        _timer_start.elapsed()
199    );
200    None
201}
202
203/// Resolve a bare binary name to its absolute path via `where` (Windows)
204/// or `which` (Unix). Returns the original name as fallback.
205fn resolve_absolute_path(bin_name: &str) -> PathBuf {
206    let finder = if cfg!(target_os = "windows") {
207        "where"
208    } else {
209        "which"
210    };
211    if let Ok(output) = crate::core::process::std_command(finder)
212        .arg(bin_name)
213        .stdout(std::process::Stdio::piped())
214        .stderr(std::process::Stdio::null())
215        .output()
216    {
217        if output.status.success() {
218            if let Some(line) = String::from_utf8_lossy(&output.stdout).lines().next() {
219                let path = line.trim();
220                if !path.is_empty() {
221                    return PathBuf::from(path);
222                }
223            }
224        }
225    }
226    PathBuf::from(bin_name)
227}
228
229fn version_flag_for(tool: &str) -> &'static str {
230    match tool {
231        "ffmpeg" | "ffprobe" => "-version",
232        _ => "--version",
233    }
234}
235
236pub fn parse_version_output(tool: &str, stdout: &str) -> Option<String> {
237    let first_line = stdout.lines().next().unwrap_or("");
238
239    if tool == "ffmpeg" || tool == "ffprobe" {
240        first_line.split_whitespace().nth(2).map(|s| s.to_string())
241    } else if tool == "yt-dlp" {
242        if first_line.trim().is_empty() {
243            None
244        } else {
245            Some(first_line.trim().to_string())
246        }
247    } else if tool == "aria2c" {
248        first_line.split_whitespace().nth(2).map(|s| s.to_string())
249    } else {
250        if first_line.trim().is_empty() {
251            None
252        } else {
253            Some(first_line.trim().to_string())
254        }
255    }
256}
257
258pub async fn check_version(tool: &str) -> Option<String> {
259    let _timer_start = std::time::Instant::now();
260    let path = find_tool(tool).await?;
261    let version_flag = version_flag_for(tool);
262    let output = {
263        let path = path.clone();
264        let vf = version_flag.to_string();
265        tokio::task::spawn_blocking(move || {
266            crate::core::process::std_command(&path)
267                .arg(&vf)
268                .stdout(Stdio::piped())
269                .stderr(Stdio::piped())
270                .output()
271        })
272        .await
273        .ok()?
274        .ok()?
275    };
276
277    if !output.status.success() {
278        tracing::debug!(
279            "[perf] check_version({}) took {:?}",
280            tool,
281            _timer_start.elapsed()
282        );
283        return None;
284    }
285
286    let stdout = String::from_utf8_lossy(&output.stdout);
287    let result = parse_version_output(tool, &stdout);
288
289    tracing::debug!(
290        "[perf] check_version({}) took {:?}",
291        tool,
292        _timer_start.elapsed()
293    );
294    result
295}
296
297pub async fn ensure_ffmpeg(
298    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
299) -> anyhow::Result<PathBuf> {
300    // Always ensure the managed binary exists — the standalone yt-dlp.exe
301    // cannot discover system FFmpeg from PATH.
302    if !is_flatpak() {
303        let managed = managed_bin_dir().map(|d| d.join(bin_name("ffmpeg")));
304        if managed.as_ref().is_none_or(|p| !p.exists()) {
305            if let Ok(path) = download_ffmpeg(_reporter).await {
306                crate::core::ytdlp::reset_ffmpeg_location_cache();
307                return Ok(path);
308            }
309        }
310    }
311
312    if let Some(path) = find_tool("ffmpeg").await {
313        return Ok(path);
314    }
315    if is_flatpak() {
316        return Err(anyhow!("FFmpeg not found in Flatpak sandbox"));
317    }
318    let path = download_ffmpeg(_reporter).await?;
319    crate::core::ytdlp::reset_ffmpeg_location_cache();
320    Ok(path)
321}
322
323async fn download_ffmpeg(
324    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
325) -> anyhow::Result<PathBuf> {
326    if is_offline_mode() {
327        return Err(anyhow!("Offline mode enabled: automatic FFmpeg download disabled"));
328    }
329    let bin_dir = managed_bin_dir().ok_or_else(|| anyhow!("Could not determine data directory"))?;
330    std::fs::create_dir_all(&bin_dir)?;
331
332    let ffmpeg_name = bin_name("ffmpeg");
333    let ffprobe_name = bin_name("ffprobe");
334    let ffmpeg_target = bin_dir.join(&ffmpeg_name);
335
336    let downloads = ffmpeg_download_urls();
337
338    for (url, archive_type) in downloads {
339        tracing::info!("Downloading FFmpeg component from {}", url);
340        let bytes = crate::core::http_client::download_with_progress(url, |percent| {
341            if let Some(r) = _reporter {
342                r.on_system_progress("ffmpeg", percent, "Downloading FFmpeg...");
343            }
344        })
345        .await?;
346
347        let temp_path = bin_dir.join(".ffmpeg_download.tmp");
348        let data = bytes.to_vec();
349        let temp_clone = temp_path.clone();
350        tokio::task::spawn_blocking(move || std::fs::write(&temp_clone, &data))
351            .await
352            .map_err(|e| anyhow!("spawn_blocking failed: {}", e))??;
353
354        let file_size = std::fs::metadata(&temp_path)?.len();
355        if file_size < 1_000_000 {
356            let _ = std::fs::remove_file(&temp_path);
357            return Err(anyhow!(
358                "Downloaded file from {} is too small ({}B) — likely an error page",
359                url,
360                file_size
361            ));
362        }
363
364        match archive_type {
365            ArchiveType::Zip => {
366                extract_zip_ffmpeg(&temp_path, &bin_dir, &ffmpeg_name, &ffprobe_name).await?
367            }
368            ArchiveType::TarXz => {
369                extract_tar_xz_ffmpeg(&temp_path, &bin_dir, &ffmpeg_name, &ffprobe_name).await?
370            }
371        }
372
373        let _ = std::fs::remove_file(&temp_path);
374    }
375
376    #[cfg(unix)]
377    {
378        use std::os::unix::fs::PermissionsExt;
379        let perms = std::fs::Permissions::from_mode(0o755);
380        let _ = std::fs::set_permissions(&ffmpeg_target, perms.clone());
381        let ffprobe_path = bin_dir.join(&ffprobe_name);
382        if ffprobe_path.exists() {
383            let _ = std::fs::set_permissions(&ffprobe_path, perms);
384        }
385    }
386
387    #[cfg(target_os = "macos")]
388    {
389        let ffmpeg_mac = ffmpeg_target.clone();
390        if let Err(e) = tokio::task::spawn_blocking(move || {
391            crate::core::process::std_command("xattr")
392                .args(["-d", "com.apple.quarantine"])
393                .arg(&ffmpeg_mac)
394                .output()
395        })
396        .await
397        .map_err(|e| std::io::Error::other(e.to_string()))
398        .and_then(|r| r)
399        {
400            tracing::warn!("Failed to remove quarantine from ffmpeg: {}", e);
401        }
402        let ffprobe_path = bin_dir.join(&ffprobe_name);
403        if ffprobe_path.exists() {
404            let ffprobe_mac = ffprobe_path.clone();
405            if let Err(e) = tokio::task::spawn_blocking(move || {
406                crate::core::process::std_command("xattr")
407                    .args(["-d", "com.apple.quarantine"])
408                    .arg(&ffprobe_mac)
409                    .output()
410            })
411            .await
412            .map_err(|e| std::io::Error::other(e.to_string()))
413            .and_then(|r| r)
414            {
415                tracing::warn!("Failed to remove quarantine from ffprobe: {}", e);
416            }
417        }
418    }
419
420    if !ffmpeg_target.exists() {
421        return Err(anyhow!("FFmpeg binary not found after extraction"));
422    }
423
424    // If an expected hash is present in app data, verify the assembled ffmpeg binary.
425    if let Some(expected) = read_expected_hash("ffmpeg") {
426        let target_clone = ffmpeg_target.clone();
427        let expected_clone = expected.clone();
428        let ok = tokio::task::spawn_blocking(move || verify_sha256(&target_clone, &expected_clone))
429            .await
430            .map_err(|e| anyhow!("spawn_blocking failed: {}", e))??;
431        if !ok {
432            let _ = std::fs::remove_file(&ffmpeg_target);
433            return Err(anyhow!("FFmpeg download failed SHA256 verification"));
434        }
435    }
436
437    let verify = {
438        let target = ffmpeg_target.clone();
439        tokio::task::spawn_blocking(move || {
440            crate::core::process::std_command(&target)
441                .arg("-version")
442                .stdout(Stdio::null())
443                .stderr(Stdio::null())
444                .status()
445        })
446        .await
447        .map_err(|e| anyhow!("spawn_blocking failed: {}", e))?
448    };
449    match verify {
450        Ok(s) if s.success() => {}
451        Ok(s) => {
452            return Err(anyhow!(
453                "FFmpeg installed but failed to execute (exit code {})",
454                s
455            ))
456        }
457        Err(e) => return Err(anyhow!("FFmpeg installed but failed to execute: {}", e)),
458    }
459
460    tracing::info!("FFmpeg installed to {}", ffmpeg_target.display());
461    Ok(ffmpeg_target)
462}
463
464enum ArchiveType {
465    Zip,
466    TarXz,
467}
468
469fn ffmpeg_download_urls() -> Vec<(&'static str, ArchiveType)> {
470    if cfg!(target_os = "windows") {
471        vec![(
472            "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip",
473            ArchiveType::Zip,
474        )]
475    } else if cfg!(target_os = "macos") {
476        vec![
477            (
478                "https://evermeet.cx/ffmpeg/getrelease/zip",
479                ArchiveType::Zip,
480            ),
481            (
482                "https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip",
483                ArchiveType::Zip,
484            ),
485        ]
486    } else if cfg!(target_arch = "aarch64") {
487        vec![(
488            "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz",
489            ArchiveType::TarXz,
490        )]
491    } else {
492        vec![(
493            "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz",
494            ArchiveType::TarXz,
495        )]
496    }
497}
498
499async fn extract_zip_ffmpeg(
500    archive_path: &std::path::Path,
501    bin_dir: &std::path::Path,
502    ffmpeg_name: &str,
503    ffprobe_name: &str,
504) -> anyhow::Result<()> {
505    let archive_path = archive_path.to_path_buf();
506    let bin_dir = bin_dir.to_path_buf();
507    let ffmpeg_name = ffmpeg_name.to_string();
508    let ffprobe_name = ffprobe_name.to_string();
509
510    tokio::task::spawn_blocking(move || {
511        let file = std::fs::File::open(&archive_path)
512            .map_err(|e| anyhow!("Failed to open archive: {}", e))?;
513        let mut archive =
514            zip::ZipArchive::new(file).map_err(|e| anyhow!("Failed to open zip: {}", e))?;
515
516        let targets = [ffmpeg_name.as_str(), ffprobe_name.as_str()];
517
518        for i in 0..archive.len() {
519            let mut entry = archive
520                .by_index(i)
521                .map_err(|e| anyhow!("Failed to read zip entry: {}", e))?;
522
523            let name = entry.name().to_string();
524            for target in &targets {
525                if name.ends_with(target) {
526                    let dest = bin_dir.join(target);
527                    let mut out = std::fs::File::create(&dest)?;
528                    std::io::copy(&mut entry, &mut out)?;
529                    break;
530                }
531            }
532        }
533
534        Ok::<(), anyhow::Error>(())
535    })
536    .await
537    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
538
539    Ok(())
540}
541
542async fn extract_tar_xz_ffmpeg(
543    archive_path: &std::path::Path,
544    bin_dir: &std::path::Path,
545    ffmpeg_name: &str,
546    ffprobe_name: &str,
547) -> anyhow::Result<()> {
548    let archive_path = archive_path.to_path_buf();
549    let bin_dir = bin_dir.to_path_buf();
550    let ffmpeg_name = ffmpeg_name.to_string();
551    let ffprobe_name = ffprobe_name.to_string();
552
553    tokio::task::spawn_blocking(move || {
554        let file = std::fs::File::open(&archive_path)
555            .map_err(|e| anyhow!("Failed to open archive: {}", e))?;
556        let decompressor = xz2::read::XzDecoder::new(file);
557        let mut archive = tar::Archive::new(decompressor);
558        let targets = [ffmpeg_name.as_str(), ffprobe_name.as_str()];
559
560        for entry_result in archive
561            .entries()
562            .map_err(|e| anyhow!("Failed to read tar entries: {}", e))?
563        {
564            let mut entry = entry_result.map_err(|e| anyhow!("Failed to read tar entry: {}", e))?;
565            let path = entry
566                .path()
567                .map_err(|e| anyhow!("Failed to read entry path: {}", e))?;
568            let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
569            for target in &targets {
570                if file_name == *target {
571                    let dest = bin_dir.join(target);
572                    let mut out = std::fs::File::create(&dest)?;
573                    std::io::copy(&mut entry, &mut out)?;
574                    break;
575                }
576            }
577        }
578        Ok::<(), anyhow::Error>(())
579    })
580    .await
581    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
582    Ok(())
583}
584
585// --- aria2c ---
586
587// --- deno (JS runtime for yt-dlp nsig challenge) ---
588
589/// Ensures a JavaScript runtime is available for yt-dlp's YouTube nsig
590/// challenge solver. Checks for any existing runtime first (Node.js, Deno,
591/// Bun), then auto-downloads Deno if none is found.
592pub async fn ensure_js_runtime(
593    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
594) -> Option<PathBuf> {
595    // Check system-installed runtimes first.
596    for tool in &["deno", "node", "bun"] {
597        if let Some(path) = find_tool(tool).await {
598            return Some(path);
599        }
600    }
601
602    // Check well-known install locations on Windows.
603    #[cfg(target_os = "windows")]
604    {
605        let candidates = [
606            r"C:\Program Files\nodejs\node.exe",
607            r"C:\Program Files (x86)\nodejs\node.exe",
608        ];
609        for path in &candidates {
610            let p = std::path::PathBuf::from(path);
611            if p.exists() {
612                return Some(p);
613            }
614        }
615    }
616
617    match download_deno(_reporter).await {
618        Ok(path) => Some(path),
619        Err(e) => {
620            tracing::warn!("Failed to download Deno JS runtime: {}", e);
621            None
622        }
623    }
624}
625
626async fn download_deno(
627    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
628) -> anyhow::Result<PathBuf> {
629    let bin_dir = managed_bin_dir().ok_or_else(|| anyhow!("Could not determine data directory"))?;
630    std::fs::create_dir_all(&bin_dir)?;
631
632    let deno_name = bin_name("deno");
633    let deno_target = bin_dir.join(&deno_name);
634
635    if deno_target.exists() {
636        return Ok(deno_target);
637    }
638
639    let url = if cfg!(target_os = "windows") {
640        "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-pc-windows-msvc.zip"
641    } else if cfg!(target_os = "macos") {
642        if cfg!(target_arch = "aarch64") {
643            "https://github.com/denoland/deno/releases/latest/download/deno-aarch64-apple-darwin.zip"
644        } else {
645            "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-apple-darwin.zip"
646        }
647    } else if cfg!(target_arch = "aarch64") {
648        "https://github.com/denoland/deno/releases/latest/download/deno-aarch64-unknown-linux-gnu.zip"
649    } else {
650        "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip"
651    };
652
653    tracing::info!("Downloading Deno JS runtime from {}", url);
654
655    let bytes = crate::core::http_client::download_with_progress(url, |percent| {
656        if let Some(r) = _reporter {
657            r.on_system_progress("deno", percent, "Downloading Deno...");
658        }
659    })
660    .await?;
661    let data = bytes.to_vec();
662    let bin_dir_clone = bin_dir.clone();
663    let deno_name_clone = deno_name.clone();
664
665    tokio::task::spawn_blocking(move || {
666        let cursor = std::io::Cursor::new(&data);
667        let mut archive =
668            zip::ZipArchive::new(cursor).map_err(|e| anyhow!("Failed to open Deno zip: {}", e))?;
669
670        for i in 0..archive.len() {
671            let mut file = archive
672                .by_index(i)
673                .map_err(|e| anyhow!("Failed to read zip entry: {}", e))?;
674
675            let name = file.name().to_string();
676            if name.ends_with(&deno_name_clone) || name == "deno" || name == "deno.exe" {
677                let dest = bin_dir_clone.join(&deno_name_clone);
678                let mut buf = Vec::new();
679                std::io::Read::read_to_end(&mut file, &mut buf)?;
680                std::fs::write(&dest, &buf)?;
681                break;
682            }
683        }
684
685        Ok::<(), anyhow::Error>(())
686    })
687    .await
688    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
689
690    if !deno_target.exists() {
691        return Err(anyhow!("Deno binary not found after extraction"));
692    }
693
694    #[cfg(unix)]
695    {
696        use std::os::unix::fs::PermissionsExt;
697        let _ = std::fs::set_permissions(&deno_target, std::fs::Permissions::from_mode(0o755));
698    }
699
700    #[cfg(target_os = "macos")]
701    {
702        let deno_mac = deno_target.clone();
703        let _ = tokio::task::spawn_blocking(move || {
704            crate::core::process::std_command("xattr")
705                .args(["-d", "com.apple.quarantine"])
706                .arg(&deno_mac)
707                .output()
708        })
709        .await;
710    }
711
712    tracing::info!("Deno installed to {}", deno_target.display());
713    Ok(deno_target)
714}
715
716pub async fn ensure_aria2c(
717    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
718) -> Option<PathBuf> {
719    if let Some(path) = find_tool("aria2c").await {
720        return Some(path);
721    }
722
723    // Auto-download only on Windows
724    #[cfg(target_os = "windows")]
725    {
726        match download_aria2c(_reporter).await {
727            Ok(path) => return Some(path),
728            Err(e) => {
729                tracing::warn!("Failed to download aria2c: {}", e);
730            }
731        }
732    }
733
734    None
735}
736
737#[cfg(target_os = "windows")]
738async fn download_aria2c(
739    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
740) -> anyhow::Result<PathBuf> {
741    let bin_dir = managed_bin_dir().ok_or_else(|| anyhow!("Could not determine data directory"))?;
742    std::fs::create_dir_all(&bin_dir)?;
743
744    let aria2c_name = bin_name("aria2c");
745    let aria2c_target = bin_dir.join(&aria2c_name);
746
747    let url = "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip";
748
749    let bytes = crate::core::http_client::download_with_progress(url, |percent| {
750        if let Some(r) = _reporter {
751            r.on_system_progress("aria2c", percent, "Downloading aria2c...");
752        }
753    })
754    .await?;
755
756    let data = bytes.to_vec();
757    let bin_dir_clone = bin_dir.clone();
758    let aria2c_name_clone = aria2c_name.clone();
759
760    tokio::task::spawn_blocking(move || {
761        let cursor = std::io::Cursor::new(&data);
762        let mut archive = zip::ZipArchive::new(cursor)
763            .map_err(|e| anyhow!("Failed to open aria2c zip: {}", e))?;
764
765        for i in 0..archive.len() {
766            let mut file = archive
767                .by_index(i)
768                .map_err(|e| anyhow!("Failed to read zip entry: {}", e))?;
769
770            let name = file.name().to_string();
771            if name.ends_with(&aria2c_name_clone) {
772                let dest = bin_dir_clone.join(&aria2c_name_clone);
773                let mut buf = Vec::new();
774                std::io::Read::read_to_end(&mut file, &mut buf)?;
775                std::fs::write(&dest, &buf)?;
776                break;
777            }
778        }
779
780        Ok::<(), anyhow::Error>(())
781    })
782    .await
783    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
784
785    if !aria2c_target.exists() {
786        return Err(anyhow!("aria2c binary not found after extraction"));
787    }
788
789    Ok(aria2c_target)
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795    use crate::core::events::QueueItemProgress;
796    use crate::core::traits::DownloadReporter;
797    use crate::models::queue::QueueItemInfo;
798    use crate::models::settings::ProxySettings;
799    use std::sync::Arc;
800    use std::sync::Mutex;
801
802    static TEST_MUTEX: Mutex<()> = Mutex::new(());
803
804    struct MockReporter;
805
806    impl DownloadReporter for MockReporter {
807        fn on_progress(&self, _id: u64, _prog: QueueItemProgress) {}
808        fn on_complete(&self, _id: u64, _path: Option<String>, _size: Option<u64>) {}
809        fn on_error(&self, _id: u64, _msg: String) {}
810        fn on_retry(&self, _id: u64, _attempt: u32, _delay: u64) {}
811        fn on_phase_change(&self, _id: u64, _phase: String) {}
812        fn on_media_preview(
813            &self,
814            _u: String,
815            _t: String,
816            _a: String,
817            _th: Option<String>,
818            _d: Option<f64>,
819        ) {
820        }
821        fn on_queue_update(&self, _s: Vec<QueueItemInfo>) {}
822        fn on_system_progress(&self, _title: &str, _pct: f32, _msg: &str) {}
823    }
824
825    #[test]
826    fn test_bin_name() {
827        if cfg!(target_os = "windows") {
828            assert_eq!(bin_name("test-tool"), "test-tool.exe");
829            assert_eq!(bin_name("yt-dlp"), "yt-dlp.exe");
830        } else {
831            assert_eq!(bin_name("test-tool"), "test-tool");
832            assert_eq!(bin_name("yt-dlp"), "yt-dlp");
833        }
834    }
835
836    #[test]
837    fn test_is_flatpak() {
838        let _guard = TEST_MUTEX.lock().unwrap();
839
840        let original_val = std::env::var("FLATPAK_ID");
841
842        std::env::set_var("FLATPAK_ID", "org.mangofetch.App");
843        assert!(is_flatpak(), "Should be true when FLATPAK_ID is set");
844
845        std::env::remove_var("FLATPAK_ID");
846        let expected = std::path::Path::new("/.flatpak-info").exists();
847        assert_eq!(
848            is_flatpak(),
849            expected,
850            "When FLATPAK_ID is not set, it should match the existence of /.flatpak-info"
851        );
852
853        match original_val {
854            Ok(v) => std::env::set_var("FLATPAK_ID", v),
855            Err(_) => std::env::remove_var("FLATPAK_ID"),
856        }
857    }
858
859    #[test]
860    fn test_parse_version_output() {
861        // ffmpeg
862        assert_eq!(
863            parse_version_output("ffmpeg", "ffmpeg version 2024-05-13-git-93afb9c47c-full_build-www.gyan.dev Copyright (c) 2000-2024 the FFmpeg developers"),
864            Some("2024-05-13-git-93afb9c47c-full_build-www.gyan.dev".to_string())
865        );
866        assert_eq!(
867            parse_version_output(
868                "ffmpeg",
869                "ffmpeg version N-111111-g1234567890 Copyright (c) 2000-2023 the FFmpeg developers"
870            ),
871            Some("N-111111-g1234567890".to_string())
872        );
873        assert_eq!(parse_version_output("ffmpeg", "ffmpeg version"), None);
874        assert_eq!(parse_version_output("ffmpeg", ""), None);
875
876        // ffprobe
877        assert_eq!(
878            parse_version_output("ffprobe", "ffprobe version 2024-05-13-git-93afb9c47c-full_build-www.gyan.dev Copyright (c) 2000-2024 the FFmpeg developers"),
879            Some("2024-05-13-git-93afb9c47c-full_build-www.gyan.dev".to_string())
880        );
881        assert_eq!(parse_version_output("ffprobe", "ffprobe version"), None);
882        assert_eq!(parse_version_output("ffprobe", ""), None);
883
884        // yt-dlp
885        assert_eq!(
886            parse_version_output("yt-dlp", "2024.04.09\n"),
887            Some("2024.04.09".to_string())
888        );
889        assert_eq!(
890            parse_version_output("yt-dlp", "2023.11.16"),
891            Some("2023.11.16".to_string())
892        );
893        assert_eq!(
894            parse_version_output("yt-dlp", "  2024.04.09  "),
895            Some("2024.04.09".to_string())
896        );
897        assert_eq!(parse_version_output("yt-dlp", ""), None);
898        assert_eq!(parse_version_output("yt-dlp", "   \n"), None);
899
900        // aria2c
901        assert_eq!(
902            parse_version_output(
903                "aria2c",
904                "aria2 version 1.37.0\nCopyright (C) 2006, 2019 Tatsuhiro Tsujikawa"
905            ),
906            Some("1.37.0".to_string())
907        );
908        assert_eq!(
909            parse_version_output("aria2c", "aria2 version 1.36.0"),
910            Some("1.36.0".to_string())
911        );
912        assert_eq!(parse_version_output("aria2c", "aria2 version"), None);
913        assert_eq!(parse_version_output("aria2c", ""), None);
914
915        // other / default
916        assert_eq!(
917            parse_version_output("other", "1.2.3\n"),
918            Some("1.2.3".to_string())
919        );
920        assert_eq!(
921            parse_version_output("other", "  1.2.3  "),
922            Some("1.2.3".to_string())
923        );
924        assert_eq!(parse_version_output("other", ""), None);
925        assert_eq!(parse_version_output("other", "   \n"), None);
926    }
927
928    #[tokio::test]
929    async fn test_ensure_dependencies_force_error() {
930        let _guard = TEST_MUTEX.lock().unwrap();
931
932        let reporter: Arc<dyn DownloadReporter> = Arc::new(MockReporter);
933
934        // Set an invalid proxy to force download failures
935        crate::core::http_client::init_proxy(ProxySettings {
936            enabled: true,
937            proxy_type: "http".into(),
938            host: "0.0.0.0".into(), // Unroutable IP to simulate network failure
939            port: 1,
940            username: "".into(),
941            password: "".into(),
942        });
943
944        // Test with force=true
945        let result = ensure_dependencies(true, Some(reporter.clone())).await;
946
947        // ensure_dependencies itself should still succeed because it gracefully handles download errors
948        assert!(result.is_ok());
949        let deps = result.unwrap();
950
951        // However, the missing dependencies should not have been downloaded
952        assert!(
953            deps.ytdlp.is_none(),
954            "ytdlp should be none on network error"
955        );
956        assert!(
957            deps.ffmpeg.is_none(),
958            "ffmpeg should be none on network error"
959        );
960
961        // Restore global proxy setting
962        crate::core::http_client::init_proxy(ProxySettings::default());
963    }
964
965    #[test]
966    fn test_verify_sha256_and_read_expected_hash() {
967        let _guard = TEST_MUTEX.lock().unwrap();
968
969        // Create a temporary app data dir
970        let tmp = std::env::temp_dir().join(format!("mangofetch_test_{}", uuid::Uuid::new_v4()));
971        let _ = std::fs::create_dir_all(&tmp);
972
973        // Ensure we restore env var after test
974        let original = std::env::var("MANGOFETCH_DATA_DIR");
975        std::env::set_var("MANGOFETCH_DATA_DIR", &tmp);
976
977        // Create a test file and compute its sha256
978        let file_path = tmp.join("test.bin");
979        let data = b"hello-mangofetch";
980        std::fs::write(&file_path, &data[..]).expect("write temp file");
981        let mut hasher = Sha256::new();
982        hasher.update(data);
983        let expected = hex::encode(hasher.finalize());
984
985        // verify_sha256 should succeed with the correct hash
986        assert!(verify_sha256(&file_path, &expected).unwrap());
987
988        // and fail for an incorrect hash
989        assert!(!verify_sha256(&file_path, "deadbeef").unwrap());
990
991        // Create a tool_hashes.json manifest and read_expected_hash
992        let manifest = serde_json::json!({"yt-dlp": expected});
993        let manifest_path = tmp.join("tool_hashes.json");
994        std::fs::write(&manifest_path, serde_json::to_string(&manifest).unwrap()).unwrap();
995
996        // read_expected_hash should pick up the value
997        let found = read_expected_hash("yt-dlp");
998        assert_eq!(found.as_deref(), Some(expected.as_str()));
999
1000        // Non-existent entry returns None
1001        assert!(read_expected_hash("ffmpeg").is_none());
1002
1003        // restore original env
1004        match original {
1005            Ok(v) => std::env::set_var("MANGOFETCH_DATA_DIR", v),
1006            Err(_) => std::env::remove_var("MANGOFETCH_DATA_DIR"),
1007        }
1008
1009        // cleanup
1010        let _ = std::fs::remove_file(&file_path);
1011        let _ = std::fs::remove_file(&manifest_path);
1012        let _ = std::fs::remove_dir(&tmp);
1013    }
1014}