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 crate::core::ytdlp::reset_ytdlp_cache();
27 crate::core::ytdlp::reset_ffmpeg_location_cache();
28 crate::core::ytdlp::reset_js_runtime_cache();
29
30 let ytdlp = crate::core::ytdlp::force_update_ytdlp(rep_ref).await.ok();
32
33 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
53pub 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
64pub 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
84pub 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 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 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
205fn 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 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 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
589pub async fn ensure_js_runtime(
597 _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
598) -> Option<PathBuf> {
599 for tool in &["deno", "node", "bun"] {
601 if let Some(path) = find_tool(tool).await {
602 return Some(path);
603 }
604 }
605
606 #[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 #[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 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 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 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 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 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 crate::core::http_client::init_proxy(ProxySettings {
940 enabled: true,
941 proxy_type: "http".into(),
942 host: "0.0.0.0".into(), port: 1,
944 username: "".into(),
945 password: "".into(),
946 });
947
948 let result = ensure_dependencies(true, Some(reporter.clone())).await;
950
951 assert!(result.is_ok());
953 let deps = result.unwrap();
954
955 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 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 let tmp = std::env::temp_dir().join(format!("mangofetch_test_{}", uuid::Uuid::new_v4()));
975 let _ = std::fs::create_dir_all(&tmp);
976
977 let original = std::env::var("MANGOFETCH_DATA_DIR");
979 std::env::set_var("MANGOFETCH_DATA_DIR", &tmp);
980
981 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 assert!(verify_sha256(&file_path, &expected).unwrap());
991
992 assert!(!verify_sha256(&file_path, "deadbeef").unwrap());
994
995 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 let found = read_expected_hash("yt-dlp");
1002 assert_eq!(found.as_deref(), Some(expected.as_str()));
1003
1004 assert!(read_expected_hash("ffmpeg").is_none());
1006
1007 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 let _ = std::fs::remove_file(&file_path);
1015 let _ = std::fs::remove_file(&manifest_path);
1016 let _ = std::fs::remove_dir(&tmp);
1017 }
1018}