diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index 491a16f..88e5d75 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -189,7 +189,7 @@ impl CoordinatorEventSink { fn emit_failed(&self, id: Uuid, error: String) { match self { Self::Tauri(app_handle) => { - eprintln!("download {id} failed: {error}"); + log::error!("native download {} failed: {}", id, error); let _ = app_handle.emit("download-failed", id.to_string()); } Self::Headless(event_tx) => { diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 3155458..d1d6dc3 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -154,6 +154,7 @@ pub enum ActiveView { Scheduler, #[serde(rename = "speedLimiter")] SpeedLimiter, + Diagnostics, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f213fbe..6521444 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1301,6 +1301,29 @@ fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String> } } +#[tauri::command] +async fn export_logs(app_handle: tauri::AppHandle, dest_path: String) -> Result { + use tauri::Manager; + let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?; + let log_file = log_dir.join("firelink.log"); + let src = if log_file.exists() { + log_file + } else { + let mut found = None; + if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + if entry.path().extension().is_some_and(|e| e == "log") { + found = Some(entry.path()); + break; + } + } + } + found.ok_or_else(|| "No log file found in app log directory".to_string())? + }; + tokio::fs::copy(&src, &dest_path).await.map_err(|e| e.to_string())?; + Ok(dest_path) +} + #[tauri::command] fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> { use tauri::tray::TrayIconBuilder; @@ -1785,6 +1808,15 @@ pub fn run() { tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), ]) .level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info }) + .max_file_size(10_000_000) + .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3)) + .format(move |out, message, _record| { + let msg = message.to_string(); + if msg.contains("[download]") && msg.contains('%') { + return; + } + out.finish(format_args!("{}\n", msg)); + }) .build(), ) .plugin(tauri_plugin_store::Builder::new().build()) @@ -1811,7 +1843,8 @@ pub fn run() { enqueue_download, enqueue_many, move_in_queue, remove_from_queue, get_pending_order, commands::reveal_in_file_manager, commands::open_downloaded_file, commands::trash_download_assets, parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains, - parity::create_category_directories + parity::create_category_directories, + export_logs ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src/App.tsx b/src/App.tsx index 4a83fe2..a742997 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { useSettingsStore } from "./store/useSettingsStore"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; import SchedulerView from "./components/SchedulerView"; import SpeedLimiterView from "./components/SpeedLimiterView"; +import DiagnosticsView from "./components/DiagnosticsView"; function App() { const [filter, setFilter] = useState('all'); @@ -305,6 +306,7 @@ function App() { {activeView === 'settings' && } {activeView === 'scheduler' && } {activeView === 'speedLimiter' && } + {activeView === 'diagnostics' && } {/* Status Bar */} diff --git a/src/bindings/ActiveView.ts b/src/bindings/ActiveView.ts index 650b12b..ac280b2 100644 --- a/src/bindings/ActiveView.ts +++ b/src/bindings/ActiveView.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter"; +export type ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter" | "diagnostics"; diff --git a/src/components/DiagnosticsView.tsx b/src/components/DiagnosticsView.tsx new file mode 100644 index 0000000..973f343 --- /dev/null +++ b/src/components/DiagnosticsView.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { invokeCommand as invoke } from '../ipc'; +import { save } from '@tauri-apps/plugin-dialog'; +import { FileDown, Trash2, Terminal } from 'lucide-react'; +import { WindowDragRegion } from './WindowDragRegion'; + +interface LogEntry { + level: 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error'; + message: string; +} + +export default function DiagnosticsView() { + const [logs, setLogs] = useState([]); + const scrollRef = useRef(null); + const MAX_LOG_LINES = 2000; + + useEffect(() => { + const unlisten = listen<{ level: string; message: string }>('log', (event) => { + const level = event.payload.level as LogEntry['level']; + const message = event.payload.message; + if (message.includes('[download]') && message.includes('%')) return; + setLogs(prev => { + const next = [...prev, { level, message }]; + return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next; + }); + }); + return () => { unlisten.then(f => f()); }; + }, []); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs]); + + const handleExport = async () => { + try { + const path = await save({ + defaultPath: 'Firelink-Diagnostics.log', + filters: [{ name: 'Log Files', extensions: ['log'] }], + }); + if (!path) return; + await invoke('export_logs', { destPath: path }); + } catch (e) { + console.error('Export failed:', e); + } + }; + + const handleClear = () => setLogs([]); + + const severityClass = (level: string) => { + switch (level) { + case 'Error': return 'log-error'; + case 'Warn': return 'log-warn'; + case 'Info': return 'log-info'; + default: return 'log-debug'; + } + }; + + return ( +
+ + + {/* Toolbar */} +
+
+ + Diagnostics Console + ({logs.length} entries) +
+
+ + +
+
+ + {/* Console */} +
+ {logs.length === 0 && ( +
Waiting for log entries...
+ )} + {logs.map((entry, i) => ( +
+ [{entry.level}] + {entry.message} +
+ ))} +
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b16fe7e..9fc4d8c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Inbox, Zap, CheckCircle2, CircleDashed, Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion, - List, CalendarClock, Gauge, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft, + List, CalendarClock, Gauge, Bug, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft, type LucideIcon } from 'lucide-react'; import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore'; @@ -229,6 +229,7 @@ export const Sidebar: React.FC = (props) => {
Tools
+ diff --git a/src/index.css b/src/index.css index 99957b1..f126356 100644 --- a/src/index.css +++ b/src/index.css @@ -1169,6 +1169,66 @@ } } +/* Diagnostics Console */ +.diagnostics-toolbar { + height: 42px; + border-bottom: 1px solid hsl(var(--border-color)); + background: hsl(var(--statusbar-bg)); +} + +.diagnostics-console { + background: hsl(0 0% 7%); + color: hsl(0 0% 82%); + font-family: "SF Mono", Monaco, "Cascadia Code", "Fira Code", "JetBrains Mono", monospace; +} + +.diagnostics-console .log-line { + display: flex; + gap: 8px; + padding: 0 4px; + min-height: 18px; + align-items: baseline; + border-radius: 2px; + white-space: pre-wrap; + word-break: break-all; +} + +.diagnostics-console .log-level-tag { + flex-shrink: 0; + font-weight: 600; + min-width: 52px; + font-size: 10px; +} + +.diagnostics-console .log-message { + flex: 1; + min-width: 0; +} + +.diagnostics-console .log-error .log-level-tag, +.diagnostics-console .log-error .log-message { + color: hsl(0 72% 58%); +} + +.diagnostics-console .log-warn .log-level-tag, +.diagnostics-console .log-warn .log-message { + color: hsl(45 100% 50%); +} + +.diagnostics-console .log-info .log-level-tag, +.diagnostics-console .log-info .log-message { + color: hsl(0 0% 75%); +} + +.diagnostics-console .log-debug .log-level-tag, +.diagnostics-console .log-debug .log-message { + color: hsl(0 0% 45%); +} + +.diagnostics-console .log-line:hover { + background: hsl(0 0% 100% / 0.04); +} + @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } diff --git a/src/ipc.ts b/src/ipc.ts index dee631b..a8ed78e 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -100,6 +100,7 @@ type CommandMap = { db_save_queue: { args: { id: string; data: string }; result: void }; db_delete_queue: { args: { id: string }; result: void }; create_category_directories: { args: { paths: string[] }; result: void }; + export_logs: { args: { destPath: string }; result: string }; get_pending_order: { args: undefined; result: string[] }; enqueue_download: { args: { item: any }; result: string }; enqueue_many: { args: { items: any[] }; result: void };