mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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.
This commit is contained in:
+1
-1
Submodule Extensions/Firefox updated: be632f368c...c45b8a3667
@@ -315,10 +315,15 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
|
|||||||
silent: payload.silent,
|
silent: payload.silent,
|
||||||
filename,
|
filename,
|
||||||
headers: payload.headers.filter(|value| !value.trim().is_empty()),
|
headers: payload.headers.filter(|value| !value.trim().is_empty()),
|
||||||
// Keep the exact tab/container cookie context. The Add modal may retry
|
// Explicit media is resolved by yt-dlp, which must use Firelink's
|
||||||
// public media without this header when an upstream rejects its size,
|
// configured browser-cookie source. Forwarding a browser's complete
|
||||||
// but authenticated and container-scoped media must not lose it here.
|
// Cookie header can exceed upstream limits and makes old extension
|
||||||
cookies: payload.cookies.filter(|value| !value.trim().is_empty()),
|
// 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,
|
media: payload.media,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -496,24 +501,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_media_preserves_the_extension_cookie_header() {
|
fn explicit_media_drops_the_extension_cookie_header() {
|
||||||
let download = normalize_download(ExtensionRequest {
|
let download = normalize_download(ExtensionRequest {
|
||||||
urls: vec!["https://www.youtube.com/watch?v=example".to_string()],
|
urls: vec!["https://www.youtube.com/watch?v=example".to_string()],
|
||||||
referer: None,
|
referer: None,
|
||||||
silent: false,
|
silent: false,
|
||||||
filename: None,
|
filename: None,
|
||||||
headers: Some("User-Agent: Firefox".to_string()),
|
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,
|
media: true,
|
||||||
})
|
})
|
||||||
.expect("valid media handoff");
|
.expect("valid media handoff");
|
||||||
|
|
||||||
assert!(download.media);
|
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!(
|
assert_eq!(
|
||||||
download.cookies.as_deref(),
|
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]
|
#[test]
|
||||||
|
|||||||
+57
-27
@@ -4542,6 +4542,27 @@ fn redact_log_line(line: &str) -> String {
|
|||||||
query.replace_all(&redacted, "$1?[redacted]").into_owned()
|
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, "<HOME>");
|
||||||
|
}
|
||||||
|
if !escaped_home.is_empty() && escaped_home != home {
|
||||||
|
redacted = redacted.replace(escaped_home, "<HOME>");
|
||||||
|
}
|
||||||
|
redact_log_line(&redacted)
|
||||||
|
}
|
||||||
|
|
||||||
fn redact_log_line_for_app(line: &str, app_handle: &tauri::AppHandle) -> String {
|
fn redact_log_line_for_app(line: &str, app_handle: &tauri::AppHandle) -> String {
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
let without_home = app_handle
|
let without_home = app_handle
|
||||||
@@ -4780,8 +4801,9 @@ mod tests {
|
|||||||
media_output_template, media_progress_args, media_progress_speed,
|
media_output_template, media_progress_args, media_progress_speed,
|
||||||
normalize_speed_limit_for_aria2,
|
normalize_speed_limit_for_aria2,
|
||||||
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
||||||
redact_log_line, sanitize_ytdlp_config_value, should_cleanup_media_artifacts_after_failure,
|
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
|
||||||
FirelinkDeepLink, MediaProgress, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX,
|
should_cleanup_media_artifacts_after_failure, FirelinkDeepLink, MediaProgress,
|
||||||
|
MediaSpeedSampler, MEDIA_PROGRESS_PREFIX,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -4979,6 +5001,15 @@ mod tests {
|
|||||||
assert!(redacted.contains("[redacted]"));
|
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]
|
#[test]
|
||||||
fn collects_primary_url_and_unique_mirrors_in_order() {
|
fn collects_primary_url_and_unique_mirrors_in_order() {
|
||||||
let uris = collect_download_uris(
|
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_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]
|
#[tauri::command]
|
||||||
fn toggle_log_pause(pause: bool) {
|
fn toggle_log_pause(pause: bool) {
|
||||||
@@ -5470,6 +5502,11 @@ fn is_log_paused() -> bool {
|
|||||||
LOG_PAUSED.load(std::sync::atomic::Ordering::Relaxed)
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
ensure_reqwest_crypto_provider();
|
ensure_reqwest_crypto_provider();
|
||||||
@@ -5881,7 +5918,13 @@ pub fn run() {
|
|||||||
tauri_plugin_log::Builder::new()
|
tauri_plugin_log::Builder::new()
|
||||||
.targets([
|
.targets([
|
||||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
|
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 })
|
.level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info })
|
||||||
.filter(|metadata| {
|
.filter(|metadata| {
|
||||||
@@ -5899,29 +5942,15 @@ pub fn run() {
|
|||||||
.max_file_size(10_000_000)
|
.max_file_size(10_000_000)
|
||||||
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3))
|
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3))
|
||||||
.timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal)
|
.timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal)
|
||||||
.format({
|
.format(|out, message, record| {
|
||||||
let home_dir = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();
|
let redacted = redact_log_line_for_output(&message.to_string());
|
||||||
let home_dir = if home_dir.len() > 3 { home_dir } else { String::new() };
|
out.finish(format_args!(
|
||||||
let escaped_home_dir = home_dir.replace("\\", "\\\\");
|
"[{}][{}][{}] {}",
|
||||||
move |out, message, record| {
|
chrono::Local::now().format("%Y-%m-%d][%H:%M:%S"),
|
||||||
let msg = message.to_string();
|
record.level(),
|
||||||
let redacted = if !home_dir.is_empty() {
|
record.target(),
|
||||||
let mut s = msg.replace(&home_dir, "<HOME>");
|
redacted
|
||||||
if !escaped_home_dir.is_empty() && escaped_home_dir != home_dir {
|
))
|
||||||
s = s.replace(&escaped_home_dir, "<HOME>");
|
|
||||||
}
|
|
||||||
s
|
|
||||||
} else {
|
|
||||||
msg
|
|
||||||
};
|
|
||||||
out.finish(format_args!(
|
|
||||||
"[{}][{}][{}] {}",
|
|
||||||
chrono::Local::now().format("%Y-%m-%d][%H:%M:%S"),
|
|
||||||
record.level(),
|
|
||||||
record.target(),
|
|
||||||
redacted
|
|
||||||
))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
@@ -5955,7 +5984,8 @@ pub fn run() {
|
|||||||
parity::create_category_directories,
|
parity::create_category_directories,
|
||||||
db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads,
|
db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads,
|
||||||
db_get_all_queues, db_replace_queues,
|
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!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
|
|||||||
@@ -397,6 +397,12 @@ function App() {
|
|||||||
invoke('toggle_log_pause', { pause: !logsEnabled }).catch(console.error);
|
invoke('toggle_log_pause', { pause: !logsEnabled }).catch(console.error);
|
||||||
}, [logsEnabled]);
|
}, [logsEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeView !== 'logs') {
|
||||||
|
invoke('set_log_stream_active', { active: false }).catch(console.error);
|
||||||
|
}
|
||||||
|
}, [activeView]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!extensionPairingToken) return;
|
if (!extensionPairingToken) return;
|
||||||
invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => {
|
invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => {
|
||||||
|
|||||||
@@ -120,9 +120,8 @@ export const AddDownloadsModal = () => {
|
|||||||
if (context) return extensionHeaders(context).trim();
|
if (context) return extensionHeaders(context).trim();
|
||||||
return hasExtensionRequestContext ? '' : headers.trim();
|
return hasExtensionRequestContext ? '' : headers.trim();
|
||||||
};
|
};
|
||||||
const cookiesForRow = (sourceUrl: string, omitRequestCookies = false) => {
|
const cookiesForRow = (sourceUrl: string) => {
|
||||||
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
||||||
if (omitRequestCookies) return '';
|
|
||||||
const context = requestContextForUrl(sourceUrl);
|
const context = requestContextForUrl(sourceUrl);
|
||||||
if (context) return context.cookies.trim();
|
if (context) return context.cookies.trim();
|
||||||
return hasExtensionRequestContext ? '' : cookies.trim();
|
return hasExtensionRequestContext ? '' : cookies.trim();
|
||||||
@@ -303,25 +302,7 @@ export const AddDownloadsModal = () => {
|
|||||||
cookies: rowCookies || null,
|
cookies: rowCookies || null,
|
||||||
proxy
|
proxy
|
||||||
};
|
};
|
||||||
let requestCookiesOmitted = false;
|
const mediaData = await fetchMediaMetadataDeduped(mediaMetadataArgs);
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (mediaData && mediaData.formats.length > 0) {
|
if (mediaData && mediaData.formats.length > 0) {
|
||||||
const mappedFormats = mediaData.formats.map(f => {
|
const mappedFormats = mediaData.formats.map(f => {
|
||||||
const quality = f.resolution || 'Video';
|
const quality = f.resolution || 'Video';
|
||||||
@@ -353,7 +334,6 @@ export const AddDownloadsModal = () => {
|
|||||||
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined,
|
size: mappedFormats[0].bytes ? mappedFormats[0].detail : undefined,
|
||||||
sizeBytes: mappedFormats[0].bytes || undefined,
|
sizeBytes: mappedFormats[0].bytes || undefined,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
requestCookiesOmitted,
|
|
||||||
formats: mappedFormats,
|
formats: mappedFormats,
|
||||||
selectedFormat: 0
|
selectedFormat: 0
|
||||||
})
|
})
|
||||||
@@ -729,7 +709,7 @@ export const AddDownloadsModal = () => {
|
|||||||
checksum: checksumEnabled && checksumValue.trim()
|
checksum: checksumEnabled && checksumValue.trim()
|
||||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||||
: undefined,
|
: undefined,
|
||||||
cookies: cookiesForRow(item.sourceUrl, item.requestCookiesOmitted) || undefined,
|
cookies: cookiesForRow(item.sourceUrl) || undefined,
|
||||||
mirrors: mirrors.trim() || undefined,
|
mirrors: mirrors.trim() || undefined,
|
||||||
destination: useSharedDestination
|
destination: useSharedDestination
|
||||||
? finalLocation
|
? finalLocation
|
||||||
@@ -1148,11 +1128,6 @@ export const AddDownloadsModal = () => {
|
|||||||
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
|
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
|
||||||
aria-label="Cookies"
|
aria-label="Cookies"
|
||||||
/>
|
/>
|
||||||
{!cookiesManuallyEditedRef.current && parsedItems.some(item => item.requestCookiesOmitted) && (
|
|
||||||
<p className="mt-1 text-[10px] text-amber-400">
|
|
||||||
Media metadata only worked without the captured cookies, so they will be omitted for affected rows. Edit this field to force a manual value.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Mirrors</label>
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Mirrors</label>
|
||||||
|
|||||||
+81
-43
@@ -7,11 +7,15 @@ import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'luc
|
|||||||
import { WindowDragRegion } from './WindowDragRegion';
|
import { WindowDragRegion } from './WindowDragRegion';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
|
import {
|
||||||
interface LogEntry {
|
MAX_LOG_LINES,
|
||||||
level: 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error';
|
appendBoundedLogEntries,
|
||||||
message: string;
|
liveLogEntry,
|
||||||
}
|
mergeLogSnapshotAndLiveEntries,
|
||||||
|
persistedLogEntry,
|
||||||
|
pushBoundedLogEntry,
|
||||||
|
type LogEntry
|
||||||
|
} from '../utils/logEntries';
|
||||||
|
|
||||||
export default function LogsView() {
|
export default function LogsView() {
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
@@ -20,56 +24,75 @@ export default function LogsView() {
|
|||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
|
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||||
|
const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden');
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const rawLineCountRef = useRef(0);
|
const liveBatchRef = useRef<LogEntry[]>([]);
|
||||||
|
const liveFrameRef = useRef<number | null>(null);
|
||||||
const MAX_LOG_LINES = 2000;
|
|
||||||
|
|
||||||
useEffect(() => {
|
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 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 () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
await initLogger();
|
await initLogger();
|
||||||
const [lines] = await Promise.all([
|
|
||||||
invoke('read_logs', { limit: MAX_LOG_LINES })
|
|
||||||
]);
|
|
||||||
if (!active) return;
|
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) {
|
if (logsEnabled) {
|
||||||
unlisten = await attachLogger((log) => {
|
unlistenPromise = attachLogger((log) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const levelStr: LogEntry['level'] = log.level === 5 ? 'Error'
|
scheduleLiveEntry(liveLogEntry(log.level, log.message));
|
||||||
: 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;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to init logs:', e);
|
console.error('Failed to init logs:', e);
|
||||||
}
|
}
|
||||||
@@ -78,9 +101,19 @@ export default function LogsView() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
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(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
@@ -139,6 +172,11 @@ export default function LogsView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = async () => {
|
const handleClear = async () => {
|
||||||
|
liveBatchRef.current = [];
|
||||||
|
if (liveFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(liveFrameRef.current);
|
||||||
|
liveFrameRef.current = null;
|
||||||
|
}
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
await invoke('clear_logs').catch(console.error);
|
await invoke('clear_logs').catch(console.error);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ type CommandMap = {
|
|||||||
clear_logs: { args: undefined; result: void };
|
clear_logs: { args: undefined; result: void };
|
||||||
toggle_log_pause: { args: { pause: boolean }; result: void };
|
toggle_log_pause: { args: { pause: boolean }; result: void };
|
||||||
is_log_paused: { args: undefined; result: boolean };
|
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[] };
|
get_pending_order: { args: { queueId: string | null }; result: string[] };
|
||||||
enqueue_download: { args: { item: EnqueueItem }; result: EnqueueAccepted };
|
enqueue_download: { args: { item: EnqueueItem }; result: EnqueueAccepted };
|
||||||
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
|
cancel_enqueue_generation: { args: { id: string; generation: string }; result: void };
|
||||||
|
|||||||
@@ -819,7 +819,7 @@ describe('useDownloadStore', () => {
|
|||||||
silent: false,
|
silent: false,
|
||||||
filename: null,
|
filename: null,
|
||||||
headers: 'User-Agent: Firefox Test',
|
headers: 'User-Agent: Firefox Test',
|
||||||
cookies: 'session=secret',
|
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
|
||||||
media: true
|
media: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -827,7 +827,21 @@ describe('useDownloadStore', () => {
|
|||||||
expect(state.isAddModalOpen).toBe(true);
|
expect(state.isAddModalOpen).toBe(true);
|
||||||
expect(state.pendingAddUrls).toBe('https://adult.example/watch/123');
|
expect(state.pendingAddUrls).toBe('https://adult.example/watch/123');
|
||||||
expect(state.pendingAddMediaUrls).toEqual(['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 () => {
|
it('clears stale request context when the same URL is captured without it later', async () => {
|
||||||
|
|||||||
@@ -551,12 +551,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
|
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
|
||||||
if (urls.length === 0) return;
|
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(
|
get().openAddModalWithUrls(
|
||||||
urls.join('\n'),
|
urls.join('\n'),
|
||||||
request.referer,
|
request.referer,
|
||||||
urls.length === 1 ? request.filename : null,
|
urls.length === 1 ? request.filename : null,
|
||||||
request.headers,
|
request.headers,
|
||||||
request.cookies,
|
cookies,
|
||||||
request.media === true
|
request.media === true
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ describe('add download metadata workflow', () => {
|
|||||||
status: 'ready',
|
status: 'ready',
|
||||||
generation: 4,
|
generation: 4,
|
||||||
requestContextVersion: 1,
|
requestContextVersion: 1,
|
||||||
requestCookiesOmitted: true,
|
|
||||||
formats: [{
|
formats: [{
|
||||||
name: '1080p MP4',
|
name: '1080p MP4',
|
||||||
selector: '137+140',
|
selector: '137+140',
|
||||||
@@ -121,7 +120,6 @@ describe('add download metadata workflow', () => {
|
|||||||
status: 'loading',
|
status: 'loading',
|
||||||
generation: 5,
|
generation: 5,
|
||||||
requestContextVersion: 2,
|
requestContextVersion: 2,
|
||||||
requestCookiesOmitted: false,
|
|
||||||
formats: undefined,
|
formats: undefined,
|
||||||
selectedFormat: undefined
|
selectedFormat: undefined
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export interface AddDownloadDraftRow {
|
|||||||
status: MetadataStatus;
|
status: MetadataStatus;
|
||||||
generation: number;
|
generation: number;
|
||||||
requestContextVersion?: number;
|
requestContextVersion?: number;
|
||||||
requestCookiesOmitted?: boolean;
|
|
||||||
isMedia: boolean;
|
isMedia: boolean;
|
||||||
resumable?: boolean;
|
resumable?: boolean;
|
||||||
formats?: AddMediaFormat[];
|
formats?: AddMediaFormat[];
|
||||||
@@ -94,7 +93,6 @@ export const reconcileDownloadRows = (
|
|||||||
status: 'loading',
|
status: 'loading',
|
||||||
generation: preserved.generation + 1,
|
generation: preserved.generation + 1,
|
||||||
requestContextVersion,
|
requestContextVersion,
|
||||||
requestCookiesOmitted: false,
|
|
||||||
isMedia: preserved.isMedia || forcedMedia,
|
isMedia: preserved.isMedia || forcedMedia,
|
||||||
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
|
formats: preserved.isMedia || forcedMedia ? undefined : preserved.formats,
|
||||||
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat
|
selectedFormat: preserved.isMedia || forcedMedia ? undefined : preserved.selectedFormat
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<number, LogLevel> = {
|
||||||
|
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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user