From 0a1d367a9907d90b2c6445bd5e468a321813115a Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 17 Jun 2026 18:45:11 +0330 Subject: [PATCH] feat(engine): replace vague error strings with structured get_engine_status diagnostics Add a single Rust command returning per-engine structured status with resolved path, version, error context, stderr tail, and remediation hints. - Aria2: binary check, daemon startup error, RPC readiness, live stderr capture - yt-dlp: binary check, onedir layout validation (_internal/Python.framework) - FFmpeg / Deno: binary check, version parse - UI: Ready/Error badge + expandable technical detail panel - Old test_* commands kept for backward compatibility --- src-tauri/src/lib.rs | 307 ++++++++++++++++++++++++++++- src/bindings/EngineStatusItem.ts | 3 + src/bindings/EngineStatusResult.ts | 4 + src/components/SettingsView.tsx | 198 +++++++++++-------- src/ipc.ts | 6 +- 5 files changed, 431 insertions(+), 87 deletions(-) create mode 100644 src/bindings/EngineStatusItem.ts create mode 100644 src/bindings/EngineStatusResult.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f697a94..ab8b4b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -462,6 +462,7 @@ use std::sync::{Arc, Mutex, RwLock}; struct Aria2DaemonGuard { child: Mutex>, startup_error: Mutex>, + last_stderr: Mutex, } impl Aria2DaemonGuard { @@ -469,6 +470,7 @@ impl Aria2DaemonGuard { Self { child: Mutex::new(None), startup_error: Mutex::new(None), + last_stderr: Mutex::new(String::new()), } } } @@ -524,6 +526,33 @@ pub struct DownloadProgressEvent { size: Option, } +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct EngineStatusItem { + name: String, + kind: String, + expected_sidecar: String, + resolved_path: Option, + version: Option, + ready: bool, + error: Option, + stderr_tail: Option, + remediation_hint: Option, + rpc_port: Option, + daemon_alive: Option, + rpc_ready: Option, + last_stderr_tail: Option, + expects_internal_dir: Option, + has_internal_dir: Option, + has_python_framework: Option, +} + +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct EngineStatusResult { + pub engines: Vec, +} + pub(crate) fn resolve_path(path: &str, app_handle: &tauri::AppHandle) -> std::path::PathBuf { use tauri::Manager; @@ -672,6 +701,273 @@ async fn test_aria2c(app_handle: tauri::AppHandle, state: tauri::State<'_, AppSt .ok_or_else(|| "aria2 returned an invalid version response".to_string()) } +// ── get_engine_status: Structured engine diagnostics ────────────── + +async fn run_sidecar_version( + app_handle: &tauri::AppHandle, + sidecar_name: &str, + args: &[&str], +) -> (Option, Option, Option) { + use tauri_plugin_shell::ShellExt; + + let cmd = match app_handle.shell().sidecar(sidecar_name) { + Ok(cmd) => cmd, + Err(e) => return (None, Some(format!("Cannot create sidecar '{}': {}", sidecar_name, e)), None), + }; + let cmd = args.iter().fold(cmd, |c, arg| c.arg(arg)); + + let output = match tokio::time::timeout(std::time::Duration::from_secs(8), cmd.output()).await { + Ok(Ok(o)) => o, + Ok(Err(e)) => return (None, Some(format!("Failed to execute '{}': {}", sidecar_name, e)), None), + Err(_) => return (None, Some(format!("'{}' version check timed out", sidecar_name)), None), + }; + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stderr_tail = if stderr.is_empty() { None } else { Some(stderr.clone()) }; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (Some(stdout), None, stderr_tail) + } else { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let err = if !stderr.is_empty() { + stderr.lines().rev().take(10).collect::>().join("\n") + } else { + format!("Exited with code {:?}", output.status.code()) + }; + (if stdout.is_empty() { None } else { Some(stdout) }, Some(err), stderr_tail) + } +} + +fn generate_remediation_hint(error: &str, _kind: &str) -> Option { + let lower = error.to_lowercase(); + if lower.contains("library not loaded") || lower.contains("dylib") { + Some("A required system library is missing. Try reinstalling Firelink or run 'brew install openssl'.".to_string()) + } else if lower.contains("not found") || lower.contains("could not find") { + Some("The bundled binary file is missing. Reinstall Firelink to restore it.".to_string()) + } else if lower.contains("timed out") { + Some("The binary did not respond within the timeout. It may be damaged or incompatible with this system.".to_string()) + } else if lower.contains("permission denied") { + Some("The binary does not have execute permission. Try reinstalling Firelink.".to_string()) + } else { + None + } +} + +fn arch_suffix() -> &'static str { + if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" } +} + +async fn check_aria2(app_handle: &tauri::AppHandle, port: u16, secret: &str) -> EngineStatusItem { + let sidecar_name = "aria2c"; + let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix()); + + let resolved = resolve_bundled_binary_path(app_handle, sidecar_name); + let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string()); + + let (startup_err, daemon_stderr) = { + let guard = app_handle.state::(); + let se = guard.startup_error.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let stderr = guard.last_stderr.lock().unwrap_or_else(|e| e.into_inner()).clone(); + (se, stderr) + }; + let daemon_alive = startup_err.is_none(); + let last_stderr_tail = if daemon_stderr.is_empty() { None } else { Some(daemon_stderr) }; + + let (version_raw, run_error, stderr_tail) = run_sidecar_version(app_handle, sidecar_name, &["--version"]).await; + let version = version_raw.and_then(|v| v.lines().next().map(|l| l.trim().to_string())); + + let rpc_ready = if daemon_alive { + rpc_call(port, secret, "aria2.getVersion", serde_json::json!([])) + .await + .is_ok() + } else { + false + }; + + let error = startup_err.or(run_error); + let ready = daemon_alive && rpc_ready && version.is_some(); + let remediation_hint = error.as_ref().and_then(|e| generate_remediation_hint(e, sidecar_name)); + + EngineStatusItem { + name: "Aria2".to_string(), + kind: "aria2".to_string(), + expected_sidecar, + resolved_path, + version, + ready, + error, + stderr_tail, + remediation_hint, + rpc_port: Some(port), + daemon_alive: Some(daemon_alive), + rpc_ready: Some(rpc_ready), + last_stderr_tail, + expects_internal_dir: None, + has_internal_dir: None, + has_python_framework: None, + } +} + +async fn check_ytdlp(app_handle: &tauri::AppHandle) -> EngineStatusItem { + let sidecar_name = "yt-dlp"; + let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix()); + + let resolved = resolve_bundled_binary_path(app_handle, sidecar_name); + let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string()); + + let (expects_internal_dir, has_internal_dir, has_python_framework) = if let Some(ref path) = resolved_path { + let parent = std::path::Path::new(path).parent().map(|p| p.to_path_buf()); + if let Some(parent) = parent { + let internal = parent.join("_internal"); + let hi = internal.is_dir(); + let hp = if hi { + internal.join("Python.framework").is_dir() || internal.join("Python").exists() + } else { + false + }; + (true, hi, hp) + } else { + (true, false, false) + } + } else { + (true, false, false) + }; + + let (version_raw, run_error, stderr_tail) = run_sidecar_version(app_handle, sidecar_name, &["--version"]).await; + let version = version_raw.and_then(|v| v.lines().next().map(|l| l.trim().to_string())); + + let mut error = run_error; + let mut remediation_hint = None; + + if error.is_none() { + if !has_internal_dir { + error = Some("_internal directory was not found beside yt-dlp sidecar".to_string()); + remediation_hint = Some("The yt-dlp distribution is missing its _internal directory. Reinstall Firelink.".to_string()); + } else if !has_python_framework { + error = Some("_internal/Python.framework was not found beside yt-dlp sidecar".to_string()); + remediation_hint = Some("The yt-dlp distribution is missing its embedded Python runtime. Reinstall Firelink.".to_string()); + } + } + + if remediation_hint.is_none() { + remediation_hint = error.as_ref().and_then(|e| generate_remediation_hint(e, sidecar_name)); + } + + EngineStatusItem { + name: "yt-dlp".to_string(), + kind: "ytdlp".to_string(), + expected_sidecar, + resolved_path, + version, + ready: error.is_none(), + error, + stderr_tail, + remediation_hint, + rpc_port: None, + daemon_alive: None, + rpc_ready: None, + last_stderr_tail: None, + expects_internal_dir: Some(expects_internal_dir), + has_internal_dir: Some(has_internal_dir), + has_python_framework: Some(has_python_framework), + } +} + +async fn check_ffmpeg(app_handle: &tauri::AppHandle) -> EngineStatusItem { + let sidecar_name = "ffmpeg"; + let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix()); + + let resolved = resolve_bundled_binary_path(app_handle, sidecar_name); + let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string()); + + let (version_raw, run_error, stderr_tail) = run_sidecar_version(app_handle, sidecar_name, &["-version"]).await; + let version = version_raw.as_ref().and_then(|text| { + text.lines().next().and_then(|first| { + let parts: Vec<&str> = first.split_whitespace().collect(); + parts.get(2).map(|v| v.split('-').next().unwrap_or(v).to_string()) + }) + }); + + let error = run_error; + let remediation_hint = error.as_ref().and_then(|e| generate_remediation_hint(e, sidecar_name)); + + EngineStatusItem { + name: "FFmpeg".to_string(), + kind: "ffmpeg".to_string(), + expected_sidecar, + resolved_path, + version, + ready: error.is_none(), + error, + stderr_tail, + remediation_hint, + rpc_port: None, + daemon_alive: None, + rpc_ready: None, + last_stderr_tail: None, + expects_internal_dir: None, + has_internal_dir: None, + has_python_framework: None, + } +} + +async fn check_deno(app_handle: &tauri::AppHandle) -> EngineStatusItem { + let sidecar_name = "deno"; + let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix()); + + let resolved = resolve_bundled_binary_path(app_handle, sidecar_name); + let resolved_path = resolved.as_ref().ok().map(|p| p.to_string_lossy().to_string()); + + let (version_raw, run_error, stderr_tail) = run_sidecar_version(app_handle, sidecar_name, &["--version"]).await; + let version = version_raw.as_ref().and_then(|text| { + let re = regex::Regex::new(r"deno\s+(\d+\.\d+\.\d+)").ok()?; + re.captures(text).and_then(|c| c.get(1)).map(|m| m.as_str().to_string()) + }).or(version_raw); + + let error = run_error; + let remediation_hint = error.as_ref().and_then(|e| generate_remediation_hint(e, sidecar_name)); + + EngineStatusItem { + name: "Deno".to_string(), + kind: "deno".to_string(), + expected_sidecar, + resolved_path, + version, + ready: error.is_none(), + error, + stderr_tail, + remediation_hint, + rpc_port: None, + daemon_alive: None, + rpc_ready: None, + last_stderr_tail: None, + expects_internal_dir: None, + has_internal_dir: None, + has_python_framework: None, + } +} + +#[tauri::command] +async fn get_engine_status( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let port = state.aria2_port; + let secret = state.aria2_secret.clone(); + + let (aria2, ytdlp, ffmpeg, deno) = tokio::join!( + check_aria2(&app_handle, port, &secret), + check_ytdlp(&app_handle), + check_ffmpeg(&app_handle), + check_deno(&app_handle), + ); + + Ok(EngineStatusResult { + engines: vec![aria2, ytdlp, ffmpeg, deno], + }) +} + fn resolve_bundled_binary_path(app_handle: &tauri::AppHandle, binary_name: &str) -> Result { let full_name = format!( @@ -1751,11 +2047,20 @@ pub fn run() { let guard = app.state::(); *guard.child.lock().unwrap() = Some(child); + let daemon_app = app.handle().clone(); tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { match event { tauri_plugin_shell::process::CommandEvent::Stderr(bytes) => { let line = String::from_utf8_lossy(&bytes); + // Store for diagnostics + if let Ok(mut stderr_lock) = daemon_app.state::().last_stderr.lock() { + stderr_lock.push_str(&line); + let excess = stderr_lock.len().saturating_sub(8192); + if excess > 0 { + let _ = stderr_lock.drain(..excess); + } + } let lower = line.to_lowercase(); if lower.contains("error") || lower.contains("critical") { log::error!("aria2c stderr: {}", line.trim()); @@ -1962,7 +2267,7 @@ pub fn run() { } }) .invoke_handler(tauri::generate_handler![ - test_ytdlp, test_aria2c, test_ffmpeg, test_deno, open_file, show_in_folder, + get_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, open_file, show_in_folder, pause_download, fetch_metadata, fetch_media_metadata, update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action, request_automation_permission, open_automation_settings, diff --git a/src/bindings/EngineStatusItem.ts b/src/bindings/EngineStatusItem.ts new file mode 100644 index 0000000..3148441 --- /dev/null +++ b/src/bindings/EngineStatusItem.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type EngineStatusItem = { name: string, kind: string, expected_sidecar: string, resolved_path: string | null, version: string | null, ready: boolean, error: string | null, stderr_tail: string | null, remediation_hint: string | null, rpc_port: number | null, daemon_alive: boolean | null, rpc_ready: boolean | null, last_stderr_tail: string | null, expects_internal_dir: boolean | null, has_internal_dir: boolean | null, has_python_framework: boolean | null, }; diff --git a/src/bindings/EngineStatusResult.ts b/src/bindings/EngineStatusResult.ts new file mode 100644 index 0000000..956e426 --- /dev/null +++ b/src/bindings/EngineStatusResult.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EngineStatusItem } from "./EngineStatusItem"; + +export type EngineStatusResult = { engines: Array, }; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 5c73dd1..3f3ef58 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -13,6 +13,7 @@ import { import { open } from '@tauri-apps/plugin-dialog'; import { getVersion } from '@tauri-apps/api/app'; import { invokeCommand as invoke } from '../ipc'; +import type { EngineStatusItem } from '../bindings/EngineStatusItem'; import { WindowDragRegion } from './WindowDragRegion'; import appIcon from '../assets/app-icon.png'; @@ -33,19 +34,11 @@ export default function SettingsView() { const { pickDirectory } = useDirectoryPicker(); const activeTab = settings.activeSettingsTab; - // Local state for versions - const [aria2Version, setAria2Version] = useState('Checking...'); - const [ytdlpVersion, setYtdlpVersion] = useState('Checking...'); - const [ffmpegVersion, setFfmpegVersion] = useState('Checking...'); - const [denoVersion, setDenoVersion] = useState('Checking...'); + // Local state for engine diagnostics + const [engineStatus, setEngineStatus] = useState(null); + const [expandedEngine, setExpandedEngine] = useState(null); const [appVersion, setAppVersion] = useState('0.7.3'); - const getEngineStatus = (v: string) => { - if (v === 'Checking...') return Checking...; - if (v.startsWith('Error')) return Error / Missing; - return Ready; - }; - // Local state for adding site login const [loginPattern, setLoginPattern] = useState(''); const [loginUser, setLoginUser] = useState(''); @@ -67,24 +60,14 @@ export default function SettingsView() { getVersion().then(setAppVersion).catch(() => undefined); }, []); - // Fetch engine versions when Engine tab is opened + // Fetch engine status when Engine tab is opened useEffect(() => { if (settings.activeView === 'settings' && activeTab === 'engine') { - invoke('test_aria2c') - .then(v => setAria2Version(v)) - .catch(e => setAria2Version('Error: ' + e)); - - invoke('test_ytdlp') - .then(v => setYtdlpVersion(v)) - .catch(e => setYtdlpVersion('Error: ' + e)); - - invoke('test_ffmpeg') - .then(v => setFfmpegVersion(v)) - .catch(e => setFfmpegVersion('Error: ' + e)); - - invoke('test_deno') - .then(v => setDenoVersion(v)) - .catch(e => setDenoVersion('Error: ' + e)); + setEngineStatus(null); + setExpandedEngine(null); + invoke('get_engine_status') + .then(r => setEngineStatus(r.engines)) + .catch(() => setEngineStatus([])); } }, [settings.activeView, activeTab]); @@ -92,6 +75,52 @@ export default function SettingsView() { setToastMessage(msg); }; + const findEngine = (kind: string) => engineStatus?.find(e => e.kind === kind) ?? null; + + const renderEngineStatus = (item: EngineStatusItem | null) => { + if (!item) return Checking...; + if (item.ready) return Ready; + return Error / Missing; + }; + + const renderEngineVersion = (item: EngineStatusItem | null) => { + if (!item) return 'Checking...'; + if (item.version) return item.version; + if (item.error) return `Error: ${item.error.length > 80 ? item.error.substring(0, 80) + '…' : item.error}`; + return 'Unknown'; + }; + + const renderEngineDetails = (item: EngineStatusItem | null) => { + if (!item || item.ready || (!item.error && !item.remediation_hint && !item.stderr_tail)) return null; + const isExpanded = expandedEngine === item.kind; + return ( + <> + + {isExpanded && ( +
+ {item.resolved_path &&

Binary: {item.resolved_path}

} + {item.expected_sidecar &&

Expected: {item.expected_sidecar}

} + {item.error &&

Error: {item.error}

} + {item.remediation_hint &&

Tip: {item.remediation_hint}

} + {item.stderr_tail &&
stderr
{item.stderr_tail}
} + {item.daemon_alive != null &&

Daemon process alive: {String(item.daemon_alive)}

} + {item.rpc_ready != null &&

RPC ready: {String(item.rpc_ready)}

} + {item.rpc_port != null &&

RPC port: {item.rpc_port}

} + {item.last_stderr_tail &&
daemon stderr
{item.last_stderr_tail}
} + {item.expects_internal_dir != null &&

Expects _internal layout: {String(item.expects_internal_dir)}

} + {item.has_internal_dir != null &&

_internal directory found: {String(item.has_internal_dir)}

} + {item.has_python_framework != null &&

Python runtime found: {String(item.has_python_framework)}

} +
+ )} + + ); + }; + const handleCheckForUpdates = async () => { if (isCheckingForUpdates) return; @@ -741,64 +770,69 @@ export default function SettingsView() {

Media Downloader & Engines

-
-
-

- Core Downloader (Aria2) -

-
- Version: - {aria2Version} -
-
- Status: - {getEngineStatus(aria2Version)} -
-
+ {(() => { + const a2 = findEngine('aria2'); const yt = findEngine('ytdlp'); + const ff = findEngine('ffmpeg'); const dn = findEngine('deno'); + return ( +
+ {/* aria2 card */} +
+

+ Core Downloader (Aria2) +

+
+ Version: + {renderEngineVersion(a2)} +
+
+ Status: + {renderEngineStatus(a2)} +
+ {renderEngineDetails(a2)} +
-
-

- Media Extractors -

- -
- yt-dlp: - {ytdlpVersion} - {getEngineStatus(ytdlpVersion)} -
- -
- FFmpeg: - {ffmpegVersion} - {getEngineStatus(ffmpegVersion)} -
- -
- Deno: - {denoVersion} - {getEngineStatus(denoVersion)} -
+ {/* yt-dlp / ffmpeg / deno card */} +
+

+ Media Extractors +

+ {[ + { key: 'ytdlp', item: yt, label: 'yt-dlp' }, + { key: 'ffmpeg', item: ff, label: 'FFmpeg' }, + { key: 'deno', item: dn, label: 'Deno' }, + ].map(({ key, item, label }) => ( +
+
+ {label}: + {renderEngineVersion(item)} + {renderEngineStatus(item)} +
+ {renderEngineDetails(item)} +
+ ))} -
- - +
+ + +
+

yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.

+
-

yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.

-
-
+ ); + })()}
)} diff --git a/src/ipc.ts b/src/ipc.ts index a8ed78e..2ba5f6c 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -9,6 +9,7 @@ import type { ExtensionDownload } from './bindings/ExtensionDownload'; import type { MediaCookieSource } from './bindings/MediaCookieSource'; import type { MediaMetadata } from './bindings/MediaMetadata'; import type { MetadataResponse } from './bindings/MetadataResponse'; +import type { EngineStatusResult } from './bindings/EngineStatusResult'; import type { PostQueueAction } from './bindings/PostQueueAction'; import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome'; @@ -55,10 +56,7 @@ type CommandMap = { args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null }; result: MediaMetadata; }; - test_ytdlp: { args: undefined; result: string }; - test_aria2c: { args: undefined; result: string }; - test_ffmpeg: { args: undefined; result: string }; - test_deno: { args: undefined; result: string }; + get_engine_status: { args: undefined; result: EngineStatusResult }; open_file: { args: { path: string }; result: void }; show_in_folder: { args: { path: string }; result: void }; reveal_in_file_manager: { args: { path: string }; result: void };