From b36f55e5b59db898b453504d8e2aabbbf4a05069 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 5 Aug 2026 21:41:59 +0330 Subject: [PATCH] fix(properties): harden recovery and window chrome --- src-tauri/src/properties_window.rs | 26 +- src/components/PropertiesWindowApp.tsx | 235 ++++++++++++------ src/components/PropertiesWindowBridgeHost.tsx | 168 ++++++++++--- src/i18n/catalogs/en.ts | 2 + src/i18n/catalogs/fa.ts | 2 + src/i18n/catalogs/he.ts | 2 + src/i18n/catalogs/ru.ts | 2 + src/i18n/catalogs/uk.ts | 2 + src/i18n/catalogs/zh-CN.ts | 2 + src/index.css | 38 ++- src/propertiesBridge.test.ts | 64 ++++- src/propertiesBridge.ts | 57 ++++- src/utils/propertiesFooter.test.ts | 21 ++ src/utils/propertiesFooter.ts | 15 ++ src/utils/windowControlStyle.test.ts | 13 +- src/utils/windowControlStyle.ts | 10 + 16 files changed, 527 insertions(+), 132 deletions(-) create mode 100644 src/utils/propertiesFooter.test.ts create mode 100644 src/utils/propertiesFooter.ts diff --git a/src-tauri/src/properties_window.rs b/src-tauri/src/properties_window.rs index 58466bf..17926f7 100644 --- a/src-tauri/src/properties_window.rs +++ b/src-tauri/src/properties_window.rs @@ -331,14 +331,16 @@ pub fn open_download_properties_window( // If the native window disappeared without delivering Destroyed, discard // the old readiness bit before constructing a fresh hidden webview. 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) .inner_size(1000.0, 720.0) .min_inner_size(760.0, 560.0) .resizable(true) .always_on_top(false) - .visible(false) - .build(); + .visible(false); + #[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 { // Two rapid main-window requests can race between the native lookup // 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()) { return Err("Properties window close request is not registered".to_string()); } - if let Some(label) = registry.window_for_download(&id)? { - if let Some(window) = app.get_webview_window(&label) { + if let Some(window_label) = registry.window_for_download(&id)? { + if let Some(window) = app.get_webview_window(&window_label) { 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(()) } @@ -517,7 +525,11 @@ pub fn properties_window_registry_remove_for_download( } if let Some(label) = registry.remove_download(&id)? { 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(()) diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 6427ad3..6a8903f 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -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 { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager'; @@ -15,15 +15,18 @@ import { PROPERTIES_WINDOW_REMOVED, PROPERTIES_WINDOW_SNAPSHOT, attachAsyncPropertiesListener, + DEFAULT_PROPERTIES_WINDOW_CHROME, formatPropertiesQueuePlacement, getPropertiesLifecycleAction, - propertiesLifecycleReachedPostcondition, propertiesTorrentPeerLimit, propertiesDiagnosticRequestState, sendPropertiesActionRequest, sendPropertiesReady, isExpectedPropertiesDiagnosticUnavailable, + nextPropertiesRequestId, propertiesDiagnosticPhase, + propertiesActionRequestKey, + resetPropertiesActionState, type PropertiesAction, type PropertiesActionRequest, type PropertiesActionResult, @@ -35,6 +38,9 @@ import { import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress'; import { changeAppLocale } from '../i18n'; import { synchronizeDocumentAppearance } from '../utils/documentAppearance'; +import { getWindowControlRevealOffset } from '../utils/windowControlStyle'; +import { getPropertiesFooterActions } from '../utils/propertiesFooter'; +import { WindowControls } from './WindowControls'; import { TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, @@ -47,9 +53,6 @@ type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | ' type SecretName = 'username' | 'password' | 'cookies' | 'headers'; type SecretDraft = { value: string; touched: boolean; clear: boolean }; 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) => ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status); @@ -149,8 +152,9 @@ export const PropertiesWindowApp = () => { const closeAfterSaveRef = useRef(false); const switchAfterSaveRef = useRef(null); const requestIdRef = useRef(0); + const pendingActionRequestRef = useRef(null); const pendingActionRef = useRef(null); - const pendingLifecycleIntentRef = useRef>(null); + const pendingActionSendKeyRef = useRef(null); const latestSnapshotRevisionRef = useRef(0); const latestBridgeGenerationRef = useRef(null); const appearanceCleanupRef = useRef<(() => void) | null>(null); @@ -167,6 +171,9 @@ export const PropertiesWindowApp = () => { const diagnosticAttemptsRef = useRef(new Set()); const diagnosticLifecycleKeyRef = useRef(''); const diagnosticLifecycleEpochRef = useRef(0); + const allowWindowCloseRef = useRef(false); + const isDirtyRef = useRef(false); + const windowChromeRef = useRef(DEFAULT_PROPERTIES_WINDOW_CHROME); snapshotRef.current = snapshot; activeTabRef.current = activeTab; downloadIdRef.current = downloadId; @@ -180,6 +187,23 @@ export const PropertiesWindowApp = () => { ? ['overview', 'files', 'trackers', 'peers', 'options'] : ['overview', 'transfer', 'advanced'], [isTorrent]); 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(() => { draftTabRef.current = draftTab; @@ -322,6 +346,7 @@ export const PropertiesWindowApp = () => { if (cancelled) return; setDownloadId(id); const snapshotListener = await listen(PROPERTIES_WINDOW_SNAPSHOT, async event => { + if (cancelled) return; if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id || event.payload.sessionId !== sessionId) return; @@ -334,7 +359,8 @@ export const PropertiesWindowApp = () => { diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); 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 // the store transition event while the child Properties window // 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 // request, and also recovers when the native request failed while // its failure event was lost. - requestIdRef.current = nextPropertiesRequestId(requestIdRef.current); - pendingActionRef.current = null; - pendingLifecycleIntentRef.current = null; + const resetActionState = resetPropertiesActionState(requestIdRef.current); + requestIdRef.current = resetActionState.requestId; + pendingActionRequestRef.current = resetActionState.request; + pendingActionRef.current = resetActionState.pendingAction; setPendingAction(null); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; - if (lostApply) { + if (lostDraftAction) { // A completed property action is represented by the fresh // snapshot, not by the stale draft that produced the request. draftTabRef.current = null; @@ -368,23 +395,16 @@ export const PropertiesWindowApp = () => { diagnosticAttemptsRef.current.clear(); } 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 = synchronizeDocumentAppearance( window, event.payload.snapshot.appearance, ); + windowChromeRef.current = event.payload.snapshot.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME; 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); void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined); if (!hasRevealedWindowRef.current && !revealInFlightRef.current) { @@ -407,31 +427,40 @@ export const PropertiesWindowApp = () => { } unlistenSnapshot = snapshotListener; const resultListener = await listen(PROPERTIES_WINDOW_ACTION_RESULT, event => { + if (cancelled) return; if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id || event.payload.sessionId !== sessionId) return; if (event.payload.requestId !== requestIdRef.current) return; + if (pendingActionRef.current === null) return; const completedAction = pendingActionRef.current; pendingActionRef.current = null; - pendingLifecycleIntentRef.current = null; + pendingActionRequestRef.current = null; setPendingAction(null); - if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed'); - else { + if (!event.payload.ok) { + 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 shouldClose = closeAfterSaveRef.current; switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setErrorMessage(''); setNotice(completedAction === 'apply-properties' ? t($ => $.properties.saved) : ''); - draftTabRef.current = null; - setDraftTab(null); - if (nextTab) { - setActiveTab(nextTab); - setPendingTab(null); - } - if (shouldClose) { + if (commitsDraft) { + draftTabRef.current = null; + setDraftTab(null); 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; 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 (readyRetryTimer !== undefined) { window.clearInterval(readyRetryTimer); @@ -454,6 +484,18 @@ export const PropertiesWindowApp = () => { diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); 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)); } }); @@ -491,10 +533,13 @@ export const PropertiesWindowApp = () => { unlistenSnapshot?.(); unlistenResult?.(); unlistenRemoved?.(); + pendingActionRequestRef.current = null; + pendingActionSendKeyRef.current = null; + allowWindowCloseRef.current = false; appearanceCleanupRef.current?.(); appearanceCleanupRef.current = null; }; - }, [currentWindow, hydrateDraft, sessionId, t, windowLabel]); + }, [closeCurrentWindow, currentWindow, hydrateDraft, sessionId, t, windowLabel]); useEffect(() => { if (!snapshot || draftTab !== null) return; @@ -533,10 +578,14 @@ export const PropertiesWindowApp = () => { }, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]); useEffect(() => { - if (!isDirty) return; let disposed = false; let unlisten: UnlistenFn | undefined; attachAsyncPropertiesListener(currentWindow.onCloseRequested(event => { + if (allowWindowCloseRef.current) { + allowWindowCloseRef.current = false; + return; + } + if (!isDirtyRef.current) return; event.preventDefault(); setClosePrompt(true); }), () => disposed, value => { unlisten = value; }); @@ -544,7 +593,27 @@ export const PropertiesWindowApp = () => { disposed = true; 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 ( action: PropertiesAction, @@ -554,30 +623,26 @@ export const PropertiesWindowApp = () => { const requestId = nextPropertiesRequestId(requestIdRef.current); requestIdRef.current = requestId; pendingActionRef.current = action; - if (action === 'pause-resume') { - pendingLifecycleIntentRef.current = getPropertiesLifecycleAction(snapshot?.status ?? 'completed'); - } else { - pendingLifecycleIntentRef.current = null; - } setPendingAction(action); - try { - await sendPropertiesActionRequest({ - windowLabel, - downloadId, - sessionId, - requestId, - action, - payload, - }); - } catch (error) { - setPendingAction(null); - pendingActionRef.current = null; - pendingLifecycleIntentRef.current = null; - closeAfterSaveRef.current = false; - switchAfterSaveRef.current = null; - setErrorMessage(errorText(error)); - } - }, [downloadId, sessionId, snapshot?.status, windowLabel]); + const request: PropertiesActionRequest = { + windowLabel, + downloadId, + sessionId, + requestId, + action, + payload, + }; + pendingActionRequestRef.current = request; + await sendPendingAction(true); + }, [downloadId, sendPendingAction, sessionId, windowLabel]); + + useEffect(() => { + if (pendingAction === null) return; + const retryTimer = window.setInterval(() => { + void sendPendingAction(false); + }, 2500); + return () => window.clearInterval(retryTimer); + }, [pendingAction, sendPendingAction]); const updateSecretDraft = (name: SecretName, value: string) => { setSecretDrafts(current => ({ @@ -692,6 +757,7 @@ export const PropertiesWindowApp = () => { }; const discardDraft = () => { + if (pendingActionRef.current !== null) return; const shouldClose = closePrompt; if (snapshot) hydrateDraft(snapshot); draftTabRef.current = null; @@ -699,16 +765,15 @@ export const PropertiesWindowApp = () => { if (pendingTab) setActiveTab(pendingTab); setPendingTab(null); setClosePrompt(false); - if (shouldClose) void currentWindow.close().catch(error => setErrorMessage(errorText(error))); + if (shouldClose) void closeCurrentWindow(true); }; const closeWindow = async () => { - if (!downloadId) return; - try { - await invoke('close_download_properties_window', { id: downloadId }); - } catch (error) { - setErrorMessage(errorText(error)); + if (isDirtyRef.current) { + setClosePrompt(true); + return; } + await closeCurrentWindow(); }; const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => { @@ -743,16 +808,34 @@ export const PropertiesWindowApp = () => { } }; - if (!downloadId) { - return
{errorMessage || t($ => $.app.loading)}
; - } - if (!snapshot) { - return
{errorMessage || t($ => $.app.loading)}
; + const windowChrome = snapshot?.windowChrome ?? windowChromeRef.current; + const windowControlRevealOffset = getWindowControlRevealOffset(windowChrome.controlStyle); + if (!downloadId || !snapshot) { + return ( +
+ +
+ {t($ => $.downloadTable.properties)} - Firelink +
+
+ {errorMessage || t($ => $.app.loading)} +
+
+ ); } const progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0)); const lifecycleAction = getPropertiesLifecycleAction(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 total = snapshot.size || (snapshot.totalBytes === undefined ? t($ => $.addDownloads.unknownSize) @@ -790,7 +873,15 @@ export const PropertiesWindowApp = () => { }; return ( -
+
+ +
+ {snapshot.fileName} - {t($ => $.downloadTable.properties)} - Firelink +
@@ -1006,7 +1097,7 @@ export const PropertiesWindowApp = () => { {(isDirty || errorMessage || notice || pendingTab || closePrompt) &&
- {pendingTab || closePrompt ?
{t($ => $.scheduler.unsavedChanges)}
:
{errorMessage || notice}
{isDirty && <>}
} + {isPromptFooter ?
{t($ => $.scheduler.unsavedChanges)}
:
{errorMessage || notice}
{footerActions.includes('discardChanges') && <>}
}
}
); diff --git a/src/components/PropertiesWindowBridgeHost.tsx b/src/components/PropertiesWindowBridgeHost.tsx index 8592cfb..3d43bdd 100644 --- a/src/components/PropertiesWindowBridgeHost.tsx +++ b/src/components/PropertiesWindowBridgeHost.tsx @@ -22,21 +22,25 @@ import { applySecretPatch, attachAsyncPropertiesListener, beginExclusivePropertiesAction, + classifyPropertiesActionRequest, createFrameCoalescer, enqueuePropertiesAction, getPropertiesLifecycleAction, + propertiesActionRequestKey, sanitizePropertiesSnapshot, sendPropertiesActionResult, sendPropertiesRemoved, sendPropertiesSnapshot, - shouldAcceptPropertiesActionRequest, type PropertiesActionRequest, + type PropertiesActionResult, type PropertiesPatch, type PropertiesWindowRegistration, type PropertiesWindowReady, } from '../propertiesBridge'; 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); let lastPropertiesBridgeGeneration = 0; @@ -175,6 +179,9 @@ export const PropertiesWindowBridgeHost = () => { const snapshotRevisions = new Map(); const actionsInFlight = new Set(); const actionChains = new Map>(); + const actionOperations = new Map>(); + const actionResults = new Map(); + let platformOs = 'unknown'; const bridgeGeneration = Math.max(Date.now(), lastPropertiesBridgeGeneration + 1); lastPropertiesBridgeGeneration = bridgeGeneration; let disposed = false; @@ -190,6 +197,43 @@ export const PropertiesWindowBridgeHost = () => { 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 registration = windows.get(windowLabel); if (!registration || registration.downloadId !== downloadId || disposed) return false; @@ -200,6 +244,17 @@ export const PropertiesWindowBridgeHost = () => { ?? store.queues.find(candidate => candidate.isMain); const settings = useSettingsStore.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; snapshotRevisions.set(windowLabel, revision); await sendPropertiesSnapshot(windowLabel, { @@ -219,6 +274,7 @@ export const PropertiesWindowBridgeHost = () => { moveProgress: progress.moveProgressMap[downloadId], }, { queueName: queue?.name, + windowChrome, }), }); return true; @@ -231,6 +287,7 @@ export const PropertiesWindowBridgeHost = () => { ) => { const previous = windows.get(windowLabel); const sessionChanged = previous?.downloadId !== downloadId || previous.sessionId !== sessionId; + if (sessionChanged) clearWindowActionState(windowLabel); windows.set(windowLabel, { downloadId, sessionId, @@ -279,7 +336,6 @@ export const PropertiesWindowBridgeHost = () => { }; const processAction = async (request: PropertiesActionRequest) => { - if (disposed) return; let ok = false; let error: string | undefined; const actionKey = `${request.windowLabel}:${request.downloadId}`; @@ -440,32 +496,44 @@ export const PropertiesWindowBridgeHost = () => { } finally { releaseAction?.(); } - if (disposed) return; - if (ok) { - try { - await sendFor(request.windowLabel, request.downloadId); - } catch { - // Snapshot delivery is best effort across a close/reopen race. - } - } + if (ok) void sendFor(request.windowLabel, request.downloadId).catch(() => undefined); + const result: PropertiesActionResult = { + windowLabel: request.windowLabel, + downloadId: request.downloadId, + sessionId: request.sessionId, + requestId: request.requestId, + ok, + ...(error ? { error } : {}), + }; + cacheActionResult(propertiesActionRequestKey(request), result); try { - await sendPropertiesActionResult(request.windowLabel, { - windowLabel: request.windowLabel, - downloadId: request.downloadId, - sessionId: request.sessionId, - requestId: request.requestId, - ok, - ...(error ? { error } : {}), - }); + await sendPropertiesActionResult(request.windowLabel, result); } catch { - // The window may have closed between the request and its result. - return; + // The result remains cached so a same-request retry can replay it. + } + }; + + 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) => { if (disposed) return; - const actionKey = `${request.windowLabel}:${request.downloadId}`; try { // The native command validates the caller, download binding, and // 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); if (disposed) return; synchronizeRegistration(request.windowLabel, request.downloadId, request.sessionId); - } catch { + } catch (error) { // 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; } const registration = windows.get(request.windowLabel); - if (!registration) return; - if (!shouldAcceptPropertiesActionRequest(registration, request)) return; + if (!registration) { + 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; + const actionKey = `${request.windowLabel}:${request.downloadId}`; // Preserve user order for accepted requests. This keeps a pause from an // earlier request from running after a newer resume, while still // 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( @@ -510,6 +609,7 @@ export const PropertiesWindowBridgeHost = () => { const registration = windows.get(event.payload); windows.delete(event.payload); snapshotRevisions.delete(event.payload); + clearWindowActionState(event.payload); snapshotCoalescer.cancel(event.payload); if (registration) actionsInFlight.delete(`${event.payload}:${registration.downloadId}`); }), @@ -527,6 +627,7 @@ export const PropertiesWindowBridgeHost = () => { void sendPropertiesRemoved(windowLabel, downloadId).catch(() => undefined); windows.delete(windowLabel); snapshotRevisions.delete(windowLabel); + clearWindowActionState(windowLabel); void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined); } else if (next !== before) { snapshotCoalescer.schedule(windowLabel); @@ -547,7 +648,9 @@ export const PropertiesWindowBridgeHost = () => { && state.fontFamily === previous.fontFamily && state.appFontSize === previous.appFontSize && state.listRowDensity === previous.listRowDensity - && state.language === previous.language) { + && state.language === previous.language + && state.windowControlStyle === previous.windowControlStyle + && state.sidebarPosition === previous.sidebarPosition) { return; } for (const windowLabel of windows.keys()) { @@ -561,6 +664,12 @@ export const PropertiesWindowBridgeHost = () => { }; 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 () => { disposed = true; snapshotCoalescer.cancelAll(); @@ -571,6 +680,9 @@ export const PropertiesWindowBridgeHost = () => { unlistenReady?.(); unlistenAction?.(); unlistenClosed?.(); + actionOperations.clear(); + actionResults.clear(); + actionChains.clear(); }; }, []); diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 6925df1..98ae49c 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -220,6 +220,8 @@ const common = { }, }, properties: { + discardChanges: 'Discard changes', + keepEditing: 'Keep editing', progress: 'Progress', size: 'Size', speed: 'Speed', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 2b1634b..3cd3920 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -220,6 +220,8 @@ const fa = { }, }, properties: { + discardChanges: 'صرف‌نظر از تغییرات', + keepEditing: 'ادامه ویرایش', progress: 'پیشرفت', size: 'اندازه', speed: 'سرعت', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index d914ff2..0fcc6b8 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -220,6 +220,8 @@ const he = { }, }, properties: { + discardChanges: 'השלכת השינויים', + keepEditing: 'להמשיך לערוך', progress: 'התקדמות', size: 'גודל', speed: 'מהירות', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 23d7d5e..d9c17b4 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -220,6 +220,8 @@ const ru = { }, }, properties: { + discardChanges: 'Отменить изменения', + keepEditing: 'Продолжить редактирование', progress: 'Прогресс', size: 'Размер', speed: 'Скорость', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 1f77f5b..c0137cf 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -220,6 +220,8 @@ const uk = { }, }, properties: { + discardChanges: 'Відкинути зміни', + keepEditing: 'Продовжити редагування', progress: 'Прогрес', size: 'Розмір', speed: 'Швидкість', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index cb95cfe..926afec 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -220,6 +220,8 @@ const zhCN = { }, }, properties: { + discardChanges: '放弃更改', + keepEditing: '继续编辑', progress: '进度', size: '大小', speed: '速度', diff --git a/src/index.css b/src/index.css index 6205a08..6775596 100644 --- a/src/index.css +++ b/src/index.css @@ -572,14 +572,44 @@ html[data-list-density="relaxed"] { } .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-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 { - background: - radial-gradient(circle at 100% 0%, hsl(var(--accent-color) / 0.1), transparent 38%), - var(--properties-header-surface); + background: var(--properties-header-surface); box-shadow: inset 0 -1px 0 hsl(0 0% 100% / 0.025); } diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index c8abada..986ffd9 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -14,15 +14,17 @@ import { applySecretPatch, attachAsyncPropertiesListener, beginExclusivePropertiesAction, + classifyPropertiesActionRequest, createFrameCoalescer, enqueuePropertiesAction, formatPropertiesQueuePlacement, getPropertiesLifecycleAction, isExpectedPropertiesDiagnosticUnavailable, propertiesDiagnosticPhase, + propertiesActionRequestKey, propertiesDiagnosticRequestState, - propertiesLifecycleReachedPostcondition, propertiesTorrentPeerLimit, + resetPropertiesActionState, sanitizePropertiesSnapshot, shouldAcceptPropertiesActionRequest, } from './propertiesBridge'; @@ -65,6 +67,7 @@ describe('Properties window bridge', () => { listRowDensity: 'compact', locale: 'fa', }); + expect(snapshot.windowChrome).toEqual({ controlStyle: 'macos', side: 'left' }); }); 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'); }); + 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', () => { expect(propertiesDiagnosticPhase(false, 'request-start')).toBe('initial'); expect(propertiesDiagnosticPhase(false, 'request-start', true)).toBe('refreshing'); @@ -264,15 +288,6 @@ describe('Properties window bridge', () => { 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', () => { expect(propertiesTorrentPeerLimit(undefined)).toBe(55); expect(propertiesTorrentPeerLimit(120)).toBe(120); @@ -337,6 +352,35 @@ describe('Properties window bridge', () => { })).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 () => { const chains = new Map>(); const events: string[] = []; diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index 9e768c5..a5e6c63 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -6,6 +6,7 @@ import type { DownloadStatus } from './bindings/DownloadStatus'; import type { DownloadItem } from './store/useDownloadStore'; import { canPauseDownload } from './utils/downloadActions'; import type { DocumentAppearance } from './utils/documentAppearance'; +import type { ResolvedWindowControlStyle } from './utils/windowControlStyle'; import { invokeCommand as invoke } from './ipc'; 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 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 => typeof value === 'number' && Number.isInteger(value) @@ -144,10 +155,12 @@ type SafePropertiesFields = Pick { - if (action === 'pause') { - return ['paused', 'completed', 'failed'].includes(status); - } - return ['queued', 'downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying'].includes(status); -}; - export const getPropertiesLifecycleAction = ( status: DownloadStatus, ): PropertiesLifecycleAction | null => { @@ -250,6 +253,15 @@ export type PropertiesActionResult = { 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 = { windowLabel: string; downloadId: string; @@ -265,6 +277,30 @@ export type PropertiesWindowRegistration = { latestRequestId: number; }; +export type PropertiesActionRequestDisposition = 'accept' | 'replay' | 'pending' | 'ignore'; + +export const propertiesActionRequestKey = ( + request: Pick, +): string => `${request.windowLabel}\u0000${request.sessionId}\u0000${request.requestId}`; + +export const classifyPropertiesActionRequest = ( + registration: PropertiesWindowRegistration | undefined, + request: Pick, + 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 = ( registration: PropertiesWindowRegistration | undefined, request: Pick, @@ -308,6 +344,7 @@ const copyWithoutSecrets = ( return { ...safeItem, appearance, + windowChrome: context?.windowChrome ?? DEFAULT_PROPERTIES_WINDOW_CHROME, ...(lastErrorKind ? { lastErrorKind } : {}), ...(context?.queueName ? { queueName: context.queueName } : {}), ...(live?.progress ? { diff --git a/src/utils/propertiesFooter.test.ts b/src/utils/propertiesFooter.test.ts new file mode 100644 index 0000000..ae3e71e --- /dev/null +++ b/src/utils/propertiesFooter.test.ts @@ -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']); + }); +}); diff --git a/src/utils/propertiesFooter.ts b/src/utils/propertiesFooter.ts new file mode 100644 index 0000000..92fb0be --- /dev/null +++ b/src/utils/propertiesFooter.ts @@ -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']; +}; diff --git a/src/utils/windowControlStyle.test.ts b/src/utils/windowControlStyle.test.ts index 657d7d1..3383241 100644 --- a/src/utils/windowControlStyle.test.ts +++ b/src/utils/windowControlStyle.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { getWindowControlRevealOffset, resolveWindowControlStyle } from './windowControlStyle'; +import { + getWindowControlRevealOffset, + resolveWindowControlSide, + resolveWindowControlStyle, +} from './windowControlStyle'; describe('resolveWindowControlStyle', () => { it('uses the platform convention for automatic style', () => { @@ -32,4 +36,11 @@ describe('resolveWindowControlStyle', () => { expect(getWindowControlRevealOffset('gnome')).toBe(134); 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'); + }); }); diff --git a/src/utils/windowControlStyle.ts b/src/utils/windowControlStyle.ts index 83638bb..add669e 100644 --- a/src/utils/windowControlStyle.ts +++ b/src/utils/windowControlStyle.ts @@ -1,6 +1,8 @@ import type { WindowControlStyle } from '../bindings/WindowControlStyle'; export type ResolvedWindowControlStyle = Exclude; +export type WindowControlSide = 'left' | 'right'; +export type SidebarPosition = 'auto' | WindowControlSide; // 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 @@ -15,6 +17,14 @@ const WINDOW_CONTROL_REVEAL_OFFSETS: Record export const getWindowControlRevealOffset = (style: ResolvedWindowControlStyle): number => 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 = ( style: WindowControlStyle, os: string,