From 248f3869adba52693ee6ba58a1b2efdc54ca9b41 Mon Sep 17 00:00:00 2001 From: NimBold Date: Fri, 10 Jul 2026 19:02:39 +0330 Subject: [PATCH] fix: harden media handoff and live logs Reject stale extension media cookie headers before yt-dlp metadata work, preserve ordinary capture cookies, and advance the companion extension. Stream redacted diagnostic logs only while the visible Logs view is active, with bounded batched updates and race-safe snapshot handoff. --- Extensions/Firefox | 2 +- src-tauri/src/extension_server.rs | 38 ++++++-- src-tauri/src/lib.rs | 84 +++++++++++------ src/App.tsx | 6 ++ src/components/AddDownloadsModal.tsx | 31 +------ src/components/LogsView.tsx | 124 +++++++++++++++++--------- src/ipc.ts | 1 + src/store/useDownloadStore.test.ts | 18 +++- src/store/useDownloadStore.ts | 7 +- src/utils/addDownloadMetadata.test.ts | 2 - src/utils/addDownloadMetadata.ts | 2 - src/utils/logEntries.test.ts | 55 ++++++++++++ src/utils/logEntries.ts | 92 +++++++++++++++++++ 13 files changed, 348 insertions(+), 114 deletions(-) create mode 100644 src/utils/logEntries.test.ts create mode 100644 src/utils/logEntries.ts diff --git a/Extensions/Firefox b/Extensions/Firefox index be632f3..c45b8a3 160000 --- a/Extensions/Firefox +++ b/Extensions/Firefox @@ -1 +1 @@ -Subproject commit be632f368cc42e416ecdb94727d68a6d6c5bfeaf +Subproject commit c45b8a3667a2fe535a188112a7d7741e50de189f diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 018b953..089cc7c 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -315,10 +315,15 @@ fn normalize_download(payload: ExtensionRequest) -> Option { silent: payload.silent, filename, headers: payload.headers.filter(|value| !value.trim().is_empty()), - // Keep the exact tab/container cookie context. The Add modal may retry - // public media without this header when an upstream rejects its size, - // but authenticated and container-scoped media must not lose it here. - cookies: payload.cookies.filter(|value| !value.trim().is_empty()), + // Explicit media is resolved by yt-dlp, which must use Firelink's + // configured browser-cookie source. Forwarding a browser's complete + // Cookie header can exceed upstream limits and makes old extension + // builds pay for a doomed metadata request before retrying. Ordinary + // captured downloads still need their exact request cookies. + cookies: (!payload.media) + .then_some(payload.cookies) + .flatten() + .filter(|value| !value.trim().is_empty()), media: payload.media, }) } @@ -496,24 +501,41 @@ mod tests { } #[test] - fn explicit_media_preserves_the_extension_cookie_header() { + fn explicit_media_drops_the_extension_cookie_header() { let download = normalize_download(ExtensionRequest { urls: vec!["https://www.youtube.com/watch?v=example".to_string()], referer: None, silent: false, filename: None, headers: Some("User-Agent: Firefox".to_string()), - cookies: Some("large=browser-cookie-header".to_string()), + cookies: Some(format!("large={}", "x".repeat(64 * 1024))), media: true, }) .expect("valid media handoff"); assert!(download.media); + assert!(download.cookies.is_none()); + assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox")); + } + + #[test] + fn regular_capture_preserves_the_extension_cookie_header() { + let download = normalize_download(ExtensionRequest { + urls: vec!["https://example.com/private.zip".to_string()], + referer: None, + silent: true, + filename: None, + headers: None, + cookies: Some("session=browser-cookie-header".to_string()), + media: false, + }) + .expect("valid download handoff"); + + assert!(!download.media); assert_eq!( download.cookies.as_deref(), - Some("large=browser-cookie-header") + Some("session=browser-cookie-header") ); - assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox")); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f76b79c..67de61e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4542,6 +4542,27 @@ fn redact_log_line(line: &str) -> String { query.replace_all(&redacted, "$1?[redacted]").into_owned() } +fn redact_log_line_for_output(line: &str) -> String { + static HOME_PATHS: OnceLock<(String, String)> = OnceLock::new(); + let (home, escaped_home) = HOME_PATHS.get_or_init(|| { + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_default(); + let home = if home.len() > 3 { home } else { String::new() }; + let escaped_home = home.replace('\\', "\\\\"); + (home, escaped_home) + }); + + let mut redacted = line.to_string(); + if !home.is_empty() { + redacted = redacted.replace(home, ""); + } + if !escaped_home.is_empty() && escaped_home != home { + redacted = redacted.replace(escaped_home, ""); + } + redact_log_line(&redacted) +} + fn redact_log_line_for_app(line: &str, app_handle: &tauri::AppHandle) -> String { use tauri::Manager; let without_home = app_handle @@ -4780,8 +4801,9 @@ mod tests { media_output_template, media_progress_args, media_progress_speed, normalize_speed_limit_for_aria2, parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line, - redact_log_line, sanitize_ytdlp_config_value, should_cleanup_media_artifacts_after_failure, - FirelinkDeepLink, MediaProgress, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX, + redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value, + should_cleanup_media_artifacts_after_failure, FirelinkDeepLink, MediaProgress, + MediaSpeedSampler, MEDIA_PROGRESS_PREFIX, }; use serde_json::json; use std::time::{Duration, Instant}; @@ -4979,6 +5001,15 @@ mod tests { assert!(redacted.contains("[redacted]")); } + #[test] + fn redacts_live_log_output_before_webview_delivery() { + let line = "Cookie: session=abc https://example.com/file?signature=secret"; + let redacted = redact_log_line_for_output(line); + assert!(!redacted.contains("session=abc")); + assert!(!redacted.contains("signature=secret")); + assert!(redacted.contains("[redacted]")); + } + #[test] fn collects_primary_url_and_unique_mirrors_in_order() { let uris = collect_download_uris( @@ -5459,6 +5490,7 @@ mod tests { } static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); +static LOG_STREAM_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); #[tauri::command] fn toggle_log_pause(pause: bool) { @@ -5470,6 +5502,11 @@ fn is_log_paused() -> bool { LOG_PAUSED.load(std::sync::atomic::Ordering::Relaxed) } +#[tauri::command] +fn set_log_stream_active(active: bool) { + LOG_STREAM_ACTIVE.store(active, std::sync::atomic::Ordering::Release); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { ensure_reqwest_crypto_provider(); @@ -5881,7 +5918,13 @@ pub fn run() { tauri_plugin_log::Builder::new() .targets([ tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: None }), + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { + file_name: None, + }), + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview) + .filter(|_| { + LOG_STREAM_ACTIVE.load(std::sync::atomic::Ordering::Acquire) + }), ]) .level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info }) .filter(|metadata| { @@ -5899,29 +5942,15 @@ pub fn run() { .max_file_size(10_000_000) .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3)) .timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal) - .format({ - let home_dir = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default(); - let home_dir = if home_dir.len() > 3 { home_dir } else { String::new() }; - let escaped_home_dir = home_dir.replace("\\", "\\\\"); - move |out, message, record| { - let msg = message.to_string(); - let redacted = if !home_dir.is_empty() { - let mut s = msg.replace(&home_dir, ""); - if !escaped_home_dir.is_empty() && escaped_home_dir != home_dir { - s = s.replace(&escaped_home_dir, ""); - } - s - } else { - msg - }; - out.finish(format_args!( - "[{}][{}][{}] {}", - chrono::Local::now().format("%Y-%m-%d][%H:%M:%S"), - record.level(), - record.target(), - redacted - )) - } + .format(|out, message, record| { + let redacted = redact_log_line_for_output(&message.to_string()); + out.finish(format_args!( + "[{}][{}][{}] {}", + chrono::Local::now().format("%Y-%m-%d][%H:%M:%S"), + record.level(), + record.target(), + redacted + )) }) .build(), ) @@ -5955,7 +5984,8 @@ pub fn run() { parity::create_category_directories, db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads, db_get_all_queues, db_replace_queues, - read_logs, export_logs, toggle_log_pause, is_log_paused, clear_logs + read_logs, export_logs, toggle_log_pause, is_log_paused, clear_logs, + set_log_stream_active ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src/App.tsx b/src/App.tsx index 562950d..28033a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -397,6 +397,12 @@ function App() { invoke('toggle_log_pause', { pause: !logsEnabled }).catch(console.error); }, [logsEnabled]); + useEffect(() => { + if (activeView !== 'logs') { + invoke('set_log_stream_active', { active: false }).catch(console.error); + } + }, [activeView]); + useEffect(() => { if (!extensionPairingToken) return; invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => { diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 89a1cfa..ccf1229 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -120,9 +120,8 @@ export const AddDownloadsModal = () => { if (context) return extensionHeaders(context).trim(); return hasExtensionRequestContext ? '' : headers.trim(); }; - const cookiesForRow = (sourceUrl: string, omitRequestCookies = false) => { + const cookiesForRow = (sourceUrl: string) => { if (cookiesManuallyEditedRef.current) return cookies.trim(); - if (omitRequestCookies) return ''; const context = requestContextForUrl(sourceUrl); if (context) return context.cookies.trim(); return hasExtensionRequestContext ? '' : cookies.trim(); @@ -303,25 +302,7 @@ export const AddDownloadsModal = () => { cookies: rowCookies || null, proxy }; - let requestCookiesOmitted = false; - let mediaData; - try { - mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs); - } catch (error) { - const capturedCookies = requestContextForUrl(row.sourceUrl)?.cookies.trim(); - if (!rowCookies || !capturedCookies || cookiesManuallyEditedRef.current) { - throw error; - } - console.warn( - 'Media metadata rejected the captured Cookie header; retrying without request cookies', - error - ); - mediaData = await fetchMediaMetadataDeduped({ - ...mediaMetadataArgs, - cookies: null - }); - requestCookiesOmitted = true; - } + const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs); if (mediaData && mediaData.formats.length > 0) { const mappedFormats = mediaData.formats.map(f => { const quality = f.resolution || 'Video'; @@ -353,7 +334,6 @@ export const AddDownloadsModal = () => { size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined, sizeBytes: mappedFormats[0].bytes || undefined, status: 'ready', - requestCookiesOmitted, formats: mappedFormats, selectedFormat: 0 }) @@ -729,7 +709,7 @@ export const AddDownloadsModal = () => { checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgo}=${checksumValue.trim()}` : undefined, - cookies: cookiesForRow(item.sourceUrl, item.requestCookiesOmitted) || undefined, + cookies: cookiesForRow(item.sourceUrl) || undefined, mirrors: mirrors.trim() || undefined, destination: useSharedDestination ? finalLocation @@ -1148,11 +1128,6 @@ export const AddDownloadsModal = () => { className="add-download-control w-full px-3 py-1.5 text-xs font-mono" aria-label="Cookies" /> - {!cookiesManuallyEditedRef.current && parsedItems.some(item => item.requestCookiesOmitted) && ( -

- Media metadata only worked without the captured cookies, so they will be omitted for affected rows. Edit this field to force a manual value. -

- )}
diff --git a/src/components/LogsView.tsx b/src/components/LogsView.tsx index 970e8e8..18f9b3c 100644 --- a/src/components/LogsView.tsx +++ b/src/components/LogsView.tsx @@ -7,11 +7,15 @@ import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'luc import { WindowDragRegion } from './WindowDragRegion'; import { useToast } from '../contexts/ToastContext'; import { useSettingsStore } from '../store/useSettingsStore'; - -interface LogEntry { - level: 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error'; - message: string; -} +import { + MAX_LOG_LINES, + appendBoundedLogEntries, + liveLogEntry, + mergeLogSnapshotAndLiveEntries, + persistedLogEntry, + pushBoundedLogEntry, + type LogEntry +} from '../utils/logEntries'; export default function LogsView() { const { addToast } = useToast(); @@ -20,56 +24,75 @@ export default function LogsView() { const [logs, setLogs] = useState([]); const [levelFilter, setLevelFilter] = useState('All'); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null); + const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden'); const scrollRef = useRef(null); - const rawLineCountRef = useRef(0); - - const MAX_LOG_LINES = 2000; + const liveBatchRef = useRef([]); + const liveFrameRef = useRef(null); useEffect(() => { + const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden'); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, []); + + useEffect(() => { + if (!pageVisible) { + void invoke('set_log_stream_active', { active: false }).catch(console.error); + return; + } + + if (!logsEnabled) { + void invoke('set_log_stream_active', { active: false }).catch(console.error); + } + let active = true; - let unlisten: (() => void) | undefined; + let initialized = false; + let pendingLiveEntries: LogEntry[] = []; + let unlistenPromise: Promise<() => void> | undefined; + + const scheduleLiveEntry = (entry: LogEntry) => { + if (!initialized) { + pushBoundedLogEntry(pendingLiveEntries, entry); + return; + } + + pushBoundedLogEntry(liveBatchRef.current, entry); + if (liveFrameRef.current !== null) return; + liveFrameRef.current = window.requestAnimationFrame(() => { + liveFrameRef.current = null; + if (!active || liveBatchRef.current.length === 0) return; + const batch = liveBatchRef.current; + liveBatchRef.current = []; + setLogs(current => appendBoundedLogEntries(current, batch)); + }); + }; const init = async () => { try { await initLogger(); - const [lines] = await Promise.all([ - invoke('read_logs', { limit: MAX_LOG_LINES }) - ]); if (!active) return; - const initialLogs = lines.map(message => { - const level: LogEntry['level'] = message.includes('[ERROR]') ? 'Error' - : message.includes('[WARN]') ? 'Warn' - : message.includes('[INFO]') ? 'Info' - : message.includes('[TRACE]') ? 'Trace' - : 'Debug'; - return { level, message }; - }); - - setLogs(initialLogs); - rawLineCountRef.current = initialLogs.length; if (logsEnabled) { - unlisten = await attachLogger((log) => { + unlistenPromise = attachLogger((log) => { if (!active) return; - const levelStr: LogEntry['level'] = log.level === 5 ? 'Error' - : log.level === 4 ? 'Warn' - : log.level === 3 ? 'Info' - : log.level === 1 ? 'Trace' - : 'Debug'; - - const timeStr = new Date().toISOString().replace('T', ' ').substring(0, 19); - const formattedMsg = `[${timeStr}] [${levelStr.toUpperCase()}] ${log.message}`; - - setLogs(prev => { - const newLogs = [...prev, { level: levelStr, message: formattedMsg }]; - rawLineCountRef.current = newLogs.length; - if (newLogs.length > MAX_LOG_LINES + 500) { - return newLogs.slice(newLogs.length - MAX_LOG_LINES); - } - return newLogs; - }); + scheduleLiveEntry(liveLogEntry(log.level, log.message)); }); + await unlistenPromise; + if (!active) return; + await invoke('set_log_stream_active', { active: true }); + if (!active) { + await invoke('set_log_stream_active', { active: false }).catch(console.error); + return; + } } + + const lines = await invoke('read_logs', { limit: MAX_LOG_LINES }); + if (!active) return; + const snapshot = lines.map(persistedLogEntry); + initialized = true; + const caughtUpLogs = mergeLogSnapshotAndLiveEntries(snapshot, pendingLiveEntries); + pendingLiveEntries = []; + setLogs(caughtUpLogs); } catch (e) { console.error('Failed to init logs:', e); } @@ -78,9 +101,19 @@ export default function LogsView() { return () => { active = false; - if (unlisten) unlisten(); + liveBatchRef.current = []; + if (liveFrameRef.current !== null) { + window.cancelAnimationFrame(liveFrameRef.current); + liveFrameRef.current = null; + } + if (logsEnabled) { + void invoke('set_log_stream_active', { active: false }).catch(console.error); + } + if (unlistenPromise) { + void unlistenPromise.then(unlisten => unlisten()).catch(console.error); + } }; - }, [logsEnabled]); + }, [logsEnabled, pageVisible]); useEffect(() => { if (scrollRef.current) { @@ -139,6 +172,11 @@ export default function LogsView() { }; const handleClear = async () => { + liveBatchRef.current = []; + if (liveFrameRef.current !== null) { + window.cancelAnimationFrame(liveFrameRef.current); + liveFrameRef.current = null; + } setLogs([]); await invoke('clear_logs').catch(console.error); }; diff --git a/src/ipc.ts b/src/ipc.ts index 4db9b12..6a62c95 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -76,6 +76,7 @@ type CommandMap = { clear_logs: { args: undefined; result: void }; toggle_log_pause: { args: { pause: boolean }; result: void }; is_log_paused: { args: undefined; result: boolean }; + set_log_stream_active: { args: { active: boolean }; result: void }; get_pending_order: { args: { queueId: string | null }; result: string[] }; enqueue_download: { args: { item: EnqueueItem }; result: EnqueueAccepted }; cancel_enqueue_generation: { args: { id: string; generation: string }; result: void }; diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index ae3c4fc..90a5402 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -819,7 +819,7 @@ describe('useDownloadStore', () => { silent: false, filename: null, headers: 'User-Agent: Firefox Test', - cookies: 'session=secret', + cookies: `oversized=${'x'.repeat(64 * 1024)}`, media: true }); @@ -827,7 +827,21 @@ describe('useDownloadStore', () => { expect(state.isAddModalOpen).toBe(true); expect(state.pendingAddUrls).toBe('https://adult.example/watch/123'); expect(state.pendingAddMediaUrls).toEqual(['https://adult.example/watch/123']); - expect(state.pendingAddCookies).toBe('session=secret'); + expect(state.pendingAddCookies).toBe(''); + }); + + it('preserves extension cookies for ordinary captured downloads', async () => { + await useDownloadStore.getState().handleExtensionDownload({ + urls: ['https://example.com/private.zip'], + referer: 'https://example.com/downloads', + silent: true, + filename: 'private.zip', + headers: null, + cookies: 'session=secret', + media: false + }); + + expect(useDownloadStore.getState().pendingAddCookies).toBe('session=secret'); }); it('clears stale request context when the same URL is captured without it later', async () => { diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index cb06a83..41837ca 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -551,12 +551,17 @@ export const useDownloadStore = create((set, get) => ({ const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))]; if (urls.length === 0) return; + // Explicit media authentication belongs to yt-dlp's configured browser + // cookie source. Keep this frontend guard for events from older desktop or + // extension builds; ordinary captured downloads retain their cookies. + const cookies = request.media === true ? null : request.cookies; + get().openAddModalWithUrls( urls.join('\n'), request.referer, urls.length === 1 ? request.filename : null, request.headers, - request.cookies, + cookies, request.media === true ); }, diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index 006d5e2..65fd851 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -94,7 +94,6 @@ describe('add download metadata workflow', () => { status: 'ready', generation: 4, requestContextVersion: 1, - requestCookiesOmitted: true, formats: [{ name: '1080p MP4', selector: '137+140', @@ -121,7 +120,6 @@ describe('add download metadata workflow', () => { status: 'loading', generation: 5, requestContextVersion: 2, - requestCookiesOmitted: false, formats: undefined, selectedFormat: undefined }); diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index 9464f18..3f824f7 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -27,7 +27,6 @@ export interface AddDownloadDraftRow { status: MetadataStatus; generation: number; requestContextVersion?: number; - requestCookiesOmitted?: boolean; isMedia: boolean; resumable?: boolean; formats?: AddMediaFormat[]; @@ -94,7 +93,6 @@ export const reconcileDownloadRows = ( status: 'loading', generation: preserved.generation + 1, requestContextVersion, - requestCookiesOmitted: false, isMedia: preserved.isMedia || forcedMedia, formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats, selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat diff --git a/src/utils/logEntries.test.ts b/src/utils/logEntries.test.ts new file mode 100644 index 0000000..6ff5145 --- /dev/null +++ b/src/utils/logEntries.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + appendBoundedLogEntries, + liveLogEntry, + mergeLogSnapshotAndLiveEntries, + persistedLogEntry, + pushBoundedLogEntry, + type LogEntry +} from './logEntries'; + +const entry = (message: string): LogEntry => ({ level: 'Info', message }); + +describe('log entry streaming', () => { + it('derives levels from persisted formatted lines', () => { + expect(persistedLogEntry('[2026-07-10][18:00:00][ERROR][firelink] failed')).toEqual({ + level: 'Error', + message: '[2026-07-10][18:00:00][ERROR][firelink] failed' + }); + }); + + it('does not double-format backend Webview log lines', () => { + const message = '[2026-07-10][18:00:00][WARN][firelink] retrying'; + expect(liveLogEntry(4, message).message).toBe(message); + }); + + it('formats an unformatted plugin event with its numeric level', () => { + expect(liveLogEntry(3, 'download started', new Date('2026-07-10T14:30:00Z'))).toEqual({ + level: 'Info', + message: '[2026-07-10 14:30:00] [INFO] download started' + }); + }); + + it('merges only the ordered snapshot-to-stream overlap', () => { + expect(mergeLogSnapshotAndLiveEntries( + [entry('one'), entry('repeat'), entry('three')], + [entry('three'), entry('repeat'), entry('four')] + ).map(item => item.message)).toEqual(['one', 'repeat', 'three', 'repeat', 'four']); + }); + + it('bounds burst updates to the newest entries', () => { + expect(appendBoundedLogEntries( + [entry('old')], + Array.from({ length: 5 }, (_, index) => entry(`new-${index}`)), + 3 + ).map(item => item.message)).toEqual(['new-2', 'new-3', 'new-4']); + }); + + it('bounds the mutable pre-render queue without copying on every event', () => { + const queue = [entry('old')]; + for (let index = 0; index < 5; index += 1) { + pushBoundedLogEntry(queue, entry(`new-${index}`), 3); + } + expect(queue.map(item => item.message)).toEqual(['new-2', 'new-3', 'new-4']); + }); +}); diff --git a/src/utils/logEntries.ts b/src/utils/logEntries.ts new file mode 100644 index 0000000..c3b3af7 --- /dev/null +++ b/src/utils/logEntries.ts @@ -0,0 +1,92 @@ +export type LogLevel = 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error'; + +export interface LogEntry { + level: LogLevel; + message: string; +} + +export const MAX_LOG_LINES = 2000; + +const LEVEL_NAMES: LogLevel[] = ['Trace', 'Debug', 'Info', 'Warn', 'Error']; +const LIVE_LEVELS: Record = { + 1: 'Trace', + 2: 'Debug', + 3: 'Info', + 4: 'Warn', + 5: 'Error' +}; + +const levelFromMessage = (message: string): LogLevel | undefined => + LEVEL_NAMES.find(level => message.includes(`[${level.toUpperCase()}]`)); + +export const persistedLogEntry = (message: string): LogEntry => ({ + level: levelFromMessage(message) || 'Debug', + message +}); + +export const liveLogEntry = ( + numericLevel: number, + message: string, + now: Date = new Date() +): LogEntry => { + const level = levelFromMessage(message) || LIVE_LEVELS[numericLevel] || 'Debug'; + const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(message); + + return { + level, + message: alreadyFormatted + ? message + : `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${message}` + }; +}; + +export const appendBoundedLogEntries = ( + current: LogEntry[], + additions: LogEntry[], + limit = MAX_LOG_LINES +): LogEntry[] => { + if (additions.length === 0) return current; + const combined = [...current, ...additions]; + return combined.length > limit ? combined.slice(combined.length - limit) : combined; +}; + +export const pushBoundedLogEntry = ( + queue: LogEntry[], + entry: LogEntry, + limit = MAX_LOG_LINES +): void => { + queue.push(entry); + if (queue.length > limit) { + queue.splice(0, queue.length - limit); + } +}; + +// The live target writes after the file target, so entries received while the +// initial disk snapshot is loading can also appear at the snapshot's tail. +// Remove only the exact ordered overlap; global message de-duplication would +// incorrectly hide legitimate repeated log lines. +export const mergeLogSnapshotAndLiveEntries = ( + snapshot: LogEntry[], + liveEntries: LogEntry[], + limit = MAX_LOG_LINES +): LogEntry[] => { + const maxOverlap = Math.min(snapshot.length, liveEntries.length); + let overlap = 0; + + for (let candidate = maxOverlap; candidate > 0; candidate -= 1) { + const snapshotStart = snapshot.length - candidate; + let matches = true; + for (let index = 0; index < candidate; index += 1) { + if (snapshot[snapshotStart + index].message !== liveEntries[index].message) { + matches = false; + break; + } + } + if (matches) { + overlap = candidate; + break; + } + } + + return appendBoundedLogEntries(snapshot, liveEntries.slice(overlap), limit); +};