mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-22 08:56:44 +00:00
fix(properties): harden recovery and window chrome
This commit is contained in:
@@ -331,14 +331,16 @@ pub fn open_download_properties_window(
|
|||||||
// If the native window disappeared without delivering Destroyed, discard
|
// If the native window disappeared without delivering Destroyed, discard
|
||||||
// the old readiness bit before constructing a fresh hidden webview.
|
// the old readiness bit before constructing a fresh hidden webview.
|
||||||
registry.clear_ready(&label)?;
|
registry.clear_ready(&label)?;
|
||||||
let build_result = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("index.html".into()))
|
let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("index.html".into()))
|
||||||
.title(PROPERTIES_WINDOW_TITLE)
|
.title(PROPERTIES_WINDOW_TITLE)
|
||||||
.inner_size(1000.0, 720.0)
|
.inner_size(1000.0, 720.0)
|
||||||
.min_inner_size(760.0, 560.0)
|
.min_inner_size(760.0, 560.0)
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.always_on_top(false)
|
.always_on_top(false)
|
||||||
.visible(false)
|
.visible(false);
|
||||||
.build();
|
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
|
||||||
|
let builder = builder.decorations(false);
|
||||||
|
let build_result = builder.build();
|
||||||
if let Err(error) = build_result {
|
if let Err(error) = build_result {
|
||||||
// Two rapid main-window requests can race between the native lookup
|
// Two rapid main-window requests can race between the native lookup
|
||||||
// above and builder creation. If the first request won, retain the
|
// above and builder creation. If the first request won, retain the
|
||||||
@@ -496,12 +498,18 @@ pub fn close_download_properties_window(
|
|||||||
if registered_id.as_deref() != Some(id.as_str()) {
|
if registered_id.as_deref() != Some(id.as_str()) {
|
||||||
return Err("Properties window close request is not registered".to_string());
|
return Err("Properties window close request is not registered".to_string());
|
||||||
}
|
}
|
||||||
if let Some(label) = registry.window_for_download(&id)? {
|
if let Some(window_label) = registry.window_for_download(&id)? {
|
||||||
if let Some(window) = app.get_webview_window(&label) {
|
if let Some(window) = app.get_webview_window(&window_label) {
|
||||||
window.close().map_err(|error| error.to_string())?;
|
window.close().map_err(|error| error.to_string())?;
|
||||||
|
} else {
|
||||||
|
// A native window can disappear without delivering its Destroyed
|
||||||
|
// event. Only clear this stale registry entry when there is no
|
||||||
|
// window left to receive a close-request veto from the child.
|
||||||
|
let _ = registry.remove_download(&id);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
let _ = registry.remove_download(&id);
|
||||||
}
|
}
|
||||||
let _ = registry.remove_download(&id);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +525,11 @@ pub fn properties_window_registry_remove_for_download(
|
|||||||
}
|
}
|
||||||
if let Some(label) = registry.remove_download(&id)? {
|
if let Some(label) = registry.remove_download(&id)? {
|
||||||
if let Some(window) = app.get_webview_window(&label) {
|
if let Some(window) = app.get_webview_window(&label) {
|
||||||
let _ = window.close();
|
// This command is used after the download has already been
|
||||||
|
// removed. It is a forced lifecycle teardown, so a dirty-draft
|
||||||
|
// close-request handler must not be able to leave an orphaned
|
||||||
|
// Properties window behind.
|
||||||
|
let _ = window.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
@@ -15,15 +15,18 @@ import {
|
|||||||
PROPERTIES_WINDOW_REMOVED,
|
PROPERTIES_WINDOW_REMOVED,
|
||||||
PROPERTIES_WINDOW_SNAPSHOT,
|
PROPERTIES_WINDOW_SNAPSHOT,
|
||||||
attachAsyncPropertiesListener,
|
attachAsyncPropertiesListener,
|
||||||
|
DEFAULT_PROPERTIES_WINDOW_CHROME,
|
||||||
formatPropertiesQueuePlacement,
|
formatPropertiesQueuePlacement,
|
||||||
getPropertiesLifecycleAction,
|
getPropertiesLifecycleAction,
|
||||||
propertiesLifecycleReachedPostcondition,
|
|
||||||
propertiesTorrentPeerLimit,
|
propertiesTorrentPeerLimit,
|
||||||
propertiesDiagnosticRequestState,
|
propertiesDiagnosticRequestState,
|
||||||
sendPropertiesActionRequest,
|
sendPropertiesActionRequest,
|
||||||
sendPropertiesReady,
|
sendPropertiesReady,
|
||||||
isExpectedPropertiesDiagnosticUnavailable,
|
isExpectedPropertiesDiagnosticUnavailable,
|
||||||
|
nextPropertiesRequestId,
|
||||||
propertiesDiagnosticPhase,
|
propertiesDiagnosticPhase,
|
||||||
|
propertiesActionRequestKey,
|
||||||
|
resetPropertiesActionState,
|
||||||
type PropertiesAction,
|
type PropertiesAction,
|
||||||
type PropertiesActionRequest,
|
type PropertiesActionRequest,
|
||||||
type PropertiesActionResult,
|
type PropertiesActionResult,
|
||||||
@@ -35,6 +38,9 @@ import {
|
|||||||
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
|
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
|
||||||
import { changeAppLocale } from '../i18n';
|
import { changeAppLocale } from '../i18n';
|
||||||
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
|
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
|
||||||
|
import { getWindowControlRevealOffset } from '../utils/windowControlStyle';
|
||||||
|
import { getPropertiesFooterActions } from '../utils/propertiesFooter';
|
||||||
|
import { WindowControls } from './WindowControls';
|
||||||
import {
|
import {
|
||||||
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
||||||
TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION,
|
TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION,
|
||||||
@@ -47,9 +53,6 @@ type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | '
|
|||||||
type SecretName = 'username' | 'password' | 'cookies' | 'headers';
|
type SecretName = 'username' | 'password' | 'cookies' | 'headers';
|
||||||
type SecretDraft = { value: string; touched: boolean; clear: boolean };
|
type SecretDraft = { value: string; touched: boolean; clear: boolean };
|
||||||
const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers'];
|
const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers'];
|
||||||
const nextPropertiesRequestId = (current: number): number =>
|
|
||||||
current >= Number.MAX_SAFE_INTEGER ? 1 : current + 1;
|
|
||||||
|
|
||||||
const isTorrentDiagnosticsStatus = (status: string) =>
|
const isTorrentDiagnosticsStatus = (status: string) =>
|
||||||
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status);
|
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status);
|
||||||
|
|
||||||
@@ -149,8 +152,9 @@ export const PropertiesWindowApp = () => {
|
|||||||
const closeAfterSaveRef = useRef(false);
|
const closeAfterSaveRef = useRef(false);
|
||||||
const switchAfterSaveRef = useRef<PropertiesTab | null>(null);
|
const switchAfterSaveRef = useRef<PropertiesTab | null>(null);
|
||||||
const requestIdRef = useRef(0);
|
const requestIdRef = useRef(0);
|
||||||
|
const pendingActionRequestRef = useRef<PropertiesActionRequest | null>(null);
|
||||||
const pendingActionRef = useRef<PropertiesAction | null>(null);
|
const pendingActionRef = useRef<PropertiesAction | null>(null);
|
||||||
const pendingLifecycleIntentRef = useRef<ReturnType<typeof getPropertiesLifecycleAction>>(null);
|
const pendingActionSendKeyRef = useRef<string | null>(null);
|
||||||
const latestSnapshotRevisionRef = useRef(0);
|
const latestSnapshotRevisionRef = useRef(0);
|
||||||
const latestBridgeGenerationRef = useRef<number | null>(null);
|
const latestBridgeGenerationRef = useRef<number | null>(null);
|
||||||
const appearanceCleanupRef = useRef<(() => void) | null>(null);
|
const appearanceCleanupRef = useRef<(() => void) | null>(null);
|
||||||
@@ -167,6 +171,9 @@ export const PropertiesWindowApp = () => {
|
|||||||
const diagnosticAttemptsRef = useRef(new Set<string>());
|
const diagnosticAttemptsRef = useRef(new Set<string>());
|
||||||
const diagnosticLifecycleKeyRef = useRef('');
|
const diagnosticLifecycleKeyRef = useRef('');
|
||||||
const diagnosticLifecycleEpochRef = useRef(0);
|
const diagnosticLifecycleEpochRef = useRef(0);
|
||||||
|
const allowWindowCloseRef = useRef(false);
|
||||||
|
const isDirtyRef = useRef(false);
|
||||||
|
const windowChromeRef = useRef(DEFAULT_PROPERTIES_WINDOW_CHROME);
|
||||||
snapshotRef.current = snapshot;
|
snapshotRef.current = snapshot;
|
||||||
activeTabRef.current = activeTab;
|
activeTabRef.current = activeTab;
|
||||||
downloadIdRef.current = downloadId;
|
downloadIdRef.current = downloadId;
|
||||||
@@ -180,6 +187,23 @@ export const PropertiesWindowApp = () => {
|
|||||||
? ['overview', 'files', 'trackers', 'peers', 'options']
|
? ['overview', 'files', 'trackers', 'peers', 'options']
|
||||||
: ['overview', 'transfer', 'advanced'], [isTorrent]);
|
: ['overview', 'transfer', 'advanced'], [isTorrent]);
|
||||||
const isDirty = draftTab !== null;
|
const isDirty = draftTab !== null;
|
||||||
|
isDirtyRef.current = isDirty;
|
||||||
|
if (isDirty && allowWindowCloseRef.current) {
|
||||||
|
// A programmatic close is only authorized for the close request it
|
||||||
|
// immediately follows. If the native close was vetoed by another owner
|
||||||
|
// and the user starts editing again, restore the dirty-state guard.
|
||||||
|
allowWindowCloseRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCurrentWindow = useCallback(async (allowDirtyClose = false) => {
|
||||||
|
allowWindowCloseRef.current = allowDirtyClose;
|
||||||
|
try {
|
||||||
|
await currentWindow.close();
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(errorText(error));
|
||||||
|
allowWindowCloseRef.current = false;
|
||||||
|
}
|
||||||
|
}, [currentWindow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
draftTabRef.current = draftTab;
|
draftTabRef.current = draftTab;
|
||||||
@@ -322,6 +346,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setDownloadId(id);
|
setDownloadId(id);
|
||||||
const snapshotListener = await listen<PropertiesSnapshotEvent>(PROPERTIES_WINDOW_SNAPSHOT, async event => {
|
const snapshotListener = await listen<PropertiesSnapshotEvent>(PROPERTIES_WINDOW_SNAPSHOT, async event => {
|
||||||
|
if (cancelled) return;
|
||||||
if (event.payload.windowLabel !== windowLabel
|
if (event.payload.windowLabel !== windowLabel
|
||||||
|| event.payload.downloadId !== id
|
|| event.payload.downloadId !== id
|
||||||
|| event.payload.sessionId !== sessionId) return;
|
|| event.payload.sessionId !== sessionId) return;
|
||||||
@@ -334,7 +359,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
diagnosticLifecycleKeyRef.current = '';
|
diagnosticLifecycleKeyRef.current = '';
|
||||||
diagnosticAttemptsRef.current.clear();
|
diagnosticAttemptsRef.current.clear();
|
||||||
const lostAction = pendingActionRef.current;
|
const lostAction = pendingActionRef.current;
|
||||||
const lostApply = lostAction === 'apply-properties';
|
const lostDraftAction = lostAction === 'apply-properties'
|
||||||
|
|| lostAction === 'set-torrent-file-selection';
|
||||||
// A main-webview restart can lose both the action-result event and
|
// A main-webview restart can lose both the action-result event and
|
||||||
// the store transition event while the child Properties window
|
// the store transition event while the child Properties window
|
||||||
// remains alive. No result from the dead bridge can be correlated
|
// remains alive. No result from the dead bridge can be correlated
|
||||||
@@ -343,13 +369,14 @@ export const PropertiesWindowApp = () => {
|
|||||||
// action. This never replays a possibly completed lifecycle
|
// action. This never replays a possibly completed lifecycle
|
||||||
// request, and also recovers when the native request failed while
|
// request, and also recovers when the native request failed while
|
||||||
// its failure event was lost.
|
// its failure event was lost.
|
||||||
requestIdRef.current = nextPropertiesRequestId(requestIdRef.current);
|
const resetActionState = resetPropertiesActionState(requestIdRef.current);
|
||||||
pendingActionRef.current = null;
|
requestIdRef.current = resetActionState.requestId;
|
||||||
pendingLifecycleIntentRef.current = null;
|
pendingActionRequestRef.current = resetActionState.request;
|
||||||
|
pendingActionRef.current = resetActionState.pendingAction;
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
closeAfterSaveRef.current = false;
|
closeAfterSaveRef.current = false;
|
||||||
switchAfterSaveRef.current = null;
|
switchAfterSaveRef.current = null;
|
||||||
if (lostApply) {
|
if (lostDraftAction) {
|
||||||
// A completed property action is represented by the fresh
|
// A completed property action is represented by the fresh
|
||||||
// snapshot, not by the stale draft that produced the request.
|
// snapshot, not by the stale draft that produced the request.
|
||||||
draftTabRef.current = null;
|
draftTabRef.current = null;
|
||||||
@@ -368,23 +395,16 @@ export const PropertiesWindowApp = () => {
|
|||||||
diagnosticAttemptsRef.current.clear();
|
diagnosticAttemptsRef.current.clear();
|
||||||
}
|
}
|
||||||
await changeAppLocale(event.payload.snapshot.appearance.locale);
|
await changeAppLocale(event.payload.snapshot.appearance.locale);
|
||||||
if (event.payload.revision !== latestSnapshotRevisionRef.current) return;
|
if (cancelled
|
||||||
|
|| event.payload.bridgeGeneration !== latestBridgeGenerationRef.current
|
||||||
|
|| event.payload.revision !== latestSnapshotRevisionRef.current) return;
|
||||||
appearanceCleanupRef.current?.();
|
appearanceCleanupRef.current?.();
|
||||||
appearanceCleanupRef.current = synchronizeDocumentAppearance(
|
appearanceCleanupRef.current = synchronizeDocumentAppearance(
|
||||||
window,
|
window,
|
||||||
event.payload.snapshot.appearance,
|
event.payload.snapshot.appearance,
|
||||||
);
|
);
|
||||||
|
windowChromeRef.current = event.payload.snapshot.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME;
|
||||||
setSnapshot(event.payload.snapshot);
|
setSnapshot(event.payload.snapshot);
|
||||||
if (pendingActionRef.current === 'pause-resume'
|
|
||||||
&& pendingLifecycleIntentRef.current
|
|
||||||
&& propertiesLifecycleReachedPostcondition(
|
|
||||||
pendingLifecycleIntentRef.current,
|
|
||||||
event.payload.snapshot.status,
|
|
||||||
)) {
|
|
||||||
pendingActionRef.current = null;
|
|
||||||
pendingLifecycleIntentRef.current = null;
|
|
||||||
setPendingAction(null);
|
|
||||||
}
|
|
||||||
if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot);
|
if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot);
|
||||||
void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined);
|
void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined);
|
||||||
if (!hasRevealedWindowRef.current && !revealInFlightRef.current) {
|
if (!hasRevealedWindowRef.current && !revealInFlightRef.current) {
|
||||||
@@ -407,31 +427,40 @@ export const PropertiesWindowApp = () => {
|
|||||||
}
|
}
|
||||||
unlistenSnapshot = snapshotListener;
|
unlistenSnapshot = snapshotListener;
|
||||||
const resultListener = await listen<PropertiesActionResult>(PROPERTIES_WINDOW_ACTION_RESULT, event => {
|
const resultListener = await listen<PropertiesActionResult>(PROPERTIES_WINDOW_ACTION_RESULT, event => {
|
||||||
|
if (cancelled) return;
|
||||||
if (event.payload.windowLabel !== windowLabel
|
if (event.payload.windowLabel !== windowLabel
|
||||||
|| event.payload.downloadId !== id
|
|| event.payload.downloadId !== id
|
||||||
|| event.payload.sessionId !== sessionId) return;
|
|| event.payload.sessionId !== sessionId) return;
|
||||||
if (event.payload.requestId !== requestIdRef.current) return;
|
if (event.payload.requestId !== requestIdRef.current) return;
|
||||||
|
if (pendingActionRef.current === null) return;
|
||||||
const completedAction = pendingActionRef.current;
|
const completedAction = pendingActionRef.current;
|
||||||
pendingActionRef.current = null;
|
pendingActionRef.current = null;
|
||||||
pendingLifecycleIntentRef.current = null;
|
pendingActionRequestRef.current = null;
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed');
|
if (!event.payload.ok) {
|
||||||
else {
|
setErrorMessage(event.payload.error ?? 'The action failed');
|
||||||
|
closeAfterSaveRef.current = false;
|
||||||
|
switchAfterSaveRef.current = null;
|
||||||
|
} else {
|
||||||
|
const commitsDraft = completedAction === 'apply-properties'
|
||||||
|
|| completedAction === 'set-torrent-file-selection';
|
||||||
const nextTab = switchAfterSaveRef.current;
|
const nextTab = switchAfterSaveRef.current;
|
||||||
const shouldClose = closeAfterSaveRef.current;
|
const shouldClose = closeAfterSaveRef.current;
|
||||||
switchAfterSaveRef.current = null;
|
switchAfterSaveRef.current = null;
|
||||||
closeAfterSaveRef.current = false;
|
closeAfterSaveRef.current = false;
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setNotice(completedAction === 'apply-properties' ? t($ => $.properties.saved) : '');
|
setNotice(completedAction === 'apply-properties' ? t($ => $.properties.saved) : '');
|
||||||
draftTabRef.current = null;
|
if (commitsDraft) {
|
||||||
setDraftTab(null);
|
draftTabRef.current = null;
|
||||||
if (nextTab) {
|
setDraftTab(null);
|
||||||
setActiveTab(nextTab);
|
|
||||||
setPendingTab(null);
|
|
||||||
}
|
|
||||||
if (shouldClose) {
|
|
||||||
setClosePrompt(false);
|
setClosePrompt(false);
|
||||||
void currentWindow.close().catch(error => setErrorMessage(errorText(error)));
|
if (nextTab) {
|
||||||
|
setActiveTab(nextTab);
|
||||||
|
setPendingTab(null);
|
||||||
|
}
|
||||||
|
if (shouldClose) {
|
||||||
|
void closeCurrentWindow(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -441,6 +470,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
}
|
}
|
||||||
unlistenResult = resultListener;
|
unlistenResult = resultListener;
|
||||||
const removedListener = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => {
|
const removedListener = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => {
|
||||||
|
if (cancelled) return;
|
||||||
if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) {
|
if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) {
|
||||||
if (readyRetryTimer !== undefined) {
|
if (readyRetryTimer !== undefined) {
|
||||||
window.clearInterval(readyRetryTimer);
|
window.clearInterval(readyRetryTimer);
|
||||||
@@ -454,6 +484,18 @@ export const PropertiesWindowApp = () => {
|
|||||||
diagnosticLifecycleKeyRef.current = '';
|
diagnosticLifecycleKeyRef.current = '';
|
||||||
diagnosticAttemptsRef.current.clear();
|
diagnosticAttemptsRef.current.clear();
|
||||||
setSnapshot(null);
|
setSnapshot(null);
|
||||||
|
draftTabRef.current = null;
|
||||||
|
isDirtyRef.current = false;
|
||||||
|
setDraftTab(null);
|
||||||
|
setPendingTab(null);
|
||||||
|
setClosePrompt(false);
|
||||||
|
closeAfterSaveRef.current = false;
|
||||||
|
switchAfterSaveRef.current = null;
|
||||||
|
pendingActionRequestRef.current = null;
|
||||||
|
pendingActionRef.current = null;
|
||||||
|
pendingActionSendKeyRef.current = null;
|
||||||
|
allowWindowCloseRef.current = false;
|
||||||
|
setPendingAction(null);
|
||||||
setNotice(t($ => $.downloadTable.noDownloads));
|
setNotice(t($ => $.downloadTable.noDownloads));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -491,10 +533,13 @@ export const PropertiesWindowApp = () => {
|
|||||||
unlistenSnapshot?.();
|
unlistenSnapshot?.();
|
||||||
unlistenResult?.();
|
unlistenResult?.();
|
||||||
unlistenRemoved?.();
|
unlistenRemoved?.();
|
||||||
|
pendingActionRequestRef.current = null;
|
||||||
|
pendingActionSendKeyRef.current = null;
|
||||||
|
allowWindowCloseRef.current = false;
|
||||||
appearanceCleanupRef.current?.();
|
appearanceCleanupRef.current?.();
|
||||||
appearanceCleanupRef.current = null;
|
appearanceCleanupRef.current = null;
|
||||||
};
|
};
|
||||||
}, [currentWindow, hydrateDraft, sessionId, t, windowLabel]);
|
}, [closeCurrentWindow, currentWindow, hydrateDraft, sessionId, t, windowLabel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!snapshot || draftTab !== null) return;
|
if (!snapshot || draftTab !== null) return;
|
||||||
@@ -533,10 +578,14 @@ export const PropertiesWindowApp = () => {
|
|||||||
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]);
|
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDirty) return;
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let unlisten: UnlistenFn | undefined;
|
let unlisten: UnlistenFn | undefined;
|
||||||
attachAsyncPropertiesListener(currentWindow.onCloseRequested(event => {
|
attachAsyncPropertiesListener(currentWindow.onCloseRequested(event => {
|
||||||
|
if (allowWindowCloseRef.current) {
|
||||||
|
allowWindowCloseRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isDirtyRef.current) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setClosePrompt(true);
|
setClosePrompt(true);
|
||||||
}), () => disposed, value => { unlisten = value; });
|
}), () => disposed, value => { unlisten = value; });
|
||||||
@@ -544,7 +593,27 @@ export const PropertiesWindowApp = () => {
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
unlisten?.();
|
unlisten?.();
|
||||||
};
|
};
|
||||||
}, [currentWindow, isDirty]);
|
}, [currentWindow]);
|
||||||
|
|
||||||
|
const sendPendingAction = useCallback(async (reportError: boolean) => {
|
||||||
|
const request = pendingActionRequestRef.current;
|
||||||
|
if (!request || pendingActionRef.current === null) return;
|
||||||
|
const requestKey = propertiesActionRequestKey(request);
|
||||||
|
if (pendingActionSendKeyRef.current === requestKey) return;
|
||||||
|
pendingActionSendKeyRef.current = requestKey;
|
||||||
|
try {
|
||||||
|
await sendPropertiesActionRequest(request);
|
||||||
|
} catch (error) {
|
||||||
|
// A rejected IPC promise does not prove that the host did not receive
|
||||||
|
// the request. Keep the exact request pending so the idempotent host can
|
||||||
|
// replay its result on the next attempt.
|
||||||
|
if (reportError) setErrorMessage(errorText(error));
|
||||||
|
} finally {
|
||||||
|
if (pendingActionSendKeyRef.current === requestKey) {
|
||||||
|
pendingActionSendKeyRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const requestAction = useCallback(async (
|
const requestAction = useCallback(async (
|
||||||
action: PropertiesAction,
|
action: PropertiesAction,
|
||||||
@@ -554,30 +623,26 @@ export const PropertiesWindowApp = () => {
|
|||||||
const requestId = nextPropertiesRequestId(requestIdRef.current);
|
const requestId = nextPropertiesRequestId(requestIdRef.current);
|
||||||
requestIdRef.current = requestId;
|
requestIdRef.current = requestId;
|
||||||
pendingActionRef.current = action;
|
pendingActionRef.current = action;
|
||||||
if (action === 'pause-resume') {
|
|
||||||
pendingLifecycleIntentRef.current = getPropertiesLifecycleAction(snapshot?.status ?? 'completed');
|
|
||||||
} else {
|
|
||||||
pendingLifecycleIntentRef.current = null;
|
|
||||||
}
|
|
||||||
setPendingAction(action);
|
setPendingAction(action);
|
||||||
try {
|
const request: PropertiesActionRequest = {
|
||||||
await sendPropertiesActionRequest({
|
windowLabel,
|
||||||
windowLabel,
|
downloadId,
|
||||||
downloadId,
|
sessionId,
|
||||||
sessionId,
|
requestId,
|
||||||
requestId,
|
action,
|
||||||
action,
|
payload,
|
||||||
payload,
|
};
|
||||||
});
|
pendingActionRequestRef.current = request;
|
||||||
} catch (error) {
|
await sendPendingAction(true);
|
||||||
setPendingAction(null);
|
}, [downloadId, sendPendingAction, sessionId, windowLabel]);
|
||||||
pendingActionRef.current = null;
|
|
||||||
pendingLifecycleIntentRef.current = null;
|
useEffect(() => {
|
||||||
closeAfterSaveRef.current = false;
|
if (pendingAction === null) return;
|
||||||
switchAfterSaveRef.current = null;
|
const retryTimer = window.setInterval(() => {
|
||||||
setErrorMessage(errorText(error));
|
void sendPendingAction(false);
|
||||||
}
|
}, 2500);
|
||||||
}, [downloadId, sessionId, snapshot?.status, windowLabel]);
|
return () => window.clearInterval(retryTimer);
|
||||||
|
}, [pendingAction, sendPendingAction]);
|
||||||
|
|
||||||
const updateSecretDraft = (name: SecretName, value: string) => {
|
const updateSecretDraft = (name: SecretName, value: string) => {
|
||||||
setSecretDrafts(current => ({
|
setSecretDrafts(current => ({
|
||||||
@@ -692,6 +757,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const discardDraft = () => {
|
const discardDraft = () => {
|
||||||
|
if (pendingActionRef.current !== null) return;
|
||||||
const shouldClose = closePrompt;
|
const shouldClose = closePrompt;
|
||||||
if (snapshot) hydrateDraft(snapshot);
|
if (snapshot) hydrateDraft(snapshot);
|
||||||
draftTabRef.current = null;
|
draftTabRef.current = null;
|
||||||
@@ -699,16 +765,15 @@ export const PropertiesWindowApp = () => {
|
|||||||
if (pendingTab) setActiveTab(pendingTab);
|
if (pendingTab) setActiveTab(pendingTab);
|
||||||
setPendingTab(null);
|
setPendingTab(null);
|
||||||
setClosePrompt(false);
|
setClosePrompt(false);
|
||||||
if (shouldClose) void currentWindow.close().catch(error => setErrorMessage(errorText(error)));
|
if (shouldClose) void closeCurrentWindow(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeWindow = async () => {
|
const closeWindow = async () => {
|
||||||
if (!downloadId) return;
|
if (isDirtyRef.current) {
|
||||||
try {
|
setClosePrompt(true);
|
||||||
await invoke('close_download_properties_window', { id: downloadId });
|
return;
|
||||||
} catch (error) {
|
|
||||||
setErrorMessage(errorText(error));
|
|
||||||
}
|
}
|
||||||
|
await closeCurrentWindow();
|
||||||
};
|
};
|
||||||
|
|
||||||
const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => {
|
const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => {
|
||||||
@@ -743,16 +808,34 @@ export const PropertiesWindowApp = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!downloadId) {
|
const windowChrome = snapshot?.windowChrome ?? windowChromeRef.current;
|
||||||
return <main className="properties-window-shell p-6" role="status">{errorMessage || t($ => $.app.loading)}</main>;
|
const windowControlRevealOffset = getWindowControlRevealOffset(windowChrome.controlStyle);
|
||||||
}
|
if (!downloadId || !snapshot) {
|
||||||
if (!snapshot) {
|
return (
|
||||||
return <main className="properties-window-shell p-6" role="status">{errorMessage || t($ => $.app.loading)}</main>;
|
<main
|
||||||
|
className={`properties-window-shell properties-window-shell--controls-${windowChrome.side} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`}
|
||||||
|
style={{ '--window-control-reveal-offset': `${windowControlRevealOffset}px` } as CSSProperties}
|
||||||
|
aria-labelledby="properties-window-title"
|
||||||
|
>
|
||||||
|
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
||||||
|
<div className="properties-window-titlebar" data-tauri-drag-region>
|
||||||
|
<span id="properties-window-title" data-tauri-drag-region>{t($ => $.downloadTable.properties)} - Firelink</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center p-6" role="status">
|
||||||
|
{errorMessage || t($ => $.app.loading)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0));
|
const progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0));
|
||||||
const lifecycleAction = getPropertiesLifecycleAction(snapshot.status);
|
const lifecycleAction = getPropertiesLifecycleAction(snapshot.status);
|
||||||
const editingEnabled = pendingAction === null && isEditableStatus(snapshot.status);
|
const editingEnabled = pendingAction === null && isEditableStatus(snapshot.status);
|
||||||
|
const footerActions = getPropertiesFooterActions({
|
||||||
|
isDirty,
|
||||||
|
hasUnsavedNavigation: pendingTab !== null || closePrompt,
|
||||||
|
});
|
||||||
|
const isPromptFooter = footerActions.includes('keepEditing');
|
||||||
const fileSelectionEditingEnabled = editingEnabled && isTorrentFileSelectionEditable(snapshot.status);
|
const fileSelectionEditingEnabled = editingEnabled && isTorrentFileSelectionEditable(snapshot.status);
|
||||||
const total = snapshot.size || (snapshot.totalBytes === undefined
|
const total = snapshot.size || (snapshot.totalBytes === undefined
|
||||||
? t($ => $.addDownloads.unknownSize)
|
? t($ => $.addDownloads.unknownSize)
|
||||||
@@ -790,7 +873,15 @@ export const PropertiesWindowApp = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="properties-window-shell flex h-screen min-h-0 flex-col bg-main-bg text-text-primary" aria-labelledby="properties-window-title">
|
<main
|
||||||
|
className={`properties-window-shell properties-window-shell--controls-${windowChrome.side} flex h-screen min-h-0 flex-col bg-main-bg text-text-primary`}
|
||||||
|
style={{ '--window-control-reveal-offset': `${windowControlRevealOffset}px` } as CSSProperties}
|
||||||
|
aria-labelledby="properties-window-title"
|
||||||
|
>
|
||||||
|
<WindowControls side={windowChrome.side} controlStyle={windowChrome.controlStyle} />
|
||||||
|
<div className="properties-window-titlebar" data-tauri-drag-region>
|
||||||
|
<span data-tauri-drag-region>{snapshot.fileName} - {t($ => $.downloadTable.properties)} - Firelink</span>
|
||||||
|
</div>
|
||||||
<header className="properties-window-header shrink-0 border-b border-border-modal bg-sidebar-bg px-5 py-4">
|
<header className="properties-window-header shrink-0 border-b border-border-modal bg-sidebar-bg px-5 py-4">
|
||||||
<div className="properties-window-hero-top">
|
<div className="properties-window-hero-top">
|
||||||
<div className="properties-window-title-block min-w-0">
|
<div className="properties-window-title-block min-w-0">
|
||||||
@@ -1006,7 +1097,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{(isDirty || errorMessage || notice || pendingTab || closePrompt) && <div className="shrink-0 border-t border-border-modal bg-sidebar-bg px-4 py-2" aria-live="polite">
|
{(isDirty || errorMessage || notice || pendingTab || closePrompt) && <div className="shrink-0 border-t border-border-modal bg-sidebar-bg px-4 py-2" aria-live="polite">
|
||||||
{pendingTab || closePrompt ? <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span>{t($ => $.scheduler.unsavedChanges)}</span><div className="flex gap-2"><button type="button" className="app-button px-3 text-xs" onClick={discardDraft}>{t($ => $.actions.cancel)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={pendingAction !== null} onClick={() => { closeAfterSaveRef.current = closePrompt; switchAfterSaveRef.current = pendingTab; void applyActiveTab(); }}>{t($ => $.properties.save)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => { switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setPendingTab(null); setClosePrompt(false); }}>{t($ => $.properties.cancel)}</button></div></div> : <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span className={errorMessage ? 'text-red-400' : 'text-text-muted'}>{errorMessage || notice}</span><div className="flex gap-2">{isDirty && <><button type="button" className="app-button px-3 text-xs" onClick={discardDraft}>{t($ => $.actions.cancel)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={pendingAction !== null} onClick={() => void applyActiveTab()}><Save size={14} />{t($ => $.properties.save)}</button></>}<button type="button" className="app-button px-3 text-xs" onClick={() => void closeWindow()}><X size={14} />{t($ => $.properties.cancel)}</button></div></div>}
|
{isPromptFooter ? <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span>{t($ => $.scheduler.unsavedChanges)}</span><div className="flex gap-2"><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null} onClick={discardDraft}>{t($ => $.properties.discardChanges)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={pendingAction !== null} onClick={() => { closeAfterSaveRef.current = closePrompt; switchAfterSaveRef.current = pendingTab; void applyActiveTab(); }}>{t($ => $.properties.save)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => { switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setPendingTab(null); setClosePrompt(false); }}>{t($ => $.properties.keepEditing)}</button></div></div> : <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span className={errorMessage ? 'text-red-400' : 'text-text-muted'}>{errorMessage || notice}</span><div className="flex gap-2">{footerActions.includes('discardChanges') && <><button type="button" className="app-button px-3 text-xs" disabled={pendingAction !== null} onClick={discardDraft}>{t($ => $.properties.discardChanges)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={pendingAction !== null} onClick={() => void applyActiveTab()}><Save size={14} />{t($ => $.properties.save)}</button></>}<button type="button" className="app-button px-3 text-xs" onClick={() => void closeWindow()}><X size={14} />{t($ => $.window.close)}</button></div></div>}
|
||||||
</div>}
|
</div>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,21 +22,25 @@ import {
|
|||||||
applySecretPatch,
|
applySecretPatch,
|
||||||
attachAsyncPropertiesListener,
|
attachAsyncPropertiesListener,
|
||||||
beginExclusivePropertiesAction,
|
beginExclusivePropertiesAction,
|
||||||
|
classifyPropertiesActionRequest,
|
||||||
createFrameCoalescer,
|
createFrameCoalescer,
|
||||||
enqueuePropertiesAction,
|
enqueuePropertiesAction,
|
||||||
getPropertiesLifecycleAction,
|
getPropertiesLifecycleAction,
|
||||||
|
propertiesActionRequestKey,
|
||||||
sanitizePropertiesSnapshot,
|
sanitizePropertiesSnapshot,
|
||||||
sendPropertiesActionResult,
|
sendPropertiesActionResult,
|
||||||
sendPropertiesRemoved,
|
sendPropertiesRemoved,
|
||||||
sendPropertiesSnapshot,
|
sendPropertiesSnapshot,
|
||||||
shouldAcceptPropertiesActionRequest,
|
|
||||||
type PropertiesActionRequest,
|
type PropertiesActionRequest,
|
||||||
|
type PropertiesActionResult,
|
||||||
type PropertiesPatch,
|
type PropertiesPatch,
|
||||||
type PropertiesWindowRegistration,
|
type PropertiesWindowRegistration,
|
||||||
type PropertiesWindowReady,
|
type PropertiesWindowReady,
|
||||||
} from '../propertiesBridge';
|
} from '../propertiesBridge';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
import i18n, { resolveAppLocale } from '../i18n';
|
import { getPlatformInfo } from '../utils/platform';
|
||||||
|
import { resolveWindowControlSide, resolveWindowControlStyle } from '../utils/windowControlStyle';
|
||||||
|
import i18n, { localeDirection, resolveAppLocale } from '../i18n';
|
||||||
|
|
||||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||||
let lastPropertiesBridgeGeneration = 0;
|
let lastPropertiesBridgeGeneration = 0;
|
||||||
@@ -175,6 +179,9 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
const snapshotRevisions = new Map<string, number>();
|
const snapshotRevisions = new Map<string, number>();
|
||||||
const actionsInFlight = new Set<string>();
|
const actionsInFlight = new Set<string>();
|
||||||
const actionChains = new Map<string, Promise<void>>();
|
const actionChains = new Map<string, Promise<void>>();
|
||||||
|
const actionOperations = new Map<string, Promise<void>>();
|
||||||
|
const actionResults = new Map<string, PropertiesActionResult>();
|
||||||
|
let platformOs = 'unknown';
|
||||||
const bridgeGeneration = Math.max(Date.now(), lastPropertiesBridgeGeneration + 1);
|
const bridgeGeneration = Math.max(Date.now(), lastPropertiesBridgeGeneration + 1);
|
||||||
lastPropertiesBridgeGeneration = bridgeGeneration;
|
lastPropertiesBridgeGeneration = bridgeGeneration;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -190,6 +197,43 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
handle => window.cancelAnimationFrame(handle),
|
handle => window.cancelAnimationFrame(handle),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearWindowActionState = (windowLabel: string) => {
|
||||||
|
const resultPrefix = `${windowLabel}\u0000`;
|
||||||
|
for (const key of actionResults.keys()) {
|
||||||
|
if (key.startsWith(resultPrefix)) actionResults.delete(key);
|
||||||
|
}
|
||||||
|
for (const key of actionOperations.keys()) {
|
||||||
|
if (key.startsWith(resultPrefix)) actionOperations.delete(key);
|
||||||
|
}
|
||||||
|
// A renderer session can be replaced while an accepted mutation is
|
||||||
|
// still running. Keep the download-scoped chain so the next session
|
||||||
|
// cannot start a second mutation concurrently with that operation.
|
||||||
|
// Completed chains remove themselves; host teardown clears the map.
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSessionActionResults = (windowLabel: string, sessionId: string) => {
|
||||||
|
const resultPrefix = `${windowLabel}\u0000${sessionId}\u0000`;
|
||||||
|
for (const key of actionResults.keys()) {
|
||||||
|
if (key.startsWith(resultPrefix)) actionResults.delete(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheActionResult = (key: string, result: PropertiesActionResult) => {
|
||||||
|
// A child can have only one pending action per session. Retain the most
|
||||||
|
// recent completed result for that session until a newer request is
|
||||||
|
// accepted, so a lost result can always be replayed without allowing an
|
||||||
|
// unbounded per-action cache.
|
||||||
|
const separator = key.lastIndexOf('\u0000');
|
||||||
|
const sessionPrefixEnd = separator >= 0 ? key.lastIndexOf('\u0000', separator - 1) : -1;
|
||||||
|
if (sessionPrefixEnd >= 0) {
|
||||||
|
const sessionPrefix = key.slice(0, sessionPrefixEnd + 1);
|
||||||
|
for (const existingKey of actionResults.keys()) {
|
||||||
|
if (existingKey.startsWith(sessionPrefix)) actionResults.delete(existingKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actionResults.set(key, result);
|
||||||
|
};
|
||||||
|
|
||||||
const sendFor = async (windowLabel: string, downloadId: string) => {
|
const sendFor = async (windowLabel: string, downloadId: string) => {
|
||||||
const registration = windows.get(windowLabel);
|
const registration = windows.get(windowLabel);
|
||||||
if (!registration || registration.downloadId !== downloadId || disposed) return false;
|
if (!registration || registration.downloadId !== downloadId || disposed) return false;
|
||||||
@@ -200,6 +244,17 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
?? store.queues.find(candidate => candidate.isMain);
|
?? store.queues.find(candidate => candidate.isMain);
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
const progress = useDownloadProgressStore.getState();
|
const progress = useDownloadProgressStore.getState();
|
||||||
|
const windowChrome = {
|
||||||
|
controlStyle: resolveWindowControlStyle(
|
||||||
|
settings.windowControlStyle,
|
||||||
|
platformOs,
|
||||||
|
navigator.userAgent,
|
||||||
|
),
|
||||||
|
side: resolveWindowControlSide(
|
||||||
|
settings.sidebarPosition,
|
||||||
|
localeDirection(resolveAppLocale(i18n.language)),
|
||||||
|
),
|
||||||
|
};
|
||||||
const revision = (snapshotRevisions.get(windowLabel) ?? 0) + 1;
|
const revision = (snapshotRevisions.get(windowLabel) ?? 0) + 1;
|
||||||
snapshotRevisions.set(windowLabel, revision);
|
snapshotRevisions.set(windowLabel, revision);
|
||||||
await sendPropertiesSnapshot(windowLabel, {
|
await sendPropertiesSnapshot(windowLabel, {
|
||||||
@@ -219,6 +274,7 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
moveProgress: progress.moveProgressMap[downloadId],
|
moveProgress: progress.moveProgressMap[downloadId],
|
||||||
}, {
|
}, {
|
||||||
queueName: queue?.name,
|
queueName: queue?.name,
|
||||||
|
windowChrome,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -231,6 +287,7 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
) => {
|
) => {
|
||||||
const previous = windows.get(windowLabel);
|
const previous = windows.get(windowLabel);
|
||||||
const sessionChanged = previous?.downloadId !== downloadId || previous.sessionId !== sessionId;
|
const sessionChanged = previous?.downloadId !== downloadId || previous.sessionId !== sessionId;
|
||||||
|
if (sessionChanged) clearWindowActionState(windowLabel);
|
||||||
windows.set(windowLabel, {
|
windows.set(windowLabel, {
|
||||||
downloadId,
|
downloadId,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -279,7 +336,6 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const processAction = async (request: PropertiesActionRequest) => {
|
const processAction = async (request: PropertiesActionRequest) => {
|
||||||
if (disposed) return;
|
|
||||||
let ok = false;
|
let ok = false;
|
||||||
let error: string | undefined;
|
let error: string | undefined;
|
||||||
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
||||||
@@ -440,32 +496,44 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
releaseAction?.();
|
releaseAction?.();
|
||||||
}
|
}
|
||||||
if (disposed) return;
|
if (ok) void sendFor(request.windowLabel, request.downloadId).catch(() => undefined);
|
||||||
if (ok) {
|
const result: PropertiesActionResult = {
|
||||||
try {
|
windowLabel: request.windowLabel,
|
||||||
await sendFor(request.windowLabel, request.downloadId);
|
downloadId: request.downloadId,
|
||||||
} catch {
|
sessionId: request.sessionId,
|
||||||
// Snapshot delivery is best effort across a close/reopen race.
|
requestId: request.requestId,
|
||||||
}
|
ok,
|
||||||
}
|
...(error ? { error } : {}),
|
||||||
|
};
|
||||||
|
cacheActionResult(propertiesActionRequestKey(request), result);
|
||||||
try {
|
try {
|
||||||
await sendPropertiesActionResult(request.windowLabel, {
|
await sendPropertiesActionResult(request.windowLabel, result);
|
||||||
windowLabel: request.windowLabel,
|
|
||||||
downloadId: request.downloadId,
|
|
||||||
sessionId: request.sessionId,
|
|
||||||
requestId: request.requestId,
|
|
||||||
ok,
|
|
||||||
...(error ? { error } : {}),
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// The window may have closed between the request and its result.
|
// The result remains cached so a same-request retry can replay it.
|
||||||
return;
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendRejectedActionResult = async (request: PropertiesActionRequest, reason: unknown) => {
|
||||||
|
const result: PropertiesActionResult = {
|
||||||
|
windowLabel: request.windowLabel,
|
||||||
|
downloadId: request.downloadId,
|
||||||
|
sessionId: request.sessionId,
|
||||||
|
requestId: request.requestId,
|
||||||
|
ok: false,
|
||||||
|
error: errorText(reason),
|
||||||
|
};
|
||||||
|
// Validation failures did not enter the mutation queue, so do not cache
|
||||||
|
// them as a completed request. A same-ID retry must be able to recover
|
||||||
|
// from a transient registry/session race.
|
||||||
|
try {
|
||||||
|
await sendPropertiesActionResult(request.windowLabel, result);
|
||||||
|
} catch {
|
||||||
|
// The child may have closed while the validation error was delivered.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = async (request: PropertiesActionRequest) => {
|
const handleAction = async (request: PropertiesActionRequest) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
|
||||||
try {
|
try {
|
||||||
// The native command validates the caller, download binding, and
|
// The native command validates the caller, download binding, and
|
||||||
// renderer session. If a ready event is delayed or lost, this valid
|
// renderer session. If a ready event is delayed or lost, this valid
|
||||||
@@ -473,21 +541,52 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
await invoke('validate_properties_window_request', request);
|
await invoke('validate_properties_window_request', request);
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
synchronizeRegistration(request.windowLabel, request.downloadId, request.sessionId);
|
synchronizeRegistration(request.windowLabel, request.downloadId, request.sessionId);
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Stale renderer actions are deliberately ignored. The current child
|
// Stale renderer actions are deliberately ignored. The current child
|
||||||
// session cannot safely consume a result for a superseded renderer.
|
// session cannot safely consume a result for a superseded renderer,
|
||||||
|
// but an active child still needs a terminal result to unlock its
|
||||||
|
// request state and decide whether to retry.
|
||||||
|
sendRejectedActionResult(request, error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registration = windows.get(request.windowLabel);
|
const registration = windows.get(request.windowLabel);
|
||||||
if (!registration) return;
|
if (!registration) {
|
||||||
if (!shouldAcceptPropertiesActionRequest(registration, request)) return;
|
sendRejectedActionResult(request, new Error('Properties window is no longer registered'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestKey = propertiesActionRequestKey(request);
|
||||||
|
const disposition = classifyPropertiesActionRequest(
|
||||||
|
registration,
|
||||||
|
request,
|
||||||
|
actionResults.has(requestKey),
|
||||||
|
actionOperations.has(requestKey),
|
||||||
|
);
|
||||||
|
if (disposition === 'replay') {
|
||||||
|
const result = actionResults.get(requestKey);
|
||||||
|
if (result) void sendPropertiesActionResult(request.windowLabel, result).catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (disposition === 'pending') return;
|
||||||
|
if (disposition === 'ignore') {
|
||||||
|
sendRejectedActionResult(request, new Error('Properties action is stale'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearSessionActionResults(request.windowLabel, request.sessionId);
|
||||||
registration.latestRequestId = request.requestId;
|
registration.latestRequestId = request.requestId;
|
||||||
|
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
||||||
|
|
||||||
// Preserve user order for accepted requests. This keeps a pause from an
|
// Preserve user order for accepted requests. This keeps a pause from an
|
||||||
// earlier request from running after a newer resume, while still
|
// earlier request from running after a newer resume, while still
|
||||||
// allowing the newer request to run after an already-started operation.
|
// allowing the newer request to run after an already-started operation.
|
||||||
await enqueuePropertiesAction(actionChains, actionKey, () => processAction(request));
|
const operation = enqueuePropertiesAction(actionChains, actionKey, () => processAction(request));
|
||||||
|
actionOperations.set(requestKey, operation);
|
||||||
|
const clearOperation = () => {
|
||||||
|
if (actionOperations.get(requestKey) === operation) actionOperations.delete(requestKey);
|
||||||
|
};
|
||||||
|
// Consume either outcome while removing the in-flight marker. An
|
||||||
|
// unexpected host exception must not become an unhandled rejection.
|
||||||
|
void operation.then(clearOperation, clearOperation);
|
||||||
};
|
};
|
||||||
|
|
||||||
attachAsyncPropertiesListener(
|
attachAsyncPropertiesListener(
|
||||||
@@ -510,6 +609,7 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
const registration = windows.get(event.payload);
|
const registration = windows.get(event.payload);
|
||||||
windows.delete(event.payload);
|
windows.delete(event.payload);
|
||||||
snapshotRevisions.delete(event.payload);
|
snapshotRevisions.delete(event.payload);
|
||||||
|
clearWindowActionState(event.payload);
|
||||||
snapshotCoalescer.cancel(event.payload);
|
snapshotCoalescer.cancel(event.payload);
|
||||||
if (registration) actionsInFlight.delete(`${event.payload}:${registration.downloadId}`);
|
if (registration) actionsInFlight.delete(`${event.payload}:${registration.downloadId}`);
|
||||||
}),
|
}),
|
||||||
@@ -527,6 +627,7 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
void sendPropertiesRemoved(windowLabel, downloadId).catch(() => undefined);
|
void sendPropertiesRemoved(windowLabel, downloadId).catch(() => undefined);
|
||||||
windows.delete(windowLabel);
|
windows.delete(windowLabel);
|
||||||
snapshotRevisions.delete(windowLabel);
|
snapshotRevisions.delete(windowLabel);
|
||||||
|
clearWindowActionState(windowLabel);
|
||||||
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
|
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
|
||||||
} else if (next !== before) {
|
} else if (next !== before) {
|
||||||
snapshotCoalescer.schedule(windowLabel);
|
snapshotCoalescer.schedule(windowLabel);
|
||||||
@@ -547,7 +648,9 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
&& state.fontFamily === previous.fontFamily
|
&& state.fontFamily === previous.fontFamily
|
||||||
&& state.appFontSize === previous.appFontSize
|
&& state.appFontSize === previous.appFontSize
|
||||||
&& state.listRowDensity === previous.listRowDensity
|
&& state.listRowDensity === previous.listRowDensity
|
||||||
&& state.language === previous.language) {
|
&& state.language === previous.language
|
||||||
|
&& state.windowControlStyle === previous.windowControlStyle
|
||||||
|
&& state.sidebarPosition === previous.sidebarPosition) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const windowLabel of windows.keys()) {
|
for (const windowLabel of windows.keys()) {
|
||||||
@@ -561,6 +664,12 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
};
|
};
|
||||||
i18n.on('languageChanged', handleLanguageChanged);
|
i18n.on('languageChanged', handleLanguageChanged);
|
||||||
|
|
||||||
|
void getPlatformInfo().then(info => {
|
||||||
|
if (disposed) return;
|
||||||
|
platformOs = info.os;
|
||||||
|
for (const windowLabel of windows.keys()) snapshotCoalescer.schedule(windowLabel);
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
snapshotCoalescer.cancelAll();
|
snapshotCoalescer.cancelAll();
|
||||||
@@ -571,6 +680,9 @@ export const PropertiesWindowBridgeHost = () => {
|
|||||||
unlistenReady?.();
|
unlistenReady?.();
|
||||||
unlistenAction?.();
|
unlistenAction?.();
|
||||||
unlistenClosed?.();
|
unlistenClosed?.();
|
||||||
|
actionOperations.clear();
|
||||||
|
actionResults.clear();
|
||||||
|
actionChains.clear();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ const common = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
|
discardChanges: 'Discard changes',
|
||||||
|
keepEditing: 'Keep editing',
|
||||||
progress: 'Progress',
|
progress: 'Progress',
|
||||||
size: 'Size',
|
size: 'Size',
|
||||||
speed: 'Speed',
|
speed: 'Speed',
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ const fa = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
|
discardChanges: 'صرفنظر از تغییرات',
|
||||||
|
keepEditing: 'ادامه ویرایش',
|
||||||
progress: 'پیشرفت',
|
progress: 'پیشرفت',
|
||||||
size: 'اندازه',
|
size: 'اندازه',
|
||||||
speed: 'سرعت',
|
speed: 'سرعت',
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ const he = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
|
discardChanges: 'השלכת השינויים',
|
||||||
|
keepEditing: 'להמשיך לערוך',
|
||||||
progress: 'התקדמות',
|
progress: 'התקדמות',
|
||||||
size: 'גודל',
|
size: 'גודל',
|
||||||
speed: 'מהירות',
|
speed: 'מהירות',
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ const ru = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
|
discardChanges: 'Отменить изменения',
|
||||||
|
keepEditing: 'Продолжить редактирование',
|
||||||
progress: 'Прогресс',
|
progress: 'Прогресс',
|
||||||
size: 'Размер',
|
size: 'Размер',
|
||||||
speed: 'Скорость',
|
speed: 'Скорость',
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ const uk = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
|
discardChanges: 'Відкинути зміни',
|
||||||
|
keepEditing: 'Продовжити редагування',
|
||||||
progress: 'Прогрес',
|
progress: 'Прогрес',
|
||||||
size: 'Розмір',
|
size: 'Розмір',
|
||||||
speed: 'Швидкість',
|
speed: 'Швидкість',
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ const zhCN = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
|
discardChanges: '放弃更改',
|
||||||
|
keepEditing: '继续编辑',
|
||||||
progress: '进度',
|
progress: '进度',
|
||||||
size: '大小',
|
size: '大小',
|
||||||
speed: '速度',
|
speed: '速度',
|
||||||
|
|||||||
+34
-4
@@ -572,14 +572,44 @@ html[data-list-density="relaxed"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.properties-window-shell {
|
.properties-window-shell {
|
||||||
--properties-header-surface: hsl(var(--surface-raised) / 0.88);
|
--properties-header-surface: hsl(var(--surface-raised));
|
||||||
--properties-card-surface: hsl(var(--bg-input) / 0.42);
|
--properties-card-surface: hsl(var(--bg-input) / 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.properties-window-titlebar {
|
||||||
|
display: flex;
|
||||||
|
height: 52px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 18px;
|
||||||
|
direction: ltr;
|
||||||
|
border-bottom: 1px solid hsl(var(--border-color));
|
||||||
|
background: hsl(var(--statusbar-bg));
|
||||||
|
color: hsl(var(--text-primary));
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-window-titlebar span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-window-shell--controls-left .properties-window-titlebar {
|
||||||
|
padding-left: calc(var(--window-control-reveal-offset, 88px) + 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-window-shell--controls-right .properties-window-titlebar {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: calc(var(--window-control-reveal-offset, 88px) + 18px);
|
||||||
|
}
|
||||||
|
|
||||||
.properties-window-header {
|
.properties-window-header {
|
||||||
background:
|
background: var(--properties-header-surface);
|
||||||
radial-gradient(circle at 100% 0%, hsl(var(--accent-color) / 0.1), transparent 38%),
|
|
||||||
var(--properties-header-surface);
|
|
||||||
box-shadow: inset 0 -1px 0 hsl(0 0% 100% / 0.025);
|
box-shadow: inset 0 -1px 0 hsl(0 0% 100% / 0.025);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,17 @@ import {
|
|||||||
applySecretPatch,
|
applySecretPatch,
|
||||||
attachAsyncPropertiesListener,
|
attachAsyncPropertiesListener,
|
||||||
beginExclusivePropertiesAction,
|
beginExclusivePropertiesAction,
|
||||||
|
classifyPropertiesActionRequest,
|
||||||
createFrameCoalescer,
|
createFrameCoalescer,
|
||||||
enqueuePropertiesAction,
|
enqueuePropertiesAction,
|
||||||
formatPropertiesQueuePlacement,
|
formatPropertiesQueuePlacement,
|
||||||
getPropertiesLifecycleAction,
|
getPropertiesLifecycleAction,
|
||||||
isExpectedPropertiesDiagnosticUnavailable,
|
isExpectedPropertiesDiagnosticUnavailable,
|
||||||
propertiesDiagnosticPhase,
|
propertiesDiagnosticPhase,
|
||||||
|
propertiesActionRequestKey,
|
||||||
propertiesDiagnosticRequestState,
|
propertiesDiagnosticRequestState,
|
||||||
propertiesLifecycleReachedPostcondition,
|
|
||||||
propertiesTorrentPeerLimit,
|
propertiesTorrentPeerLimit,
|
||||||
|
resetPropertiesActionState,
|
||||||
sanitizePropertiesSnapshot,
|
sanitizePropertiesSnapshot,
|
||||||
shouldAcceptPropertiesActionRequest,
|
shouldAcceptPropertiesActionRequest,
|
||||||
} from './propertiesBridge';
|
} from './propertiesBridge';
|
||||||
@@ -65,6 +67,7 @@ describe('Properties window bridge', () => {
|
|||||||
listRowDensity: 'compact',
|
listRowDensity: 'compact',
|
||||||
locale: 'fa',
|
locale: 'fa',
|
||||||
});
|
});
|
||||||
|
expect(snapshot.windowChrome).toEqual({ controlStyle: 'macos', side: 'left' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds resolver error metadata without exposing the queue-internal mode', () => {
|
it('adds resolver error metadata without exposing the queue-internal mode', () => {
|
||||||
@@ -202,6 +205,27 @@ describe('Properties window bridge', () => {
|
|||||||
expect(snapshot.queueId).toBe('internal-queue-id');
|
expect(snapshot.queueId).toBe('internal-queue-id');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves resolved Properties window chrome in the sanitized snapshot', () => {
|
||||||
|
const snapshot = sanitizePropertiesSnapshot({
|
||||||
|
id: 'chrome-1',
|
||||||
|
fileName: 'example.bin',
|
||||||
|
url: 'https://example.test/file',
|
||||||
|
status: 'paused',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: '',
|
||||||
|
} as DownloadItem, {
|
||||||
|
theme: 'dark',
|
||||||
|
fontFamily: 'system',
|
||||||
|
appFontSize: 'standard',
|
||||||
|
listRowDensity: 'standard',
|
||||||
|
locale: 'en',
|
||||||
|
}, undefined, {
|
||||||
|
windowChrome: { controlStyle: 'windows', side: 'right' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot.windowChrome).toEqual({ controlStyle: 'windows', side: 'right' });
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps diagnostic refreshes quiet when cached data exists', () => {
|
it('keeps diagnostic refreshes quiet when cached data exists', () => {
|
||||||
expect(propertiesDiagnosticPhase(false, 'request-start')).toBe('initial');
|
expect(propertiesDiagnosticPhase(false, 'request-start')).toBe('initial');
|
||||||
expect(propertiesDiagnosticPhase(false, 'request-start', true)).toBe('refreshing');
|
expect(propertiesDiagnosticPhase(false, 'request-start', true)).toBe('refreshing');
|
||||||
@@ -264,15 +288,6 @@ describe('Properties window bridge', () => {
|
|||||||
expect(getPropertiesLifecycleAction('completed')).toBeNull();
|
expect(getPropertiesLifecycleAction('completed')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears a lost lifecycle action from an authoritative postcondition', () => {
|
|
||||||
expect(propertiesLifecycleReachedPostcondition('resume', 'downloading')).toBe(true);
|
|
||||||
expect(propertiesLifecycleReachedPostcondition('resume', 'seeding')).toBe(true);
|
|
||||||
expect(propertiesLifecycleReachedPostcondition('resume', 'paused')).toBe(false);
|
|
||||||
expect(propertiesLifecycleReachedPostcondition('pause', 'paused')).toBe(true);
|
|
||||||
expect(propertiesLifecycleReachedPostcondition('pause', 'completed')).toBe(true);
|
|
||||||
expect(propertiesLifecycleReachedPostcondition('pause', 'downloading')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps Torrent peer-cap telemetry distinct from generic connections', () => {
|
it('keeps Torrent peer-cap telemetry distinct from generic connections', () => {
|
||||||
expect(propertiesTorrentPeerLimit(undefined)).toBe(55);
|
expect(propertiesTorrentPeerLimit(undefined)).toBe(55);
|
||||||
expect(propertiesTorrentPeerLimit(120)).toBe(120);
|
expect(propertiesTorrentPeerLimit(120)).toBe(120);
|
||||||
@@ -337,6 +352,35 @@ describe('Properties window bridge', () => {
|
|||||||
})).toBe(false);
|
})).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replays completed duplicate requests after a lost result and deduplicates retries', () => {
|
||||||
|
const registration = {
|
||||||
|
downloadId: 'download-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
latestRequestId: 4,
|
||||||
|
};
|
||||||
|
const request = { downloadId: 'download-1', sessionId: 'session-1', requestId: 4 };
|
||||||
|
|
||||||
|
expect(classifyPropertiesActionRequest(registration, { ...request, requestId: 5 }, false, false)).toBe('accept');
|
||||||
|
expect(classifyPropertiesActionRequest(registration, request, true, false)).toBe('replay');
|
||||||
|
expect(classifyPropertiesActionRequest(registration, request, false, true)).toBe('pending');
|
||||||
|
expect(classifyPropertiesActionRequest(registration, { ...request, requestId: 3 }, false, false)).toBe('ignore');
|
||||||
|
expect(classifyPropertiesActionRequest(registration, { ...request, sessionId: 'session-old' }, false, false)).toBe('ignore');
|
||||||
|
|
||||||
|
const base = { windowLabel: 'properties-1', sessionId: 'session-1', requestId: 4 };
|
||||||
|
expect(propertiesActionRequestKey(base)).not.toBe(propertiesActionRequestKey({ ...base, requestId: 5 }));
|
||||||
|
expect(propertiesActionRequestKey(base)).not.toBe(propertiesActionRequestKey({ ...base, sessionId: 'session-2' }));
|
||||||
|
expect(propertiesActionRequestKey(base)).not.toBe(propertiesActionRequestKey({ ...base, windowLabel: 'properties-2' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets pending action state while advancing the bridge-generation request cursor', () => {
|
||||||
|
expect(resetPropertiesActionState(9)).toEqual({
|
||||||
|
requestId: 10,
|
||||||
|
pendingAction: null,
|
||||||
|
request: null,
|
||||||
|
});
|
||||||
|
expect(resetPropertiesActionState(Number.MAX_SAFE_INTEGER).requestId).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('serializes actions per window and continues after an earlier action fails', async () => {
|
it('serializes actions per window and continues after an earlier action fails', async () => {
|
||||||
const chains = new Map<string, Promise<void>>();
|
const chains = new Map<string, Promise<void>>();
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
|
|||||||
+47
-10
@@ -6,6 +6,7 @@ import type { DownloadStatus } from './bindings/DownloadStatus';
|
|||||||
import type { DownloadItem } from './store/useDownloadStore';
|
import type { DownloadItem } from './store/useDownloadStore';
|
||||||
import { canPauseDownload } from './utils/downloadActions';
|
import { canPauseDownload } from './utils/downloadActions';
|
||||||
import type { DocumentAppearance } from './utils/documentAppearance';
|
import type { DocumentAppearance } from './utils/documentAppearance';
|
||||||
|
import type { ResolvedWindowControlStyle } from './utils/windowControlStyle';
|
||||||
import { invokeCommand as invoke } from './ipc';
|
import { invokeCommand as invoke } from './ipc';
|
||||||
import { classifyDownloadError } from './utils/downloadErrors';
|
import { classifyDownloadError } from './utils/downloadErrors';
|
||||||
|
|
||||||
@@ -17,6 +18,16 @@ export const PROPERTIES_WINDOW_REMOVED = 'properties-window-removed' as const;
|
|||||||
export const PROPERTIES_WINDOW_CLOSED = 'properties-window-closed' as const;
|
export const PROPERTIES_WINDOW_CLOSED = 'properties-window-closed' as const;
|
||||||
export const DEFAULT_PROPERTIES_TORRENT_MAX_PEERS = 55;
|
export const DEFAULT_PROPERTIES_TORRENT_MAX_PEERS = 55;
|
||||||
|
|
||||||
|
export type PropertiesWindowChrome = {
|
||||||
|
controlStyle: ResolvedWindowControlStyle;
|
||||||
|
side: 'left' | 'right';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PROPERTIES_WINDOW_CHROME: PropertiesWindowChrome = {
|
||||||
|
controlStyle: 'macos',
|
||||||
|
side: 'left',
|
||||||
|
};
|
||||||
|
|
||||||
export const propertiesTorrentPeerLimit = (value: unknown): number =>
|
export const propertiesTorrentPeerLimit = (value: unknown): number =>
|
||||||
typeof value === 'number'
|
typeof value === 'number'
|
||||||
&& Number.isInteger(value)
|
&& Number.isInteger(value)
|
||||||
@@ -144,10 +155,12 @@ type SafePropertiesFields = Pick<DownloadItem, (typeof PROPERTIES_SNAPSHOT_KEYS)
|
|||||||
|
|
||||||
export type PropertiesSnapshotContext = {
|
export type PropertiesSnapshotContext = {
|
||||||
queueName?: string;
|
queueName?: string;
|
||||||
|
windowChrome?: PropertiesWindowChrome;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropertiesSnapshot = SafePropertiesFields & {
|
export type PropertiesSnapshot = SafePropertiesFields & {
|
||||||
appearance: DocumentAppearance;
|
appearance: DocumentAppearance;
|
||||||
|
windowChrome: PropertiesWindowChrome;
|
||||||
queueName?: string;
|
queueName?: string;
|
||||||
lastErrorKind?: DownloadErrorKind;
|
lastErrorKind?: DownloadErrorKind;
|
||||||
lastResolverFallback?: boolean;
|
lastResolverFallback?: boolean;
|
||||||
@@ -187,16 +200,6 @@ export type PropertiesAction =
|
|||||||
|
|
||||||
export type PropertiesLifecycleAction = 'pause' | 'resume' | 'start' | 'retry';
|
export type PropertiesLifecycleAction = 'pause' | 'resume' | 'start' | 'retry';
|
||||||
|
|
||||||
export const propertiesLifecycleReachedPostcondition = (
|
|
||||||
action: PropertiesLifecycleAction,
|
|
||||||
status: DownloadStatus,
|
|
||||||
): boolean => {
|
|
||||||
if (action === 'pause') {
|
|
||||||
return ['paused', 'completed', 'failed'].includes(status);
|
|
||||||
}
|
|
||||||
return ['queued', 'downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying'].includes(status);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPropertiesLifecycleAction = (
|
export const getPropertiesLifecycleAction = (
|
||||||
status: DownloadStatus,
|
status: DownloadStatus,
|
||||||
): PropertiesLifecycleAction | null => {
|
): PropertiesLifecycleAction | null => {
|
||||||
@@ -250,6 +253,15 @@ export type PropertiesActionResult = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const nextPropertiesRequestId = (requestId: number): number =>
|
||||||
|
requestId >= Number.MAX_SAFE_INTEGER ? 1 : requestId + 1;
|
||||||
|
|
||||||
|
export const resetPropertiesActionState = (requestId: number) => ({
|
||||||
|
requestId: nextPropertiesRequestId(requestId),
|
||||||
|
pendingAction: null as PropertiesAction | null,
|
||||||
|
request: null as PropertiesActionRequest | null,
|
||||||
|
});
|
||||||
|
|
||||||
export type PropertiesSnapshotEvent = {
|
export type PropertiesSnapshotEvent = {
|
||||||
windowLabel: string;
|
windowLabel: string;
|
||||||
downloadId: string;
|
downloadId: string;
|
||||||
@@ -265,6 +277,30 @@ export type PropertiesWindowRegistration = {
|
|||||||
latestRequestId: number;
|
latestRequestId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PropertiesActionRequestDisposition = 'accept' | 'replay' | 'pending' | 'ignore';
|
||||||
|
|
||||||
|
export const propertiesActionRequestKey = (
|
||||||
|
request: Pick<PropertiesActionRequest, 'windowLabel' | 'sessionId' | 'requestId'>,
|
||||||
|
): string => `${request.windowLabel}\u0000${request.sessionId}\u0000${request.requestId}`;
|
||||||
|
|
||||||
|
export const classifyPropertiesActionRequest = (
|
||||||
|
registration: PropertiesWindowRegistration | undefined,
|
||||||
|
request: Pick<PropertiesActionRequest, 'downloadId' | 'sessionId' | 'requestId'>,
|
||||||
|
hasCachedResult: boolean,
|
||||||
|
isInFlight: boolean,
|
||||||
|
): PropertiesActionRequestDisposition => {
|
||||||
|
if (registration === undefined
|
||||||
|
|| registration.downloadId !== request.downloadId
|
||||||
|
|| registration.sessionId !== request.sessionId
|
||||||
|
|| !Number.isSafeInteger(request.requestId)
|
||||||
|
|| request.requestId <= 0) {
|
||||||
|
return 'ignore';
|
||||||
|
}
|
||||||
|
if (hasCachedResult) return 'replay';
|
||||||
|
if (isInFlight) return 'pending';
|
||||||
|
return request.requestId > registration.latestRequestId ? 'accept' : 'ignore';
|
||||||
|
};
|
||||||
|
|
||||||
export const shouldAcceptPropertiesActionRequest = (
|
export const shouldAcceptPropertiesActionRequest = (
|
||||||
registration: PropertiesWindowRegistration | undefined,
|
registration: PropertiesWindowRegistration | undefined,
|
||||||
request: Pick<PropertiesActionRequest, 'downloadId' | 'sessionId' | 'requestId'>,
|
request: Pick<PropertiesActionRequest, 'downloadId' | 'sessionId' | 'requestId'>,
|
||||||
@@ -308,6 +344,7 @@ const copyWithoutSecrets = (
|
|||||||
return {
|
return {
|
||||||
...safeItem,
|
...safeItem,
|
||||||
appearance,
|
appearance,
|
||||||
|
windowChrome: context?.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME,
|
||||||
...(lastErrorKind ? { lastErrorKind } : {}),
|
...(lastErrorKind ? { lastErrorKind } : {}),
|
||||||
...(context?.queueName ? { queueName: context.queueName } : {}),
|
...(context?.queueName ? { queueName: context.queueName } : {}),
|
||||||
...(live?.progress ? {
|
...(live?.progress ? {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getPropertiesFooterActions } from './propertiesFooter';
|
||||||
|
|
||||||
|
describe('Properties footer state', () => {
|
||||||
|
it('shows discard, save, and close for ordinary dirty edits', () => {
|
||||||
|
expect(getPropertiesFooterActions({ isDirty: true, hasUnsavedNavigation: false }))
|
||||||
|
.toEqual(['discardChanges', 'save', 'close']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows discard, save, and keep editing for an unsaved tab or close prompt', () => {
|
||||||
|
expect(getPropertiesFooterActions({ isDirty: true, hasUnsavedNavigation: true }))
|
||||||
|
.toEqual(['discardChanges', 'save', 'keepEditing']);
|
||||||
|
expect(getPropertiesFooterActions({ isDirty: false, hasUnsavedNavigation: true }))
|
||||||
|
.toEqual(['discardChanges', 'save', 'keepEditing']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps close available when there are no edits', () => {
|
||||||
|
expect(getPropertiesFooterActions({ isDirty: false, hasUnsavedNavigation: false }))
|
||||||
|
.toEqual(['close']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export type PropertiesFooterAction = 'discardChanges' | 'save' | 'close' | 'keepEditing';
|
||||||
|
|
||||||
|
export type PropertiesFooterState = {
|
||||||
|
isDirty: boolean;
|
||||||
|
hasUnsavedNavigation: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPropertiesFooterActions = ({
|
||||||
|
isDirty,
|
||||||
|
hasUnsavedNavigation,
|
||||||
|
}: PropertiesFooterState): PropertiesFooterAction[] => {
|
||||||
|
if (hasUnsavedNavigation) return ['discardChanges', 'save', 'keepEditing'];
|
||||||
|
if (isDirty) return ['discardChanges', 'save', 'close'];
|
||||||
|
return ['close'];
|
||||||
|
};
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { getWindowControlRevealOffset, resolveWindowControlStyle } from './windowControlStyle';
|
import {
|
||||||
|
getWindowControlRevealOffset,
|
||||||
|
resolveWindowControlSide,
|
||||||
|
resolveWindowControlStyle,
|
||||||
|
} from './windowControlStyle';
|
||||||
|
|
||||||
describe('resolveWindowControlStyle', () => {
|
describe('resolveWindowControlStyle', () => {
|
||||||
it('uses the platform convention for automatic style', () => {
|
it('uses the platform convention for automatic style', () => {
|
||||||
@@ -32,4 +36,11 @@ describe('resolveWindowControlStyle', () => {
|
|||||||
expect(getWindowControlRevealOffset('gnome')).toBe(134);
|
expect(getWindowControlRevealOffset('gnome')).toBe(134);
|
||||||
expect(getWindowControlRevealOffset('minimal')).toBe(104);
|
expect(getWindowControlRevealOffset('minimal')).toBe(104);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves automatic control placement from the effective document direction', () => {
|
||||||
|
expect(resolveWindowControlSide('auto', 'ltr')).toBe('left');
|
||||||
|
expect(resolveWindowControlSide('auto', 'rtl')).toBe('right');
|
||||||
|
expect(resolveWindowControlSide('left', 'rtl')).toBe('left');
|
||||||
|
expect(resolveWindowControlSide('right', 'ltr')).toBe('right');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
|
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
|
||||||
|
|
||||||
export type ResolvedWindowControlStyle = Exclude<WindowControlStyle, 'auto'>;
|
export type ResolvedWindowControlStyle = Exclude<WindowControlStyle, 'auto'>;
|
||||||
|
export type WindowControlSide = 'left' | 'right';
|
||||||
|
export type SidebarPosition = 'auto' | WindowControlSide;
|
||||||
|
|
||||||
// The reveal button sits after the complete custom-control hit area. Keep this
|
// The reveal button sits after the complete custom-control hit area. Keep this
|
||||||
// derived from the resolved style so a sidebar toggle can never overlap a
|
// derived from the resolved style so a sidebar toggle can never overlap a
|
||||||
@@ -15,6 +17,14 @@ const WINDOW_CONTROL_REVEAL_OFFSETS: Record<ResolvedWindowControlStyle, number>
|
|||||||
export const getWindowControlRevealOffset = (style: ResolvedWindowControlStyle): number =>
|
export const getWindowControlRevealOffset = (style: ResolvedWindowControlStyle): number =>
|
||||||
WINDOW_CONTROL_REVEAL_OFFSETS[style];
|
WINDOW_CONTROL_REVEAL_OFFSETS[style];
|
||||||
|
|
||||||
|
export const resolveWindowControlSide = (
|
||||||
|
sidebarPosition: SidebarPosition,
|
||||||
|
direction: 'ltr' | 'rtl',
|
||||||
|
): WindowControlSide => sidebarPosition === 'right'
|
||||||
|
|| (sidebarPosition === 'auto' && direction === 'rtl')
|
||||||
|
? 'right'
|
||||||
|
: 'left';
|
||||||
|
|
||||||
export const resolveWindowControlStyle = (
|
export const resolveWindowControlStyle = (
|
||||||
style: WindowControlStyle,
|
style: WindowControlStyle,
|
||||||
os: string,
|
os: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user