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 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).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 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 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
203fn 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 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 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
585pub async fn ensure_js_runtime(
593 _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
594) -> Option<PathBuf> {
595 for tool in &["deno", "node", "bun"] {
597 if let Some(path) = find_tool(tool).await {
598 return Some(path);
599 }
600 }
601
602 #[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 #[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 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 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 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 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 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 crate::core::http_client::init_proxy(ProxySettings {
936 enabled: true,
937 proxy_type: "http".into(),
938 host: "0.0.0.0".into(), port: 1,
940 username: "".into(),
941 password: "".into(),
942 });
943
944 let result = ensure_dependencies(true, Some(reporter.clone())).await;
946
947 assert!(result.is_ok());
949 let deps = result.unwrap();
950
951 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 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 let tmp = std::env::temp_dir().join(format!("mangofetch_test_{}", uuid::Uuid::new_v4()));
971 let _ = std::fs::create_dir_all(&tmp);
972
973 let original = std::env::var("MANGOFETCH_DATA_DIR");
975 std::env::set_var("MANGOFETCH_DATA_DIR", &tmp);
976
977 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 assert!(verify_sha256(&file_path, &expected).unwrap());
987
988 assert!(!verify_sha256(&file_path, "deadbeef").unwrap());
990
991 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 let found = read_expected_hash("yt-dlp");
998 assert_eq!(found.as_deref(), Some(expected.as_str()));
999
1000 assert!(read_expected_hash("ffmpeg").is_none());
1002
1003 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 let _ = std::fs::remove_file(&file_path);
1011 let _ = std::fs::remove_file(&manifest_path);
1012 let _ = std::fs::remove_dir(&tmp);
1013 }
1014}