Compare commits

..

1 Commits

Author SHA1 Message Date
NimBold 62b4a132ce test(startup): isolate Windows reveal timing (#37)
- Issue #37: test post-load IPC without revealing the Windows main window at RunEvent::Ready.\n- Use the result to distinguish native reveal timing from the renderer command path.\n\nRefs #37.
2026-08-27 23:46:10 +03:30
2 changed files with 41 additions and 444 deletions
+30 -270
View File
@@ -18217,9 +18217,6 @@ fn set_log_stream_active(caller: tauri::WebviewWindow, active: bool) -> Result<(
Ok(())
}
type FirelinkInvokeHandler =
Box<dyn Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync>;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
ensure_reqwest_crypto_provider();
@@ -18254,7 +18251,7 @@ pub fn run() {
let aria2_port = Arc::new(std::sync::atomic::AtomicU16::new(initial_aria2_port));
let aria2_port_clone = Arc::clone(&aria2_port);
let aria2_secret = uuid::Uuid::new_v4().to_string();
let builder = tauri::Builder::default()
tauri::Builder::default()
.manage(MainWindowRestoreState::default())
.manage(properties_window::PropertiesWindowRegistry::default())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
@@ -19862,117 +19859,25 @@ pub fn run() {
let _ = window.hide();
}
}
});
// Keep each generated handler below the Windows stack-overflow threshold.
// Tauri expands generate_handler! into one command match whose arms include
// argument extraction for every command. A single 1.4-era table was large
// enough to overflow the Windows main-thread stack on the first invoke.
let engine_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
get_engine_status,
get_aria2_engine_status,
get_ytdlp_engine_status,
get_ffmpeg_engine_status,
get_deno_engine_status,
test_ytdlp,
test_aria2c,
test_ffmpeg,
test_deno,
]);
let media_startup_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
pause_download,
resume_download,
fetch_metadata,
inspect_torrent,
rekey_torrent_metadata,
remove_torrent_metadata,
fetch_media_metadata,
fetch_media_playlist_metadata,
begin_dock_badge_session,
update_dock_badge,
get_platform_info,
]);
let system_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
approve_download_root,
set_prevent_sleep,
set_power_preferences,
get_free_space,
perform_system_action,
ack_schedule_trigger,
check_automation_permission,
request_automation_permission,
open_automation_settings,
]);
let keychain_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
set_keychain_password,
get_keychain_password,
delete_keychain_password,
save_site_login,
delete_site_login,
]);
let pairing_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
hydrate_extension_pairing_token,
get_session_pairing_token,
regenerate_pairing_token,
grant_keychain_access,
get_keychain_grant_status,
accept_keychain_grant,
abandon_keychain_grant,
authorize_keychain_access,
acknowledge_pairing_token_change,
]);
let extension_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
inspect_download_target,
toggle_tray_icon,
set_extension_pairing_token,
get_extension_server_port,
set_extension_frontend_ready,
ack_frontend_exit,
ack_extension_download,
]);
let torrent_limits_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
set_concurrent_limit,
set_queue_concurrency_limits,
set_download_speed_limit,
set_torrent_upload_limit,
set_torrent_peer_options,
get_torrent_peers,
get_torrent_availability,
]);
let torrent_files_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
get_torrent_file_progress,
get_torrent_piece_progress,
get_torrent_file_selection,
set_torrent_file_selection,
get_torrent_details,
get_torrent_magnet_link,
export_torrent_metadata,
]);
let torrent_storage_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
move_torrent_data,
cancel_torrent_move_data,
verify_torrent_data,
get_torrent_web_seeds,
set_torrent_web_seeds,
set_torrent_max_open_files,
set_torrent_overall_upload_limit,
set_global_speed_limit,
]);
let download_queue_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
remove_download,
get_download_primary_path,
detach_download_for_reconfigure,
enqueue_download,
enqueue_many,
cancel_enqueue_generation,
move_in_queue,
move_many_in_queue,
remove_from_queue,
get_pending_order,
]);
let commands_and_properties_handler: FirelinkInvokeHandler =
Box::new(tauri::generate_handler![
commands::reveal_in_file_manager,
commands::open_downloaded_file,
})
.invoke_handler(tauri::generate_handler![
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status,
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno,
pause_download, resume_download, fetch_metadata, inspect_torrent, rekey_torrent_metadata, remove_torrent_metadata, fetch_media_metadata, fetch_media_playlist_metadata,
begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, set_power_preferences, get_free_space, perform_system_action,
ack_schedule_trigger,
check_automation_permission, request_automation_permission, open_automation_settings,
set_keychain_password, get_keychain_password, delete_keychain_password,
save_site_login, delete_site_login,
hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access,
get_keychain_grant_status, accept_keychain_grant, abandon_keychain_grant,
authorize_keychain_access,
acknowledge_pairing_token_change,
inspect_download_target, toggle_tray_icon, set_extension_pairing_token,
get_extension_server_port, set_extension_frontend_ready, ack_frontend_exit, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, get_torrent_availability, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_file_selection, set_torrent_file_selection, get_torrent_details, get_torrent_magnet_link, export_torrent_metadata, move_torrent_data, cancel_torrent_move_data, verify_torrent_data, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path,
detach_download_for_reconfigure,
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
commands::reveal_in_file_manager, commands::open_downloaded_file,
properties_window::open_download_properties_window,
properties_window::get_properties_window_download_id,
properties_window::properties_window_send_ready,
@@ -19981,166 +19886,21 @@ pub fn run() {
properties_window::validate_properties_window_request,
properties_window::close_download_properties_window,
properties_window::properties_window_registry_remove_for_download,
]);
let parity_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
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,
]);
let database_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
db_save_settings,
db_load_settings,
canonicalize_torrent_network_setting,
db_get_all_downloads,
db_replace_downloads,
db_commit_download_state,
clear_torrent_removal_paths,
reconcile_torrent_removal_reservations,
db_get_all_queues,
db_replace_queues,
]);
let logs_handler: FirelinkInvokeHandler = Box::new(tauri::generate_handler![
read_logs,
export_logs,
toggle_log_pause,
is_log_paused,
clear_logs,
set_log_stream_active,
]);
builder
.invoke_handler(move |invoke| {
let command = invoke.message.command().to_owned();
match command.as_str() {
"get_engine_status"
| "get_aria2_engine_status"
| "get_ytdlp_engine_status"
| "get_ffmpeg_engine_status"
| "get_deno_engine_status"
| "test_ytdlp"
| "test_aria2c"
| "test_ffmpeg"
| "test_deno" => engine_handler(invoke),
"pause_download"
| "resume_download"
| "fetch_metadata"
| "inspect_torrent"
| "rekey_torrent_metadata"
| "remove_torrent_metadata"
| "fetch_media_metadata"
| "fetch_media_playlist_metadata"
| "begin_dock_badge_session"
| "update_dock_badge"
| "get_platform_info" => media_startup_handler(invoke),
"approve_download_root"
| "set_prevent_sleep"
| "set_power_preferences"
| "get_free_space"
| "perform_system_action"
| "ack_schedule_trigger"
| "check_automation_permission"
| "request_automation_permission"
| "open_automation_settings" => system_handler(invoke),
"set_keychain_password"
| "get_keychain_password"
| "delete_keychain_password"
| "save_site_login"
| "delete_site_login" => keychain_handler(invoke),
"hydrate_extension_pairing_token"
| "get_session_pairing_token"
| "regenerate_pairing_token"
| "grant_keychain_access"
| "get_keychain_grant_status"
| "accept_keychain_grant"
| "abandon_keychain_grant"
| "authorize_keychain_access"
| "acknowledge_pairing_token_change" => pairing_handler(invoke),
"inspect_download_target"
| "toggle_tray_icon"
| "set_extension_pairing_token"
| "get_extension_server_port"
| "set_extension_frontend_ready"
| "ack_frontend_exit"
| "ack_extension_download" => extension_handler(invoke),
"set_concurrent_limit"
| "set_queue_concurrency_limits"
| "set_download_speed_limit"
| "set_torrent_upload_limit"
| "set_torrent_peer_options"
| "get_torrent_peers"
| "get_torrent_availability" => torrent_limits_handler(invoke),
"get_torrent_file_progress"
| "get_torrent_piece_progress"
| "get_torrent_file_selection"
| "set_torrent_file_selection"
| "get_torrent_details"
| "get_torrent_magnet_link"
| "export_torrent_metadata" => torrent_files_handler(invoke),
"move_torrent_data"
| "cancel_torrent_move_data"
| "verify_torrent_data"
| "get_torrent_web_seeds"
| "set_torrent_web_seeds"
| "set_torrent_max_open_files"
| "set_torrent_overall_upload_limit"
| "set_global_speed_limit" => torrent_storage_handler(invoke),
"remove_download"
| "get_download_primary_path"
| "detach_download_for_reconfigure"
| "enqueue_download"
| "enqueue_many"
| "cancel_enqueue_generation"
| "move_in_queue"
| "move_many_in_queue"
| "remove_from_queue"
| "get_pending_order" => download_queue_handler(invoke),
"reveal_in_file_manager"
| "open_downloaded_file"
| "open_download_properties_window"
| "get_properties_window_download_id"
| "properties_window_send_ready"
| "properties_window_reveal"
| "properties_window_send_action"
| "validate_properties_window_request"
| "close_download_properties_window"
| "properties_window_registry_remove_for_download" => {
commands_and_properties_handler(invoke)
}
"get_system_proxy"
| "get_file_category"
| "check_for_updates"
| "is_supported_media"
| "get_supported_media_domains"
| "create_category_directories" => parity_handler(invoke),
"db_save_settings"
| "db_load_settings"
| "canonicalize_torrent_network_setting"
| "db_get_all_downloads"
| "db_replace_downloads"
| "db_commit_download_state"
| "clear_torrent_removal_paths"
| "reconcile_torrent_removal_reservations"
| "db_get_all_queues"
| "db_replace_queues" => database_handler(invoke),
"read_logs"
| "export_logs"
| "toggle_log_pause"
| "is_log_paused"
| "clear_logs"
| "set_log_stream_active" => logs_handler(invoke),
_ => false,
}
})
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,
db_save_settings, db_load_settings, canonicalize_torrent_network_setting,
db_get_all_downloads, db_replace_downloads,
db_commit_download_state,
clear_torrent_removal_paths, reconcile_torrent_removal_reservations,
db_get_all_queues, db_replace_queues,
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")
.run(|app_handle, event| match event {
tauri::RunEvent::Ready => {
mark_main_window_startup_complete(app_handle);
#[cfg(target_os = "windows")]
reveal_main_window(app_handle);
restore_pending_main_window(app_handle);
}
#[cfg(target_os = "macos")]
+11 -174
View File
@@ -1,178 +1,15 @@
import { StrictMode, type ComponentType } from "react";
import { createRoot } from "react-dom/client";
import "@fontsource-variable/inter/wght.css";
import "@fontsource-variable/noto-sans-hebrew/wght.css";
import "@fontsource-variable/noto-sans-sc/wght.css";
import "@fontsource-variable/outfit/wght.css";
import "@fontsource-variable/roboto/wght.css";
import "@fontsource-variable/vazirmatn/wght.css";
import "./index.css";
import { i18nReady } from "./i18n";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { ToastProvider } from "./contexts/ToastContext";
import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
import { getCurrentWindow } from '@tauri-apps/api/window';
import { invokeCommand as invoke } from './ipc';
const isPropertiesWindow = getCurrentWindow().label.startsWith('properties-');
// WebView2 can overflow its native call stack when the renderer sends IPC
// during document bootstrapping. Keep all renderer-to-native startup work
// behind the document load boundary, not just the first logger query. This is
// also needed for Zustand persistence, whose module initialization reads the
// native database before React mounts.
const documentLoaded = new Promise<void>((resolve) => {
const releaseAfterNativeLoad = () => {
// The load event is dispatched from WebView2's navigation callback. Move
// renderer startup to the next task so its first IPC cannot re-enter that
// native callback stack.
window.setTimeout(resolve, 0);
};
if (document.readyState === 'complete') {
releaseAfterNativeLoad();
return;
}
window.addEventListener('load', releaseAfterNativeLoad, { once: true });
});
void documentLoaded.then(() => {
void initLogger();
});
const serializeConsoleArguments = (values: unknown[]) => values.map(value => {
if (value instanceof Error) return `${value.name}: ${value.message}\n${value.stack || ''}`;
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}).join(' ');
const redactConsoleMessage = (message: string) => message
.replace(/(authorization|cookie|password|token|secret)\s*[:=]\s*([^\s,;]+)/gi, '$1=[redacted]')
.replace(/(https?:\/\/[^\s?]+)\?[^\s]+/g, '$1?[redacted]');
const originalConsoleError = console.error.bind(console);
const originalConsoleWarn = console.warn.bind(console);
console.error = (...values: unknown[]) => {
originalConsoleError(...values);
const message = redactConsoleMessage(serializeConsoleArguments(values));
void documentLoaded.then(() => logError(message)).catch(() => undefined);
};
console.warn = (...values: unknown[]) => {
originalConsoleWarn(...values);
const message = redactConsoleMessage(serializeConsoleArguments(values));
void documentLoaded.then(() => logWarn(message)).catch(() => undefined);
};
import { invoke } from "@tauri-apps/api/core";
const rootElement = document.getElementById("root");
const renderRoot = (RootComponent: ComponentType) => {
if (!rootElement) return;
if (rootElement) rootElement.textContent = "Firelink startup control";
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>
<ToastProvider>
<RootComponent />
</ToastProvider>
</ErrorBoundary>
</StrictMode>,
);
};
const PropertiesStartupFailure = () => (
<main className="properties-window-shell flex h-screen min-h-0 flex-col items-center justify-center gap-4 bg-main-bg p-6 text-text-primary">
<p role="alert">Download Properties could not be loaded.</p>
<button
type="button"
className="app-button app-button-primary px-3 text-xs"
onClick={() => {
void getCurrentWindow().close().catch(error => {
console.error('[PropertiesStartupFailure] close failed', error);
});
}}
>
Close
</button>
</main>
);
const MainStartupFailure = () => (
<main className="flex h-screen min-h-0 flex-col items-center justify-center gap-4 bg-main-bg p-6 text-text-primary">
<p role="alert">Firelink could not be loaded.</p>
<button
type="button"
className="app-button app-button-primary px-3 text-xs"
onClick={() => {
void getCurrentWindow().close().catch(error => {
console.error('[MainStartupFailure] close failed', error);
});
}}
>
Close
</button>
</main>
);
const renderMainApp = async () => {
if (!rootElement) return;
await documentLoaded;
try {
// Keep the child entrypoint isolated from the main application module. App
// imports the persistent Zustand stores, whose module initialization issues
// main-window-only IPC commands. Loading it in a Properties child creates a
// second persistence owner and can race the bridge handshake.
const RootComponent = (await import('./App')).default;
renderRoot(RootComponent);
} catch (error) {
console.error('Failed to initialize Firelink:', error);
renderRoot(MainStartupFailure);
}
};
const renderPropertiesApp = async () => {
if (!rootElement) return;
await documentLoaded;
try {
// Properties starts with the synchronous English catalog and changes locale
// after its first paint. Waiting for a lazy locale chunk here delays the
// loading shell and makes native window startup visible to the user.
const RootComponent = (await import('./components/PropertiesWindowApp')).PropertiesWindowApp;
renderRoot(RootComponent);
} catch (error) {
// A failed lazy chunk must not leave the native window hidden forever. Show
// a styled, closable failure state and use the same caller-validated native
// reveal command as the normal child path.
console.error('Failed to initialize the Properties window:', error);
renderRoot(PropertiesStartupFailure);
const fallbackSessionId = crypto.randomUUID();
void invoke('properties_window_send_ready', { sessionId: fallbackSessionId })
.then(() => invoke('properties_window_reveal', { sessionId: fallbackSessionId }))
.catch(revealError => {
console.error('Failed to reveal the Properties startup error:', revealError);
});
}
};
if (isPropertiesWindow) {
void renderPropertiesApp();
} else {
void i18nReady.then(renderMainApp).catch(error => {
console.error('Failed to initialize localization:', error);
void renderMainApp();
});
}
// Prevent the webview's default context menu ("Reload", etc.) on right-click.
// Individual components that provide custom context menus call preventDefault()
// in their own onContextMenu handlers, which fires before this document-level
// listener and is unaffected.
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
const documentLoaded = new Promise<void>((resolve) => {
const releaseAfterNativeLoad = () => window.setTimeout(resolve, 0);
if (document.readyState === "complete") releaseAfterNativeLoad();
else window.addEventListener("load", releaseAfterNativeLoad, { once: true });
});
void documentLoaded.then(async () => {
await invoke<boolean>("is_log_paused");
if (rootElement) rootElement.textContent = "Firelink post-load log read";
});