Compare commits

..

7 Commits

Author SHA1 Message Date
NimBold fa8d158f36 test(startup): isolate full renderer without logger IPC (#37)
- Issue #37: test the full 1.4.0 renderer without the module-time logger query.\n- Keep the normal native window lifecycle and all other startup code intact.\n\nRefs #37.
2026-08-27 22:02: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
NimBold a5cecb3777 fix(startup): block Windows activation during WebView2 init (#37)
- Issue #37: keep the Windows host hidden, unfocused, and non-activatable while WebView2 is constructed.\n- Restore focusability and reveal the main window only after Tauri reports Ready.\n- Preserve queued startup restores on non-Windows instead of clearing them before servicing.\n\nFixes #37.
2026-08-27 17:34:04 +03:30
NimBold 9c0ba106d6 fix(startup): defer Windows window activation (#37)
- Keep the Windows WebView host hidden and unfocused during native construction.\n- Queue deep-link, torrent, single-instance, and extension restore requests until Tauri is ready.\n- Reveal and focus the main window through one startup-safe path.\n\nFixes #37.
2026-08-27 17:00:57 +03:30
NimBold 8163e2241a fix(startup): avoid reentrant Windows focus (#37)
- Keep the main WebView2 window unfocused during native construction.
- Remove the synchronous post-build focus request that caused the Windows stack overflow.
- Refs #37.
2026-08-27 15:58:47 +03:30
NimBold 0c65837360 fix(startup): prevent Windows WebView2 focus crash (#37)
- Create the main Windows WebView without requesting focus until the native window exists.\n- Keep hidden Properties WebViews unfocused until their reveal path.\n- Make packaged smoke checks observe startup stability and clean up lingering Unix helpers.\n- Refs #37
2026-08-27 15:27:04 +03:30
NimBold 201bb1e07c fix(startup): support redirected Windows storage paths (#37)
- Issue #37: allow Windows AppData junctions to resolve before the main window starts.
- Keep reparse-point rejection for user-selected download and recovery paths.
- Add Windows junction coverage and v1.3.1 schema migration data-preservation coverage.
2026-08-27 13:21:46 +03:30
7 changed files with 208 additions and 38 deletions
+61 -10
View File
@@ -17,6 +17,12 @@ if (!executableArg) {
const executable = path.resolve(executableArg);
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
const assertPortableData = process.argv.includes('--assert-portable-data');
const MAX_STABILITY_MS = 60_000;
const MAX_CONSECUTIVE_STABILITY_FAILURES = 3;
const stabilityMsValue = Number.parseInt(argValue('--stability-ms') || '5000', 10);
const stabilityMs = Number.isFinite(stabilityMsValue) && stabilityMsValue >= 0
? Math.min(stabilityMsValue, MAX_STABILITY_MS)
: 5000;
const READY_PORT_TIMEOUT_MS = 500;
const child = spawn(executable, [], {
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
@@ -83,6 +89,55 @@ async function findReadyPort() {
}
}
async function checkReadyPort() {
if (readyPort === null) return false;
try {
const response = await fetch(`http://127.0.0.1:${readyPort}/ping`, {
signal: AbortSignal.timeout(READY_PORT_TIMEOUT_MS),
});
const matchesChild = response.headers.get('x-firelink-server') === '1'
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid);
await response.body?.cancel();
return matchesChild;
} catch {
return false;
}
}
async function assertStableReady() {
const deadline = Date.now() + stabilityMs;
let consecutiveFailures = 0;
while (Date.now() < deadline) {
if (spawnError) {
throw new Error(`Packaged Firelink failed during stability check: ${spawnError.message}`);
}
if (childExit) {
throw new Error(
`Packaged Firelink exited during stability check with code ${childExit.code} signal ${childExit.signal}.`,
);
}
if (await checkReadyPort()) {
consecutiveFailures = 0;
} else {
consecutiveFailures += 1;
if (consecutiveFailures >= MAX_CONSECUTIVE_STABILITY_FAILURES) {
throw new Error('Packaged Firelink stopped exposing its extension ping endpoint during stability check.');
}
}
await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
}
if (childExit) {
throw new Error(
`Packaged Firelink exited during stability check with code ${childExit.code} signal ${childExit.signal}.`,
);
}
if (!await checkReadyPort() && !await checkReadyPort()) {
throw new Error('Packaged Firelink was not healthy at the end of its stability check.');
}
}
function assertNoVisibleWindows(rootPid) {
if (process.platform !== 'win32') {
return;
@@ -277,18 +332,16 @@ async function terminateChild() {
}
}
}
if (await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000)) {
const childExited = await waitForChildExit(5000);
const processGroupExited = await waitForProcessGroupExit(child.pid, 5000);
if (childExited && processGroupExited) {
return true;
}
if (!childWasRunning || childExit) {
return false;
}
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
if (!childExit) {
if (!childExited) {
child.kill('SIGKILL');
}
}
@@ -338,11 +391,9 @@ try {
await assertPortableStorage();
}
if (childExit) {
throw new Error(`Packaged Firelink exited after exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`);
}
await assertStableReady();
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
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));
process.exitCode = 1;
+51
View File
@@ -2523,6 +2523,57 @@ mod tests {
}));
}
#[test]
fn migrates_v1_database_and_creates_backup() {
let temp = TempDir::new().unwrap();
let path = temp.path().join(DATABASE_NAME);
let connection = Connection::open(&path).unwrap();
connection
.execute_batch(
"
CREATE TABLE downloads (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
queue_id TEXT,
data TEXT NOT NULL
);
CREATE TABLE settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL);
CREATE TABLE queues (id TEXT PRIMARY KEY, data TEXT NOT NULL);
CREATE TABLE download_ownership (
id TEXT PRIMARY KEY,
primary_path TEXT NOT NULL
);
INSERT INTO download_ownership VALUES ('download-1', '/downloads/file.bin');
PRAGMA user_version = 1;
",
)
.unwrap();
drop(connection);
let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap();
let version: i64 = connection
.pragma_query_value(None, "user_version", |row| row.get(0))
.unwrap();
assert_eq!(version, CURRENT_SCHEMA_VERSION);
assert!(table_exists(&connection, "download_owned_paths").unwrap());
assert!(table_exists(&connection, "download_removal_paths").unwrap());
assert_eq!(
load_ownership(&connection).unwrap(),
vec![(
"download-1".to_string(),
"/downloads/file.bin".to_string(),
vec!["/downloads/file.bin".to_string()]
)]
);
assert!(fs::read_dir(temp.path()).unwrap().flatten().any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("firelink.sqlite.backup-schema-v1-")
}));
}
#[cfg(unix)]
#[test]
fn refuses_to_open_a_database_symlink() {
+10 -12
View File
@@ -308,18 +308,16 @@ async fn download_handler(
None => return Err(StatusCode::BAD_REQUEST),
};
if let Some(window) = state.app_handle.get_webview_window("main") {
let is_visible = window.is_visible().unwrap_or(true);
if !is_visible {
let _ = window.show();
let _ = window.set_focus();
// Sleep briefly to let the webview wake up from macOS App Nap
// otherwise the IPC event emitted immediately after is dropped.
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
} else {
let _ = window.show();
let _ = window.set_focus();
}
let is_hidden = state
.app_handle
.get_webview_window("main")
.and_then(|window| window.is_visible().ok())
.is_some_and(|is_visible| !is_visible);
crate::restore_main_window(&state.app_handle);
if is_hidden {
// Sleep briefly to let the webview wake up from macOS App Nap
// otherwise the IPC event emitted immediately after is dropped.
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
}
if !wait_for_frontend(&state.frontend_ready).await {
+53 -12
View File
@@ -3328,6 +3328,21 @@ fn metadata_is_link_or_reparse(metadata: &std::fs::Metadata) -> bool {
}
pub(crate) fn path_has_symlink_component(path: &std::path::Path) -> bool {
path_has_component_matching(path, metadata_is_link_or_reparse)
}
/// Detect only symbolic-link components, without treating every Windows
/// reparse point as a link. Trusted application-data directories may use
/// junctions for Windows folder redirection; user-selected download and
/// recovery paths continue to use the stricter helper above.
pub(crate) fn path_has_symbolic_link_component(path: &std::path::Path) -> bool {
path_has_component_matching(path, |metadata| metadata.file_type().is_symlink())
}
fn path_has_component_matching(
path: &std::path::Path,
matches: impl Fn(&std::fs::Metadata) -> bool,
) -> bool {
use std::path::Component;
let mut current = std::path::PathBuf::new();
@@ -3339,7 +3354,7 @@ pub(crate) fn path_has_symlink_component(path: &std::path::Path) -> bool {
Component::Normal(name) => {
current.push(name);
if std::fs::symlink_metadata(&current)
.is_ok_and(|metadata| metadata_is_link_or_reparse(&metadata))
.is_ok_and(|metadata| matches(&metadata))
{
return true;
}
@@ -3644,6 +3659,7 @@ pub struct AppState {
#[derive(Default)]
struct MainWindowRestoreState {
requested: AtomicBool,
startup_complete: AtomicBool,
}
#[cfg(target_os = "macos")]
@@ -3939,7 +3955,26 @@ where
.collect()
}
fn restore_main_window(app_handle: &tauri::AppHandle) {
pub(crate) fn restore_main_window(app_handle: &tauri::AppHandle) {
let startup_complete = app_handle
.try_state::<MainWindowRestoreState>()
.is_none_or(|state| state.startup_complete.load(Ordering::Acquire));
if !startup_complete {
if let Some(state) = app_handle.try_state::<MainWindowRestoreState>() {
// The first window can be requested by a single-instance callback
// or an opened .torrent path while the native host is still being
// constructed. Do not touch the HWND until RunEvent::Ready.
state.requested.store(true, Ordering::Release);
// Ready may have won the transition between the first load and
// the request store. Re-check before returning so that request is
// serviced by this call instead of being left pending forever.
if !state.startup_complete.load(Ordering::Acquire) {
return;
}
} else {
return;
}
}
let Some(window) = app_handle.get_webview_window("main") else {
if let Some(state) = app_handle.try_state::<MainWindowRestoreState>() {
state.requested.store(true, Ordering::Release);
@@ -3952,6 +3987,12 @@ fn restore_main_window(app_handle: &tauri::AppHandle) {
let _ = window.set_focus();
}
fn mark_main_window_startup_complete(app_handle: &tauri::AppHandle) {
if let Some(state) = app_handle.try_state::<MainWindowRestoreState>() {
state.startup_complete.store(true, Ordering::Release);
}
}
fn restore_pending_main_window(app_handle: &tauri::AppHandle) {
if app_handle
.try_state::<MainWindowRestoreState>()
@@ -18147,9 +18188,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]
@@ -18508,13 +18552,6 @@ pub fn run() {
collect_opened_torrent_paths(std::env::args_os().skip(1)),
);
#[cfg(target_os = "windows")]
if let Some(window) = app.get_webview_window("main") {
window
.set_decorations(false)
.map_err(|error| format!("failed to disable Windows native frame: {error}"))?;
}
let deep_link_app = app.handle().clone();
#[cfg(target_os = "linux")]
if let Err(error) = app.deep_link().register_all() {
@@ -19839,6 +19876,10 @@ pub fn run() {
.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);
restore_pending_main_window(app_handle);
}
#[cfg(target_os = "macos")]
tauri::RunEvent::Opened { urls } => {
let paths = collect_opened_torrent_paths(
+3
View File
@@ -437,6 +437,9 @@ pub fn open_download_properties_window(
// native window becomes visible. Showing an opaque native surface
// here exposes the webview's unpainted white background.
.visible(false)
// A hidden WebView2 must not request focus during construction. The
// native reveal path focuses it after the window is visible.
.focused(false)
.transparent(true);
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
let builder = builder.decorations(false);
+29 -1
View File
@@ -251,7 +251,7 @@ fn aria2_server_stat_is_valid(contents: &str) -> bool {
}
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
if crate::path_has_symlink_component(path) {
if crate::path_has_symbolic_link_component(path) {
return Err(format!(
"storage path contains a symlinked component: '{}'",
path.display()
@@ -455,4 +455,32 @@ mod tests {
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
}
#[cfg(windows)]
#[test]
fn accepts_windows_junctions_for_redirected_storage_paths() {
use std::process::Command;
let parent = TempDir::new().unwrap();
let spaced_parent = parent.path().join("firelink test data");
fs::create_dir(&spaced_parent).unwrap();
let root = TempDir::new_in(&spaced_parent).unwrap();
let target = TempDir::new_in(&spaced_parent).unwrap();
let redirected = root.path().join("redirected");
let target_storage = target.path().join("firelink");
fs::create_dir(&target_storage).unwrap();
let status = Command::new("cmd")
.args(["/D", "/C", "mklink", "/J"])
.arg(&redirected)
.arg(target.path())
.status()
.expect("Windows junction creation command should start");
assert!(status.success(), "mklink /J failed with status {status}");
assert_eq!(
canonicalize_storage_path(&redirected.join("firelink")).unwrap(),
fs::canonicalize(target_storage).unwrap()
);
}
}
+1 -3
View File
@@ -10,14 +10,12 @@ 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 { error as logError, warn as logWarn } from "./utils/logger";
import { getCurrentWindow } from '@tauri-apps/api/window';
import { invokeCommand as invoke } from './ipc';
const isPropertiesWindow = getCurrentWindow().label.startsWith('properties-');
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;