mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-28 11:37:17 +00:00
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
This commit is contained in:
+306
-1
@@ -462,6 +462,7 @@ use std::sync::{Arc, Mutex, RwLock};
|
|||||||
struct Aria2DaemonGuard {
|
struct Aria2DaemonGuard {
|
||||||
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
|
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
|
||||||
startup_error: Mutex<Option<String>>,
|
startup_error: Mutex<Option<String>>,
|
||||||
|
last_stderr: Mutex<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Aria2DaemonGuard {
|
impl Aria2DaemonGuard {
|
||||||
@@ -469,6 +470,7 @@ impl Aria2DaemonGuard {
|
|||||||
Self {
|
Self {
|
||||||
child: Mutex::new(None),
|
child: Mutex::new(None),
|
||||||
startup_error: Mutex::new(None),
|
startup_error: Mutex::new(None),
|
||||||
|
last_stderr: Mutex::new(String::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,6 +526,33 @@ pub struct DownloadProgressEvent {
|
|||||||
size: Option<String>,
|
size: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, TS)]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub struct EngineStatusItem {
|
||||||
|
name: String,
|
||||||
|
kind: String,
|
||||||
|
expected_sidecar: String,
|
||||||
|
resolved_path: Option<String>,
|
||||||
|
version: Option<String>,
|
||||||
|
ready: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
stderr_tail: Option<String>,
|
||||||
|
remediation_hint: Option<String>,
|
||||||
|
rpc_port: Option<u16>,
|
||||||
|
daemon_alive: Option<bool>,
|
||||||
|
rpc_ready: Option<bool>,
|
||||||
|
last_stderr_tail: Option<String>,
|
||||||
|
expects_internal_dir: Option<bool>,
|
||||||
|
has_internal_dir: Option<bool>,
|
||||||
|
has_python_framework: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, TS)]
|
||||||
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
pub struct EngineStatusResult {
|
||||||
|
pub engines: Vec<EngineStatusItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
pub(crate) fn resolve_path(path: &str, app_handle: &tauri::AppHandle) -> std::path::PathBuf {
|
pub(crate) fn resolve_path(path: &str, app_handle: &tauri::AppHandle) -> std::path::PathBuf {
|
||||||
use tauri::Manager;
|
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())
|
.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<String>, Option<String>, Option<String>) {
|
||||||
|
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::<Vec<_>>().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<String> {
|
||||||
|
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::<Aria2DaemonGuard>();
|
||||||
|
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<EngineStatusResult, String> {
|
||||||
|
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<std::path::PathBuf, String> {
|
fn resolve_bundled_binary_path(app_handle: &tauri::AppHandle, binary_name: &str) -> Result<std::path::PathBuf, String> {
|
||||||
let full_name = format!(
|
let full_name = format!(
|
||||||
@@ -1751,11 +2047,20 @@ pub fn run() {
|
|||||||
let guard = app.state::<Aria2DaemonGuard>();
|
let guard = app.state::<Aria2DaemonGuard>();
|
||||||
*guard.child.lock().unwrap() = Some(child);
|
*guard.child.lock().unwrap() = Some(child);
|
||||||
|
|
||||||
|
let daemon_app = app.handle().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
while let Some(event) = rx.recv().await {
|
while let Some(event) = rx.recv().await {
|
||||||
match event {
|
match event {
|
||||||
tauri_plugin_shell::process::CommandEvent::Stderr(bytes) => {
|
tauri_plugin_shell::process::CommandEvent::Stderr(bytes) => {
|
||||||
let line = String::from_utf8_lossy(&bytes);
|
let line = String::from_utf8_lossy(&bytes);
|
||||||
|
// Store for diagnostics
|
||||||
|
if let Ok(mut stderr_lock) = daemon_app.state::<Aria2DaemonGuard>().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();
|
let lower = line.to_lowercase();
|
||||||
if lower.contains("error") || lower.contains("critical") {
|
if lower.contains("error") || lower.contains("critical") {
|
||||||
log::error!("aria2c stderr: {}", line.trim());
|
log::error!("aria2c stderr: {}", line.trim());
|
||||||
@@ -1962,7 +2267,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler. 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, };
|
||||||
@@ -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<EngineStatusItem>, };
|
||||||
+116
-82
@@ -13,6 +13,7 @@ import {
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
|
import type { EngineStatusItem } from '../bindings/EngineStatusItem';
|
||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import appIcon from '../assets/app-icon.png';
|
import appIcon from '../assets/app-icon.png';
|
||||||
|
|
||||||
@@ -33,19 +34,11 @@ export default function SettingsView() {
|
|||||||
const { pickDirectory } = useDirectoryPicker();
|
const { pickDirectory } = useDirectoryPicker();
|
||||||
const activeTab = settings.activeSettingsTab;
|
const activeTab = settings.activeSettingsTab;
|
||||||
|
|
||||||
// Local state for versions
|
// Local state for engine diagnostics
|
||||||
const [aria2Version, setAria2Version] = useState<string>('Checking...');
|
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
|
||||||
const [ytdlpVersion, setYtdlpVersion] = useState<string>('Checking...');
|
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
|
||||||
const [ffmpegVersion, setFfmpegVersion] = useState<string>('Checking...');
|
|
||||||
const [denoVersion, setDenoVersion] = useState<string>('Checking...');
|
|
||||||
const [appVersion, setAppVersion] = useState('0.7.3');
|
const [appVersion, setAppVersion] = useState('0.7.3');
|
||||||
|
|
||||||
const getEngineStatus = (v: string) => {
|
|
||||||
if (v === 'Checking...') return <span className="text-text-muted font-medium">Checking...</span>;
|
|
||||||
if (v.startsWith('Error')) return <span className="text-red-500 font-medium">Error / Missing</span>;
|
|
||||||
return <span className="text-green-500 font-medium">Ready</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Local state for adding site login
|
// Local state for adding site login
|
||||||
const [loginPattern, setLoginPattern] = useState('');
|
const [loginPattern, setLoginPattern] = useState('');
|
||||||
const [loginUser, setLoginUser] = useState('');
|
const [loginUser, setLoginUser] = useState('');
|
||||||
@@ -67,24 +60,14 @@ export default function SettingsView() {
|
|||||||
getVersion().then(setAppVersion).catch(() => undefined);
|
getVersion().then(setAppVersion).catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch engine versions when Engine tab is opened
|
// Fetch engine status when Engine tab is opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.activeView === 'settings' && activeTab === 'engine') {
|
if (settings.activeView === 'settings' && activeTab === 'engine') {
|
||||||
invoke('test_aria2c')
|
setEngineStatus(null);
|
||||||
.then(v => setAria2Version(v))
|
setExpandedEngine(null);
|
||||||
.catch(e => setAria2Version('Error: ' + e));
|
invoke('get_engine_status')
|
||||||
|
.then(r => setEngineStatus(r.engines))
|
||||||
invoke('test_ytdlp')
|
.catch(() => setEngineStatus([]));
|
||||||
.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));
|
|
||||||
}
|
}
|
||||||
}, [settings.activeView, activeTab]);
|
}, [settings.activeView, activeTab]);
|
||||||
|
|
||||||
@@ -92,6 +75,52 @@ export default function SettingsView() {
|
|||||||
setToastMessage(msg);
|
setToastMessage(msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findEngine = (kind: string) => engineStatus?.find(e => e.kind === kind) ?? null;
|
||||||
|
|
||||||
|
const renderEngineStatus = (item: EngineStatusItem | null) => {
|
||||||
|
if (!item) return <span className="text-text-muted font-medium">Checking...</span>;
|
||||||
|
if (item.ready) return <span className="text-green-500 font-medium">Ready</span>;
|
||||||
|
return <span className="text-red-500 font-medium">Error / Missing</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedEngine(isExpanded ? null : item.kind)}
|
||||||
|
className="text-accent text-[11px] font-medium hover:underline mt-1"
|
||||||
|
>
|
||||||
|
{isExpanded ? 'Hide technical details' : 'Show technical details'}
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-2 p-2 bg-bg-modal rounded text-[11px] font-mono text-text-muted space-y-1 leading-relaxed">
|
||||||
|
{item.resolved_path && <p>Binary: {item.resolved_path}</p>}
|
||||||
|
{item.expected_sidecar && <p>Expected: {item.expected_sidecar}</p>}
|
||||||
|
{item.error && <p className="text-red-400">Error: {item.error}</p>}
|
||||||
|
{item.remediation_hint && <p className="text-yellow-500">Tip: {item.remediation_hint}</p>}
|
||||||
|
{item.stderr_tail && <details><summary className="cursor-pointer text-text-muted">stderr</summary><pre className="mt-1 whitespace-pre-wrap">{item.stderr_tail}</pre></details>}
|
||||||
|
{item.daemon_alive != null && <p>Daemon process alive: {String(item.daemon_alive)}</p>}
|
||||||
|
{item.rpc_ready != null && <p>RPC ready: {String(item.rpc_ready)}</p>}
|
||||||
|
{item.rpc_port != null && <p>RPC port: {item.rpc_port}</p>}
|
||||||
|
{item.last_stderr_tail && <details><summary className="cursor-pointer text-text-muted">daemon stderr</summary><pre className="mt-1 whitespace-pre-wrap">{item.last_stderr_tail}</pre></details>}
|
||||||
|
{item.expects_internal_dir != null && <p>Expects _internal layout: {String(item.expects_internal_dir)}</p>}
|
||||||
|
{item.has_internal_dir != null && <p>_internal directory found: {String(item.has_internal_dir)}</p>}
|
||||||
|
{item.has_python_framework != null && <p>Python runtime found: {String(item.has_python_framework)}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCheckForUpdates = async () => {
|
const handleCheckForUpdates = async () => {
|
||||||
if (isCheckingForUpdates) return;
|
if (isCheckingForUpdates) return;
|
||||||
|
|
||||||
@@ -741,64 +770,69 @@ export default function SettingsView() {
|
|||||||
<div className="settings-pane space-y-6 max-w-[760px]">
|
<div className="settings-pane space-y-6 max-w-[760px]">
|
||||||
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Media Downloader & Engines</h3>
|
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Media Downloader & Engines</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{(() => {
|
||||||
<div className="border border-border-modal rounded-lg p-4 space-y-3 bg-item-hover/5">
|
const a2 = findEngine('aria2'); const yt = findEngine('ytdlp');
|
||||||
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
|
const ff = findEngine('ffmpeg'); const dn = findEngine('deno');
|
||||||
<Terminal size={14} className="text-accent" /> Core Downloader (Aria2)
|
return (
|
||||||
</h4>
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-[120px_1fr] text-[13px]">
|
{/* aria2 card */}
|
||||||
<span className="text-text-secondary">Version:</span>
|
<div className="border border-border-modal rounded-lg p-4 space-y-2 bg-item-hover/5">
|
||||||
<span className="font-mono text-xs text-text-muted select-all">{aria2Version}</span>
|
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
|
||||||
</div>
|
<Terminal size={14} className="text-accent" /> Core Downloader (Aria2)
|
||||||
<div className="grid grid-cols-[120px_1fr] text-[13px] items-center">
|
</h4>
|
||||||
<span className="text-text-secondary">Status:</span>
|
<div className="grid grid-cols-[100px_1fr] text-[13px] items-center gap-x-2">
|
||||||
{getEngineStatus(aria2Version)}
|
<span className="text-text-secondary">Version:</span>
|
||||||
</div>
|
<span className="font-mono text-xs text-text-muted select-all truncate">{renderEngineVersion(a2)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-[100px_1fr] text-[13px] items-center gap-x-2">
|
||||||
|
<span className="text-text-secondary">Status:</span>
|
||||||
|
{renderEngineStatus(a2)}
|
||||||
|
</div>
|
||||||
|
{renderEngineDetails(a2)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border border-border-modal rounded-lg p-4 space-y-3 bg-item-hover/5">
|
{/* yt-dlp / ffmpeg / deno card */}
|
||||||
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
|
<div className="border border-border-modal rounded-lg p-4 space-y-2 bg-item-hover/5">
|
||||||
<Terminal size={14} className="text-orange-500" /> Media Extractors
|
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
|
||||||
</h4>
|
<Terminal size={14} className="text-orange-500" /> Media Extractors
|
||||||
|
</h4>
|
||||||
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
|
{[
|
||||||
<span className="text-text-secondary font-semibold">yt-dlp:</span>
|
{ key: 'ytdlp', item: yt, label: 'yt-dlp' },
|
||||||
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{ytdlpVersion}</span>
|
{ key: 'ffmpeg', item: ff, label: 'FFmpeg' },
|
||||||
{getEngineStatus(ytdlpVersion)}
|
{ key: 'deno', item: dn, label: 'Deno' },
|
||||||
</div>
|
].map(({ key, item, label }) => (
|
||||||
|
<div key={key}>
|
||||||
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
|
<div className="grid grid-cols-[100px_1fr_80px] text-[13px] items-center gap-x-2">
|
||||||
<span className="text-text-secondary font-semibold">FFmpeg:</span>
|
<span className="text-text-secondary font-semibold">{label}:</span>
|
||||||
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{ffmpegVersion}</span>
|
<span className="font-mono text-xs text-text-muted select-all truncate">{renderEngineVersion(item)}</span>
|
||||||
{getEngineStatus(ffmpegVersion)}
|
{renderEngineStatus(item)}
|
||||||
</div>
|
</div>
|
||||||
|
{renderEngineDetails(item)}
|
||||||
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
|
</div>
|
||||||
<span className="text-text-secondary font-semibold">Deno:</span>
|
))}
|
||||||
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{denoVersion}</span>
|
|
||||||
{getEngineStatus(denoVersion)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
|
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
|
||||||
<label className="text-text-secondary font-semibold">Browser Cookies Source:</label>
|
<label className="text-text-secondary font-semibold">Browser Cookies Source:</label>
|
||||||
<select
|
<select
|
||||||
value={settings.mediaCookieSource}
|
value={settings.mediaCookieSource}
|
||||||
onChange={(e) => settings.setMediaCookieSource(
|
onChange={(e) => settings.setMediaCookieSource(
|
||||||
e.target.value as typeof settings.mediaCookieSource
|
e.target.value as typeof settings.mediaCookieSource
|
||||||
)}
|
)}
|
||||||
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-accent"
|
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-accent"
|
||||||
>
|
>
|
||||||
<option value="none">None</option>
|
<option value="none">None</option>
|
||||||
<option value="safari">Safari</option>
|
<option value="safari">Safari</option>
|
||||||
<option value="chrome">Chrome</option>
|
<option value="chrome">Chrome</option>
|
||||||
<option value="firefox">Firefox</option>
|
<option value="firefox">Firefox</option>
|
||||||
<option value="edge">Edge</option>
|
<option value="edge">Edge</option>
|
||||||
<option value="brave">Brave</option>
|
<option value="brave">Brave</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-muted text-xs mt-1">yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-text-muted text-xs mt-1">yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.</p>
|
);
|
||||||
</div>
|
})()}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -9,6 +9,7 @@ import type { ExtensionDownload } from './bindings/ExtensionDownload';
|
|||||||
import type { MediaCookieSource } from './bindings/MediaCookieSource';
|
import type { MediaCookieSource } from './bindings/MediaCookieSource';
|
||||||
import type { MediaMetadata } from './bindings/MediaMetadata';
|
import type { MediaMetadata } from './bindings/MediaMetadata';
|
||||||
import type { MetadataResponse } from './bindings/MetadataResponse';
|
import type { MetadataResponse } from './bindings/MetadataResponse';
|
||||||
|
import type { EngineStatusResult } from './bindings/EngineStatusResult';
|
||||||
import type { PostQueueAction } from './bindings/PostQueueAction';
|
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||||
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
|
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
|
||||||
|
|
||||||
@@ -55,10 +56,7 @@ type CommandMap = {
|
|||||||
args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null };
|
args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null };
|
||||||
result: MediaMetadata;
|
result: MediaMetadata;
|
||||||
};
|
};
|
||||||
test_ytdlp: { args: undefined; result: string };
|
get_engine_status: { args: undefined; result: EngineStatusResult };
|
||||||
test_aria2c: { args: undefined; result: string };
|
|
||||||
test_ffmpeg: { args: undefined; result: string };
|
|
||||||
test_deno: { args: undefined; result: string };
|
|
||||||
open_file: { args: { path: string }; result: void };
|
open_file: { args: { path: string }; result: void };
|
||||||
show_in_folder: { args: { path: string }; result: void };
|
show_in_folder: { args: { path: string }; result: void };
|
||||||
reveal_in_file_manager: { args: { path: string }; result: void };
|
reveal_in_file_manager: { args: { path: string }; result: void };
|
||||||
|
|||||||
Reference in New Issue
Block a user