mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-10 09:45:44 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8931f14f72 | |||
| a5cecb3777 | |||
| 9c0ba106d6 | |||
| 8163e2241a | |||
| 0c65837360 | |||
| 201bb1e07c |
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+73
-9
@@ -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(¤t)
|
||||
.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,24 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn reveal_main_window(app_handle: &tauri::AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
if let Err(error) = window.set_focusable(true) {
|
||||
log::warn!("Could not make the main window focusable: {error}");
|
||||
}
|
||||
if let Err(error) = window.show() {
|
||||
eprintln!("Failed to reveal the main window: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_pending_main_window(app_handle: &tauri::AppHandle) {
|
||||
if app_handle
|
||||
.try_state::<MainWindowRestoreState>()
|
||||
@@ -18497,6 +18550,17 @@ pub fn run() {
|
||||
main_window_builder = main_window_builder
|
||||
.inner_size(startup_size.width as f64, startup_size.height as f64)
|
||||
.prevent_overflow();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Wry installs its parent WM_SETFOCUS handler while WebView2
|
||||
// is still being initialized. Keep the host hidden and
|
||||
// non-activatable until RunEvent::Ready so Windows cannot
|
||||
// re-enter that handler during native construction.
|
||||
main_window_builder = main_window_builder
|
||||
.visible(false)
|
||||
.focused(false)
|
||||
.focusable(false);
|
||||
}
|
||||
main_window_builder
|
||||
.build()
|
||||
.map_err(|error| format!("failed to create main window: {error}"))?;
|
||||
@@ -18508,13 +18572,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 +19896,13 @@ 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);
|
||||
#[cfg(target_os = "windows")]
|
||||
reveal_main_window(app_handle);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
restore_pending_main_window(app_handle);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
tauri::RunEvent::Opened { urls } => {
|
||||
let paths = collect_opened_torrent_paths(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
-37
@@ -15,8 +15,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { initDownloadListener } from './store/downloadStore';
|
||||
import {
|
||||
subscribeToSettingsPersistenceErrors,
|
||||
useSettingsStore,
|
||||
waitForSettingsPersistence
|
||||
useSettingsStore
|
||||
} from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import { WindowControls } from "./components/WindowControls";
|
||||
@@ -40,9 +39,7 @@ import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDownloadBytes } from './utils/downloadProgress';
|
||||
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
|
||||
import { createMainWindowSizePersistence } from './utils/mainWindowState';
|
||||
import { createSidebarResizeSession } from './utils/sidebarResize';
|
||||
import type { MainWindowSize } from './bindings/MainWindowSize';
|
||||
import {
|
||||
beginSchedulerControl,
|
||||
consumeSchedulerHandoffIds,
|
||||
@@ -494,44 +491,14 @@ function App() {
|
||||
useEffect(() => {
|
||||
const disposePersistence = initializeDownloadPersistence(getCurrentWindow().label);
|
||||
let active = true;
|
||||
let exitRequested = false;
|
||||
let exiting = false;
|
||||
let settingsHydrated = useSettingsStore.persist.hasHydrated();
|
||||
let latestSizeBeforeHydration: MainWindowSize | null = null;
|
||||
const unlistenSettingsHydration = settingsHydrated
|
||||
? null
|
||||
: useSettingsStore.persist.onFinishHydration(() => {
|
||||
settingsHydrated = true;
|
||||
const size = latestSizeBeforeHydration;
|
||||
latestSizeBeforeHydration = null;
|
||||
if (size && active && !exitRequested && !exiting) {
|
||||
useSettingsStore.getState().setMainWindowSize(size);
|
||||
}
|
||||
});
|
||||
const mainWindowSizePersistence = createMainWindowSizePersistence({
|
||||
appWindow: getCurrentWindow(),
|
||||
onSize: size => {
|
||||
if (!active || exiting) return;
|
||||
if (!settingsHydrated) {
|
||||
latestSizeBeforeHydration = size;
|
||||
return;
|
||||
}
|
||||
useSettingsStore.getState().setMainWindowSize(size);
|
||||
}
|
||||
});
|
||||
let cleanupListeners: (() => void) | null = null;
|
||||
let unlistenExit: (() => void) | null = null;
|
||||
const exitListener = listen('app-exit-requested', async () => {
|
||||
exitRequested = true;
|
||||
try {
|
||||
await mainWindowSizePersistence.flush();
|
||||
await waitForSettingsPersistence();
|
||||
await flushDownloadPersistence();
|
||||
} catch (error) {
|
||||
console.error('Failed to flush download state before exit:', error);
|
||||
} finally {
|
||||
exiting = true;
|
||||
latestSizeBeforeHydration = null;
|
||||
await invoke('ack_frontend_exit').catch(error => {
|
||||
console.error('Failed to acknowledge frontend exit flush:', error);
|
||||
});
|
||||
@@ -550,7 +517,6 @@ function App() {
|
||||
let unlistenDeepLink: (() => void) | null = null;
|
||||
const disposeListeners = () => {
|
||||
void queueFrontendReadyUpdate(false).catch(() => {});
|
||||
mainWindowSizePersistence.dispose();
|
||||
unlistenExit?.();
|
||||
unlistenExit = null;
|
||||
unlistenTerminalState?.();
|
||||
@@ -756,8 +722,6 @@ function App() {
|
||||
cleanupListeners = null;
|
||||
unlistenExit?.();
|
||||
unlistenExit = null;
|
||||
unlistenSettingsHydration?.();
|
||||
mainWindowSizePersistence.dispose();
|
||||
disposePersistence();
|
||||
};
|
||||
}, [addToast, enqueueAddInput, processExtensionDownload, queueFrontendReadyUpdate]);
|
||||
|
||||
Reference in New Issue
Block a user