Skip to main content

mangofetch_core/core/
dependencies.rs

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