Compare commits

..

4 Commits

Author SHA1 Message Date
NimBold 66781b8c6f fix(startup): split Windows invoke handlers (#37) 2026-08-28 00:10:11 +03:30
NimBold 639f5bf091 fix(startup): defer renderer IPC past load callback (#37)
- Issue #37: Windows still overflowed when startup IPC ran from the WebView2 load callback.
- Release the renderer startup gate on the next event-loop task after load returns.
- Keep logger and console forwarding behind the same post-load boundary.

Fixes #37.
2026-08-27 22:45:55 +03:30
NimBold 97f15dee37 fix(startup): gate renderer IPC on document load (#37)
- Issue #37: Windows 1.4.0 exited with STATUS_STACK_OVERFLOW before showing the main window.
- Gate lazy entrypoint imports, Zustand persistence, logger initialization, and console forwarding until WebView2 document load.
- Preserve the native Ready reveal and pending-window restore lifecycle across platforms.
- Show a closeable startup error if the main entrypoint cannot load.

Fixes #37.
2026-08-27 22:26:53 +03:30
NimBold 8d1cde8d2c fix(startup): restore Windows renderer startup (#37)
- Issue #37: Windows 1.4.0 exited before showing its main window.\n- Keep the logger state read independent of WebViewWindow extraction during WebView2 bootstrap.\n- Preserve main-window authorization for logging mutations and keep the process-wide pause state consistent across renderers.\n\nFixes #37.
2026-08-27 21:23:00 +03:30
3 changed files with 324 additions and 142 deletions
+1 -45
View File
@@ -37,7 +37,6 @@ const child = spawn(executable, [], {
});
let stderr = '';
let stdout = '';
let spawnError = null;
let readyPort = null;
let childExit = null;
@@ -56,9 +55,7 @@ child.on('exit', (code, signal) => {
child.stderr.on('data', data => {
stderr += data.toString();
});
child.stdout.on('data', data => {
stdout += data.toString();
});
child.stdout.on('data', () => {});
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
@@ -190,45 +187,6 @@ if ($visible.Count -gt 0) {
}
}
function windowsCrashDiagnostics() {
if (process.platform !== 'win32') {
return '';
}
const script = `
$start = (Get-Date).AddMinutes(-15)
$events = @(Get-WinEvent -FilterHashtable @{ LogName = 'Application'; StartTime = $start } -ErrorAction SilentlyContinue |
Where-Object {
$_.ProviderName -in @('Application Error', 'Windows Error Reporting') -and
$_.Message -match '(?i)firelink'
} |
Select-Object -First 8)
foreach ($event in $events) {
Write-Output ("EVENT " + $event.TimeCreated.ToString('o') + " " + $event.ProviderName + " ID=" + $event.Id)
Write-Output $event.Message
}
`;
try {
return execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
}).trim();
} catch {
return '';
}
}
function startupDiagnostics() {
const sections = [];
if (stdout.trim()) sections.push(`Firelink stdout:\n${stdout.slice(-6000)}`);
if (stderr.trim()) sections.push(`Firelink stderr:\n${stderr.slice(-6000)}`);
const crashEvents = windowsCrashDiagnostics();
if (crashEvents) sections.push(`Windows application events:\n${crashEvents.slice(-12000)}`);
return sections.join('\n\n');
}
function waitForChildExit(timeoutMs) {
if (childExit) {
return Promise.resolve(true);
@@ -438,8 +396,6 @@ try {
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort} with ${stabilityMs}ms stability`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
const diagnostics = startupDiagnostics();
if (diagnostics) console.error(`\nStartup diagnostics:\n${diagnostics}`);
process.exitCode = 1;
} finally {
if (!await terminateChild()) {
+264 -88
View File
@@ -3662,22 +3662,6 @@ struct MainWindowRestoreState {
startup_complete: AtomicBool,
}
fn startup_trace(marker: &str) {
if std::env::var_os("FIRELINK_SMOKE_TEST").is_some() {
eprintln!("[firelink-startup] {marker}");
}
}
static STARTUP_WINDOW_EVENT_COUNT: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
fn startup_trace_window_event(label: &str) {
let count = STARTUP_WINDOW_EVENT_COUNT.fetch_add(1, Ordering::Relaxed);
if count < 64 {
startup_trace(label);
}
}
#[cfg(target_os = "macos")]
const MAIN_WINDOW_MINIMIZE_MENU_ID: &str = "main_window_minimize";
@@ -18216,9 +18200,12 @@ fn toggle_log_pause(caller: tauri::WebviewWindow, pause: bool) -> Result<(), Str
}
#[tauri::command]
fn is_log_paused(caller: tauri::WebviewWindow) -> bool {
properties_window::ensure_main_window(&caller).is_ok()
&& LOG_PAUSED.load(std::sync::atomic::Ordering::Relaxed)
fn is_log_paused() -> bool {
// This read is needed during renderer bootstrap, including by standalone
// Properties windows. Avoid extracting a WebviewWindow here: on Windows,
// WebView2 can invoke the command while its host is still initializing.
// Mutating logging commands remain restricted to the main window below.
LOG_PAUSED.load(std::sync::atomic::Ordering::Relaxed)
}
#[tauri::command]
@@ -18264,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();
tauri::Builder::default()
let builder = tauri::Builder::default()
.manage(MainWindowRestoreState::default())
.manage(properties_window::PropertiesWindowRegistry::default())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
@@ -18275,12 +18262,10 @@ pub fn run() {
.plugin(tauri_plugin_deep_link::init())
.manage(Aria2DaemonGuard::new())
.setup(move |app| {
startup_trace("setup:begin");
let storage_layout = crate::storage::StorageLayout::resolve(
app.handle(),
setup_storage_mode.clone(),
)?;
startup_trace("setup:storage");
let main_window_config = app
.config()
.app
@@ -18298,7 +18283,6 @@ pub fn run() {
main_window_builder =
main_window_builder.data_directory(storage_layout.webview_dir().to_path_buf());
}
startup_trace("setup:window-builder");
let mut sys = sysinfo::System::new_all();
sys.refresh_all();
@@ -18312,7 +18296,6 @@ pub fn run() {
log::info!("==========================");
build_main_tray(app.handle())
.map_err(|error| format!("failed to create tray menu: {error}"))?;
startup_trace("setup:tray");
#[cfg(target_os = "macos")]
{
use tauri::menu::{MenuItem, WINDOW_SUBMENU_ID};
@@ -18365,7 +18348,6 @@ pub fn run() {
// malformed or interrupted transaction cannot reach a missing
// Tauri state entry at startup.
app.manage(database);
startup_trace("setup:database");
if let Err(error) = recover_torrent_move_journals(
app.handle(),
&*app.state::<crate::db::DbState>(),
@@ -18379,7 +18361,6 @@ pub fn run() {
) {
log::warn!("Download replacement recovery did not complete: {error}");
}
startup_trace("setup:recovery");
// Establish Firelink-owned Aria2 routing-table paths after the
// existing data-root initializer has created the selected storage
// directory, but before the daemon launcher is scheduled. A
@@ -18441,7 +18422,6 @@ pub fn run() {
log::warn!("could not identify retained torrent metadata: {error}");
}
}
startup_trace("setup:torrent-cache");
let initial_pairing_token = {
// Generate a temporary session token for the extension server on startup.
// The frontend will hydrate the real token via IPC once it mounts,
@@ -18538,12 +18518,10 @@ pub fn run() {
scheduler_settings: Arc::clone(&scheduler_settings),
queue_manager,
});
startup_trace("setup:app-state");
if let Err(error) = power_manager.activate() {
log::error!("power: failed to activate backend power management: {error}");
}
startup_trace("setup:power");
// Build the window only after all command state is registered. This
// prevents the frontend from racing startup and invoking IPC before
@@ -18589,9 +18567,7 @@ pub fn run() {
main_window_builder
.build()
.map_err(|error| format!("failed to create main window: {error}"))?;
startup_trace("setup:window-built");
restore_pending_main_window(app.handle());
startup_trace("setup:pending-window-restored");
#[cfg(any(target_os = "windows", target_os = "linux"))]
dispatch_opened_torrent_paths(
@@ -18612,9 +18588,7 @@ pub fn run() {
Ok(None) => {}
Err(error) => eprintln!("Failed to read startup deep link: {error}"),
}
startup_trace("setup:deep-link");
crate::scheduler::spawn_scheduler(app.handle().clone(), scheduler_settings);
startup_trace("setup:scheduler");
let global_speed_limit = persisted_settings
.as_ref()
@@ -18657,12 +18631,10 @@ pub fn run() {
let aria2_secret_clone = aria2_secret.clone();
let app_handle_bg = app.handle().clone();
tauri::async_runtime::spawn(async move {
startup_trace("aria2-task:begin");
let mut ws_port = 6800;
match resolve_bundled_binary_path(&app_handle_bg, "aria2c") {
Ok(binary_path) => {
startup_trace("aria2-task:binary");
let mut success = false;
let mut attempted_rpc_port = false;
let mut startup_failure = None;
@@ -18756,7 +18728,6 @@ pub fn run() {
match cmd.spawn() {
Ok(mut child) => {
startup_trace("aria2-task:spawned");
// Give it a moment to fail if port is in use
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
if let Ok(Some(status)) = child.try_wait() {
@@ -18846,7 +18817,6 @@ pub fn run() {
attempt_port,
async_dns_supported
);
startup_trace("aria2-task:ready");
ready = true;
break;
}
@@ -18899,10 +18869,8 @@ pub fn run() {
ws_retries = 0;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
startup_trace("aria2-task:websocket-attempt");
let ws_url = format!("ws://127.0.0.1:{}/jsonrpc", ws_port);
if let Ok((ws_stream, _)) = tokio_tungstenite::connect_async(&ws_url).await {
startup_trace("aria2-task:websocket-connected");
ws_retries = 0; // reset on success
if let Ok(mut startup_error) = app_handle_bg
.state::<Aria2DaemonGuard>()
@@ -18916,7 +18884,6 @@ pub fn run() {
}
}
reconcile_aria2_downloads(&app_handle_bg).await;
startup_trace("aria2-task:reconciled");
use futures_util::StreamExt;
let (_, mut read) = ws_stream.split();
while let Some(msg) = read.next().await {
@@ -19043,7 +19010,6 @@ pub fn run() {
let poll_secret = aria2_secret.clone();
let poll_mgr = Arc::clone(&queue_manager_poll);
tauri::async_runtime::spawn(async move {
startup_trace("poller:begin");
let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000));
let mut observations: HashMap<String, Aria2ConnectionObservation> = HashMap::new();
let mut missing_gid_recovery_at: HashMap<String, Instant> = HashMap::new();
@@ -19055,7 +19021,6 @@ pub fn run() {
let mut relocation_checks = HashSet::new();
loop {
interval.tick().await;
startup_trace("poller:tick");
// Terminal cleanup removes a download's GID mapping. Do
// not retain one observation per historical download for
// the lifetime of the poller.
@@ -19103,11 +19068,9 @@ pub fn run() {
.unwrap_or_else(|| "none".to_string()),
active_poll_started.elapsed().as_millis()
);
startup_trace("poller:rpc-failed");
continue;
}
};
startup_trace("poller:rpc-ok");
if let Some(active_arr) = active_list.as_array() {
let mut seen_ids = HashSet::new();
let mut seen_gids = HashSet::new();
@@ -19787,7 +19750,6 @@ pub fn run() {
let ext_app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
startup_trace("extension-task:begin");
while let Err(error) = extension_server::start_server(
ext_app_handle.clone(),
server_pairing_token.clone(),
@@ -19800,7 +19762,6 @@ pub fn run() {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
}
});
startup_trace("setup:tasks-started");
Ok(())
})
.plugin(
@@ -19847,9 +19808,6 @@ pub fn run() {
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_clipboard_manager::init())
.on_window_event(|window, event| {
if window.label() == "main" {
startup_trace_window_event("window-event:main");
}
if properties_window::is_properties_window_label(window.label()) {
let resize = match event {
tauri::WindowEvent::Resized(size) => {
@@ -19901,54 +19859,272 @@ 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 startup_handler: Box<dyn Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync> =
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,
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,
]);
let account_and_extension_handler: Box<dyn Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync> =
Box::new(tauri::generate_handler![
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,
]);
let torrent_handler: Box<dyn Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync> =
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,
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,
]);
let queue_and_properties_handler: Box<dyn Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync> =
Box::new(tauri::generate_handler![
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,
properties_window::properties_window_reveal,
properties_window::properties_window_send_action,
properties_window::validate_properties_window_request,
properties_window::close_download_properties_window,
properties_window::properties_window_registry_remove_for_download,
]);
let persistence_and_logs_handler: Box<dyn Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync> =
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,
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,
]);
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"
| "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" => startup_handler(invoke),
"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" => account_and_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"
| "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" => torrent_handler(invoke),
"enqueue_download"
| "enqueue_many"
| "cancel_enqueue_generation"
| "move_in_queue"
| "move_many_in_queue"
| "remove_from_queue"
| "get_pending_order"
| "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" => {
queue_and_properties_handler(invoke)
}
"get_system_proxy"
| "get_file_category"
| "check_for_updates"
| "is_supported_media"
| "get_supported_media_domains"
| "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" => persistence_and_logs_handler(invoke),
_ => false,
}
})
.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,
properties_window::properties_window_reveal,
properties_window::properties_window_send_action,
properties_window::validate_properties_window_request,
properties_window::close_download_properties_window,
properties_window::properties_window_registry_remove_for_download,
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 => {
startup_trace("run-event:ready-begin");
mark_main_window_startup_complete(app_handle);
#[cfg(target_os = "windows")]
reveal_main_window(app_handle);
#[cfg(not(target_os = "windows"))]
restore_pending_main_window(app_handle);
startup_trace("run-event:ready-end");
}
#[cfg(target_os = "macos")]
tauri::RunEvent::Opened { urls } => {
+59 -9
View File
@@ -16,7 +16,29 @@ import { invokeCommand as invoke } from './ipc';
const isPropertiesWindow = getCurrentWindow().label.startsWith('properties-');
void initLogger();
// 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 || ''}`;
@@ -36,11 +58,13 @@ const originalConsoleError = console.error.bind(console);
const originalConsoleWarn = console.warn.bind(console);
console.error = (...values: unknown[]) => {
originalConsoleError(...values);
void logError(redactConsoleMessage(serializeConsoleArguments(values))).catch(() => undefined);
const message = redactConsoleMessage(serializeConsoleArguments(values));
void documentLoaded.then(() => logError(message)).catch(() => undefined);
};
console.warn = (...values: unknown[]) => {
originalConsoleWarn(...values);
void logWarn(redactConsoleMessage(serializeConsoleArguments(values))).catch(() => undefined);
const message = redactConsoleMessage(serializeConsoleArguments(values));
void documentLoaded.then(() => logWarn(message)).catch(() => undefined);
};
const rootElement = document.getElementById("root");
@@ -75,20 +99,46 @@ const PropertiesStartupFailure = () => (
</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;
// 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);
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