feat(properties): harden standalone properties lifecycle

- synchronize child appearance and hidden-window readiness
- serialize native Torrent mutations transactionally without double-encoded rows
- preserve native lifecycle markers and fence stale or duplicate actions
- remove the obsolete modal surface and ignore implementation_plan.md
This commit is contained in:
NimBold
2026-08-04 12:02:30 +03:30
parent c342bcd347
commit 2ab292dd5d
20 changed files with 1230 additions and 2765 deletions
+8 -44
View File
@@ -35,6 +35,7 @@ import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale } from './i18n';
import { useTranslation } from 'react-i18next';
import { formatDownloadBytes } from './utils/downloadProgress';
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
const loadSettingsView = () => import('./components/SettingsView');
const loadSchedulerView = () => import('./components/SchedulerView');
@@ -669,17 +670,13 @@ function App() {
});
}, [addToast, coreReady, showKeychainModal]);
useEffect(() => {
window.document.documentElement.setAttribute('data-font-family', fontFamily);
}, [fontFamily]);
useEffect(() => {
window.document.documentElement.setAttribute('data-font-size', appFontSize);
}, [appFontSize]);
useEffect(() => {
window.document.documentElement.setAttribute('data-list-density', listRowDensity);
}, [listRowDensity]);
useEffect(() => synchronizeDocumentAppearance(window, {
theme,
fontFamily,
appFontSize,
listRowDensity,
locale: resolveAppLocale(i18n.language),
}), [appFontSize, fontFamily, i18n.language, listRowDensity, theme]);
useEffect(() => {
const checkForUpdate = () => {
@@ -1025,39 +1022,6 @@ function App() {
};
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
useEffect(() => {
const root = window.document.documentElement;
const applyTheme = () => {
// Remove all theme classes first
root.classList.remove('theme-dark', 'theme-light', 'theme-dracula', 'theme-nord', 'dark');
if (theme === 'system') {
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.classList.add(systemDark ? 'theme-dark' : 'theme-light');
root.dataset.resolvedTheme = systemDark ? 'dark' : 'light';
root.style.colorScheme = systemDark ? 'dark' : 'light';
if (systemDark) root.classList.add('dark');
} else {
root.classList.add(`theme-${theme}`);
if (['dark', 'dracula', 'nord'].includes(theme)) {
root.classList.add('dark');
}
root.dataset.resolvedTheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light';
root.style.colorScheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light';
}
};
applyTheme();
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const listener = () => applyTheme();
mediaQuery.addEventListener('change', listener);
return () => mediaQuery.removeEventListener('change', listener);
}
}, [theme]);
return (
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
isSidebarOnRight ? 'app-shell--sidebar-right' : 'app-shell--sidebar-left'
File diff suppressed because it is too large Load Diff
+81 -13
View File
@@ -14,6 +14,7 @@ import {
PROPERTIES_WINDOW_ACTION_RESULT,
PROPERTIES_WINDOW_REMOVED,
PROPERTIES_WINDOW_SNAPSHOT,
getPropertiesLifecycleAction,
sendPropertiesActionRequest,
sendPropertiesReady,
type PropertiesAction,
@@ -24,6 +25,8 @@ import {
type PropertiesSnapshotEvent,
} from '../propertiesBridge';
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
import { changeAppLocale } from '../i18n';
import { synchronizeDocumentAppearance } from '../utils/documentAppearance';
type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced';
@@ -73,7 +76,11 @@ export const PropertiesWindowApp = () => {
const closeAfterSaveRef = useRef(false);
const switchAfterSaveRef = useRef<PropertiesTab | null>(null);
const requestIdRef = useRef(0);
const pendingActionRef = useRef<PropertiesAction | null>(null);
const latestSnapshotRevisionRef = useRef(0);
const appearanceCleanupRef = useRef<(() => void) | null>(null);
const hasRevealedWindowRef = useRef(false);
const revealInFlightRef = useRef(false);
const diagnosticsInFlightRef = useRef(new Set<string>());
const snapshotRef = useRef(snapshot);
const activeTabRef = useRef(activeTab);
@@ -140,6 +147,7 @@ export const PropertiesWindowApp = () => {
useEffect(() => {
let cancelled = false;
let readyRetryTimer: number | undefined;
let unlistenSnapshot: UnlistenFn | undefined;
let unlistenResult: UnlistenFn | undefined;
let unlistenRemoved: UnlistenFn | undefined;
@@ -148,18 +156,40 @@ export const PropertiesWindowApp = () => {
const id = await invoke('get_properties_window_download_id');
if (cancelled) return;
setDownloadId(id);
unlistenSnapshot = await listen<PropertiesSnapshotEvent>(PROPERTIES_WINDOW_SNAPSHOT, event => {
unlistenSnapshot = await listen<PropertiesSnapshotEvent>(PROPERTIES_WINDOW_SNAPSHOT, async event => {
if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id) return;
if (event.payload.revision <= latestSnapshotRevisionRef.current) return;
latestSnapshotRevisionRef.current = event.payload.revision;
await changeAppLocale(event.payload.snapshot.appearance.locale);
if (event.payload.revision !== latestSnapshotRevisionRef.current) return;
appearanceCleanupRef.current?.();
appearanceCleanupRef.current = synchronizeDocumentAppearance(
window,
event.payload.snapshot.appearance,
);
setSnapshot(event.payload.snapshot);
if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot);
void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined);
if (!hasRevealedWindowRef.current && !revealInFlightRef.current) {
revealInFlightRef.current = true;
try {
await invoke('properties_window_reveal');
hasRevealedWindowRef.current = true;
if (readyRetryTimer !== undefined) {
window.clearInterval(readyRetryTimer);
readyRetryTimer = undefined;
}
} finally {
revealInFlightRef.current = false;
}
}
});
unlistenResult = await listen<PropertiesActionResult>(PROPERTIES_WINDOW_ACTION_RESULT, event => {
if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id) return;
if (event.payload.requestId !== requestIdRef.current) return;
setIsSaving(false);
const completedAction = pendingActionRef.current;
pendingActionRef.current = null;
if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed');
else {
const nextTab = switchAfterSaveRef.current;
@@ -167,7 +197,7 @@ export const PropertiesWindowApp = () => {
switchAfterSaveRef.current = null;
closeAfterSaveRef.current = false;
setErrorMessage('');
setNotice(t($ => $.properties.saved));
setNotice(completedAction === 'apply-properties' ? t($ => $.properties.saved) : '');
draftTabRef.current = null;
setDraftTab(null);
if (nextTab) {
@@ -182,11 +212,24 @@ export const PropertiesWindowApp = () => {
});
unlistenRemoved = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => {
if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) {
if (readyRetryTimer !== undefined) {
window.clearInterval(readyRetryTimer);
readyRetryTimer = undefined;
}
setSnapshot(null);
setNotice(t($ => $.downloadTable.noDownloads));
}
});
await sendPropertiesReady();
if (cancelled) return;
// Tauri event listeners are registered asynchronously. If the main
// bridge was still installing its listener, the first ready event can
// legitimately be missed; retry until the first snapshot confirms
// the handshake rather than leaving a permanently hidden window.
readyRetryTimer = window.setInterval(() => {
if (cancelled || hasRevealedWindowRef.current) return;
void sendPropertiesReady().catch(() => undefined);
}, 500);
} catch (error) {
if (!cancelled) setErrorMessage(errorText(error));
}
@@ -194,9 +237,12 @@ export const PropertiesWindowApp = () => {
void start();
return () => {
cancelled = true;
if (readyRetryTimer !== undefined) window.clearInterval(readyRetryTimer);
unlistenSnapshot?.();
unlistenResult?.();
unlistenRemoved?.();
appearanceCleanupRef.current?.();
appearanceCleanupRef.current = null;
};
}, [currentWindow, hydrateDraft, t, windowLabel]);
@@ -228,8 +274,10 @@ export const PropertiesWindowApp = () => {
payload?: PropertiesActionRequest['payload'],
) => {
if (!downloadId) return;
if (pendingActionRef.current !== null) return;
const requestId = ++requestIdRef.current;
setIsSaving(action === 'apply-properties');
pendingActionRef.current = action;
setIsSaving(true);
try {
await sendPropertiesActionRequest({
windowLabel,
@@ -240,6 +288,7 @@ export const PropertiesWindowApp = () => {
});
} catch (error) {
setIsSaving(false);
pendingActionRef.current = null;
closeAfterSaveRef.current = false;
switchAfterSaveRef.current = null;
setErrorMessage(errorText(error));
@@ -328,8 +377,7 @@ export const PropertiesWindowApp = () => {
setNotice(t($ => $.properties.torrentMoveCompleted));
}
} else {
await invoke('verify_torrent_data', { id: downloadId });
setNotice(t($ => $.properties.torrentVerifyIntegrity));
await requestAction('verify-torrent');
}
} catch (error) {
setErrorMessage(errorText(error));
@@ -344,6 +392,7 @@ export const PropertiesWindowApp = () => {
}
const progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0));
const lifecycleAction = getPropertiesLifecycleAction(snapshot.status);
const total = snapshot.size || (snapshot.totalBytes === undefined
? t($ => $.addDownloads.unknownSize)
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
@@ -369,14 +418,32 @@ export const PropertiesWindowApp = () => {
<p className="mt-1 text-xs text-text-muted" role="status">{statusLabel} · {Math.round(progress * 100)}% · {total}</p>
</div>
<div className="flex flex-wrap items-center gap-2" aria-label={t($ => $.actions.continue)}>
<button type="button" className="app-button px-3 text-xs" onClick={() => void requestAction('pause-resume')}>
{['paused', 'ready', 'staged', 'completed', 'failed'].includes(snapshot.status) ? <Play size={14} /> : <Pause size={14} />}
{['paused', 'ready', 'staged', 'completed', 'failed'].includes(snapshot.status) ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.pause)}
</button>
{lifecycleAction && <button
type="button"
className="app-button px-3 text-xs"
disabled={isSaving}
onClick={() => {
if (lifecycleAction === 'pause'
&& snapshot.resumable === false
&& !window.confirm(t($ => $.downloadTable.nonResumableOne))) {
return;
}
void requestAction('pause-resume');
}}
>
{lifecycleAction === 'pause' ? <Pause size={14} /> : <Play size={14} />}
{lifecycleAction === 'pause'
? t($ => $.downloads.actions.pause)
: lifecycleAction === 'resume'
? t($ => $.downloads.actions.resume)
: lifecycleAction === 'retry'
? t($ => $.downloads.actions.retry)
: t($ => $.downloads.actions.start)}
</button>}
{isTorrent && <>
<button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('magnet')}><Copy size={14} />{t($ => $.properties.torrentCopyMagnet)}</button>
<button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('export')}><FileDown size={14} />{t($ => $.properties.torrentExportMetadata)}</button>
<button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>
<button type="button" className="app-button px-3 text-xs" disabled={isSaving} onClick={() => void performTorrentAction('magnet')}><Copy size={14} />{t($ => $.properties.torrentCopyMagnet)}</button>
<button type="button" className="app-button px-3 text-xs" disabled={isSaving} onClick={() => void performTorrentAction('export')}><FileDown size={14} />{t($ => $.properties.torrentExportMetadata)}</button>
<button type="button" className="app-button px-3 text-xs" disabled={isSaving} onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>
</>}
</div>
</div>
@@ -387,6 +454,7 @@ export const PropertiesWindowApp = () => {
<span>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</span>
<span>{snapshot.speed || '—'}</span>
<span>{snapshot.eta || '—'}</span>
<span>{snapshot.activeConnections ?? '—'} / {snapshot.requestedConnections ?? snapshot.connections ?? '—'} {t($ => $.properties.connections)}</span>
{isTorrent && <span>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</span>}
</div>
</header>
@@ -432,7 +500,7 @@ export const PropertiesWindowApp = () => {
<span className="text-text-muted">{t($ => $.properties.torrentDetailsPieces)}</span><span>{details.pieceCount} × {formatDownloadBytes(details.pieceLength)}</span>
<span className="text-text-muted">{t($ => $.properties.torrentDetailsPrivate)}</span><span>{details.private ? t($ => $.properties.torrentDetailsPrivateYes) : t($ => $.properties.torrentDetailsPrivateNo)}</span>
</div>}
{isTorrent && <div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('verify')}><RefreshCw size={14} />{t($ => $.properties.torrentVerifyNow)}</button></div>}
{isTorrent && <div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={isSaving || !['paused', 'completed', 'failed'].includes(snapshot.status)} onClick={() => void performTorrentAction('verify')}><RefreshCw size={14} />{t($ => $.properties.torrentVerifyNow)}</button></div>}
</div>}
{activeTab === 'files' && isTorrent && <div className="space-y-3">
+127 -13
View File
@@ -2,7 +2,8 @@ import { useEffect } from 'react';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useDownloadStore } from '../store/useDownloadStore';
import type { DownloadItem } from '../store/useDownloadStore';
import { getPauseResumeAction } from '../utils/downloadActions';
import { useSettingsStore } from '../store/useSettingsStore';
import { useDownloadProgressStore } from '../store/downloadProgressStore';
import {
isValidTorrentExcludeTrackerList,
isValidTorrentTrackerList,
@@ -13,6 +14,9 @@ import {
PROPERTIES_WINDOW_CLOSED,
PROPERTIES_WINDOW_READY,
applySecretPatch,
beginExclusivePropertiesAction,
createFrameCoalescer,
getPropertiesLifecycleAction,
sanitizePropertiesSnapshot,
sendPropertiesActionResult,
sendPropertiesRemoved,
@@ -22,6 +26,7 @@ import {
type PropertiesWindowReady,
} from '../propertiesBridge';
import { invokeCommand as invoke } from '../ipc';
import i18n, { resolveAppLocale } from '../i18n';
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
@@ -105,21 +110,41 @@ export const PropertiesWindowBridgeHost = () => {
useEffect(() => {
const windows = new Map<string, string>();
const snapshotRevisions = new Map<string, number>();
const actionsInFlight = new Set<string>();
let disposed = false;
let unlistenReady: UnlistenFn | undefined;
let unlistenAction: UnlistenFn | undefined;
let unlistenClosed: UnlistenFn | undefined;
const snapshotCoalescer = createFrameCoalescer(
windowLabel => {
const downloadId = windows.get(windowLabel);
if (downloadId) void sendFor(windowLabel, downloadId).catch(() => undefined);
},
callback => window.requestAnimationFrame(callback),
handle => window.cancelAnimationFrame(handle),
);
const sendFor = async (windowLabel: string, downloadId: string) => {
const item = useDownloadStore.getState().downloads.find(download => download.id === downloadId);
if (!item || disposed) return false;
const settings = useSettingsStore.getState();
const progress = useDownloadProgressStore.getState();
const revision = (snapshotRevisions.get(windowLabel) ?? 0) + 1;
snapshotRevisions.set(windowLabel, revision);
await sendPropertiesSnapshot(windowLabel, {
windowLabel,
downloadId,
revision,
snapshot: sanitizePropertiesSnapshot(item),
snapshot: sanitizePropertiesSnapshot(item, {
theme: settings.theme,
fontFamily: settings.fontFamily,
appFontSize: settings.appFontSize,
listRowDensity: settings.listRowDensity,
locale: resolveAppLocale(i18n.language),
}, {
progress: progress.progressMap[downloadId],
moveProgress: progress.moveProgressMap[downloadId],
}),
});
return true;
};
@@ -144,11 +169,14 @@ export const PropertiesWindowBridgeHost = () => {
const handleAction = async (request: PropertiesActionRequest) => {
let ok = false;
let error: string | undefined;
const actionKey = `${request.windowLabel}:${request.downloadId}`;
let releaseAction: (() => void) | undefined;
try {
await invoke('validate_properties_window_request', request);
if (windows.get(request.windowLabel) !== request.downloadId) {
throw new Error('Properties window is no longer registered');
}
releaseAction = beginExclusivePropertiesAction(actionsInFlight, actionKey);
const store = useDownloadStore.getState();
const item = store.downloads.find(download => download.id === request.downloadId);
if (!item) throw new Error('Download no longer exists');
@@ -172,10 +200,57 @@ export const PropertiesWindowBridgeHost = () => {
await store.applyProperties(request.downloadId, safePatch);
break;
}
case 'pause-resume':
if (getPauseResumeAction(item.status) === 'pause') await store.pauseDownload(request.downloadId);
else await store.resumeDownload(request.downloadId);
case 'pause-resume': {
const lifecycleAction = getPropertiesLifecycleAction(item.status);
if (!lifecycleAction) {
throw new Error('This download has no available lifecycle action');
}
if (lifecycleAction === 'pause') {
await store.pauseDownload(request.downloadId);
const current = useDownloadStore.getState().downloads.find(download => download.id === request.downloadId);
if (!current) throw new Error('Download was removed while pausing');
if (!['paused', 'completed', 'failed'].includes(current.status)) {
throw new Error('The download did not reach a paused or terminal state');
}
} else {
const resumed = await store.resumeDownload(request.downloadId);
if (!resumed) {
throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart));
}
const current = useDownloadStore.getState().downloads.find(download => download.id === request.downloadId);
if (!current) throw new Error('Download was removed while starting');
// A fast completion is a valid outcome of a successful resume;
// only a status that proves the request never left its
// pre-action state is a rejected start. Preserve failed as an
// error so a real backend failure is not reported as success.
if (['paused', 'ready', 'staged', 'failed'].includes(current.status)) {
throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart));
}
}
break;
}
case 'verify-torrent': {
if (item.isTorrent !== true
|| !['paused', 'completed', 'failed'].includes(item.status)) {
throw new Error('Pause the Torrent before verifying its data');
}
const previousVerifyOnly = item.torrentVerifyOnly;
const previousRestoreStatus = item.torrentVerifyRestoreStatus;
store.updateDownload(request.downloadId, {
torrentVerifyOnly: true,
torrentVerifyRestoreStatus: item.status,
});
try {
await invoke('verify_torrent_data', { id: request.downloadId });
} catch (verifyError) {
useDownloadStore.getState().updateDownload(request.downloadId, {
torrentVerifyOnly: previousVerifyOnly,
torrentVerifyRestoreStatus: previousRestoreStatus,
});
throw verifyError;
}
break;
}
case 'set-download-limit':
await store.setDownloadSpeedLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null);
break;
@@ -190,9 +265,21 @@ export const PropertiesWindowBridgeHost = () => {
default:
throw new Error('Invalid Properties action');
}
if (!useDownloadStore.getState().downloads.some(download => download.id === request.downloadId)) {
throw new Error('Download was removed while applying the action');
}
ok = true;
} catch (caught) {
error = errorText(caught);
} finally {
releaseAction?.();
}
if (ok) {
try {
await sendFor(request.windowLabel, request.downloadId);
} catch {
// Snapshot delivery is best effort across a close/reopen race.
}
}
try {
await sendPropertiesActionResult(request.windowLabel, {
@@ -206,20 +293,16 @@ export const PropertiesWindowBridgeHost = () => {
// The window may have closed between the request and its result.
return;
}
if (ok) {
try {
await sendFor(request.windowLabel, request.downloadId);
} catch {
// Snapshot delivery is best effort across a close/reopen race.
}
}
};
void listen<PropertiesWindowReady>(PROPERTIES_WINDOW_READY, event => void handleReady(event.payload)).then(value => { unlistenReady = value; });
void listen<PropertiesActionRequest>(PROPERTIES_WINDOW_ACTION_REQUEST, event => void handleAction(event.payload)).then(value => { unlistenAction = value; });
void listen<string>(PROPERTIES_WINDOW_CLOSED, event => {
const downloadId = windows.get(event.payload);
windows.delete(event.payload);
snapshotRevisions.delete(event.payload);
snapshotCoalescer.cancel(event.payload);
if (downloadId) actionsInFlight.delete(`${event.payload}:${downloadId}`);
}).then(value => { unlistenClosed = value; });
const unsubscribeStore = useDownloadStore.subscribe((state, previous) => {
@@ -227,19 +310,50 @@ export const PropertiesWindowBridgeHost = () => {
const next = state.downloads.find(download => download.id === downloadId);
const before = previous.downloads.find(download => download.id === downloadId);
if (!next) {
snapshotCoalescer.cancel(windowLabel);
void sendPropertiesRemoved(windowLabel, downloadId).catch(() => undefined);
windows.delete(windowLabel);
snapshotRevisions.delete(windowLabel);
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
} else if (next !== before) {
void sendFor(windowLabel, downloadId).catch(() => undefined);
snapshotCoalescer.schedule(windowLabel);
}
}
});
const unsubscribeProgress = useDownloadProgressStore.subscribe((state, previous) => {
for (const [windowLabel, downloadId] of windows) {
if (state.progressMap[downloadId] !== previous.progressMap[downloadId]
|| state.moveProgressMap[downloadId] !== previous.moveProgressMap[downloadId]) {
snapshotCoalescer.schedule(windowLabel);
}
}
});
const unsubscribeSettings = useSettingsStore.subscribe((state, previous) => {
if (state.theme === previous.theme
&& state.fontFamily === previous.fontFamily
&& state.appFontSize === previous.appFontSize
&& state.listRowDensity === previous.listRowDensity
&& state.language === previous.language) {
return;
}
for (const windowLabel of windows.keys()) {
snapshotCoalescer.schedule(windowLabel);
}
});
const handleLanguageChanged = () => {
for (const windowLabel of windows.keys()) {
snapshotCoalescer.schedule(windowLabel);
}
};
i18n.on('languageChanged', handleLanguageChanged);
return () => {
disposed = true;
snapshotCoalescer.cancelAll();
unsubscribeStore();
unsubscribeProgress();
unsubscribeSettings();
i18n.off('languageChanged', handleLanguageChanged);
unlistenReady?.();
unlistenAction?.();
unlistenClosed?.();
+1
View File
@@ -77,6 +77,7 @@ const common = {
pause: 'Pause',
start: 'Start',
resume: 'Resume',
retry: 'Retry',
options: 'Options',
},
size: {
+1
View File
@@ -77,6 +77,7 @@ const fa = {
pause: 'توقف',
start: 'شروع',
resume: 'ادامه',
retry: 'تلاش مجدد',
options: 'گزینه‌ها',
},
size: {
+1
View File
@@ -77,6 +77,7 @@ const he = {
pause: 'השהייה',
start: 'הפעלה',
resume: 'חידוש',
retry: 'ניסיון חוזר',
options: 'אפשרויות',
},
size: {
+1
View File
@@ -77,6 +77,7 @@ const ru = {
pause: 'Приостановить',
start: 'Запустить',
resume: 'Возобновить',
retry: 'Повторить',
options: 'Параметры',
},
size: {
+1
View File
@@ -77,6 +77,7 @@ const uk = {
pause: 'Призупинити',
start: 'Запустити',
resume: 'Відновити',
retry: 'Повторити',
options: 'Опції',
},
size: {
+1
View File
@@ -77,6 +77,7 @@ const zhCN = {
pause: '暂停',
start: '开始',
resume: '恢复',
retry: '重试',
options: '选项',
},
size: {
-5
View File
@@ -643,11 +643,6 @@ html[data-list-density="relaxed"] {
max-height: min(680px, 100%);
}
.properties-modal {
max-width: 100%;
max-height: 100%;
}
/* Add Download window */
.add-download-modal {
width: min(900px, 100%);
+1
View File
@@ -158,6 +158,7 @@ type CommandMap = {
open_download_properties_window: { args: { id: string }; result: string };
get_properties_window_download_id: { args: undefined; result: string };
properties_window_send_ready: { args: undefined; result: void };
properties_window_reveal: { args: undefined; result: void };
properties_window_send_action: { args: { requestId: number; action: string; payload?: unknown }; result: void };
validate_properties_window_request: { args: { windowLabel: string; downloadId: string }; result: void };
close_download_properties_window: { args: { id: string }; result: void };
+141 -7
View File
@@ -10,7 +10,13 @@ vi.mock('@tauri-apps/api/event', () => ({
emitTo: vi.fn(),
}));
import { applySecretPatch, sanitizePropertiesSnapshot } from './propertiesBridge';
import {
applySecretPatch,
beginExclusivePropertiesAction,
createFrameCoalescer,
getPropertiesLifecycleAction,
sanitizePropertiesSnapshot,
} from './propertiesBridge';
describe('Properties window bridge', () => {
it('sanitizes transfer secrets while preserving presence flags', () => {
@@ -22,26 +28,154 @@ describe('Properties window bridge', () => {
cookies: 'sid=secret',
headers: 'Authorization: Bearer secret',
username: 'user',
mirrors: 'https://user:secret@example.test/mirror',
} as DownloadItem;
const snapshot = sanitizePropertiesSnapshot(item);
const snapshot = sanitizePropertiesSnapshot(item, {
theme: 'nord',
fontFamily: 'inter',
appFontSize: 'large',
listRowDensity: 'compact',
locale: 'fa',
});
expect(snapshot).not.toHaveProperty('password');
expect(snapshot).not.toHaveProperty('cookies');
expect(snapshot).not.toHaveProperty('headers');
expect(snapshot).not.toHaveProperty('username');
expect(snapshot).not.toHaveProperty('mirrors');
expect(snapshot.hasPassword).toBe(true);
expect(snapshot.hasCookies).toBe(true);
expect(snapshot.hasHeaders).toBe(true);
expect(snapshot.hasUsername).toBe(true);
expect(snapshot.hasMirrors).toBe(true);
expect(snapshot.appearance).toEqual({
theme: 'nord',
fontFamily: 'inter',
appFontSize: 'large',
listRowDensity: 'compact',
locale: 'fa',
});
});
it('projects the latest live telemetry without exposing secrets', () => {
const snapshot = sanitizePropertiesSnapshot({
id: 'torrent-1',
fileName: 'example',
url: 'https://example.test/file',
status: 'seeding',
category: 'Other',
dateAdded: '',
speed: '-',
eta: '-',
fraction: 0,
uploadedBytes: 1,
password: 'secret',
} as DownloadItem, {
theme: 'dark',
fontFamily: 'system',
appFontSize: 'standard',
listRowDensity: 'standard',
locale: 'en',
}, {
progress: {
id: 'torrent-1',
fraction: 0.75,
speed: '2 MiB/s',
eta: '10s',
size: '4 MiB',
size_is_final: true,
downloaded_bytes: 3,
total_bytes: 4,
total_is_estimate: false,
active_connections: 4,
requested_connections: 8,
uploaded_bytes: 9,
upload_speed: '1 MiB/s',
num_seeders: 6,
torrent_seeded_seconds: 12,
},
moveProgress: 0.5,
});
expect(snapshot).not.toHaveProperty('password');
expect(snapshot).toMatchObject({
fraction: 0.75,
speed: '1 MiB/s',
eta: '-',
downloadedBytes: 3,
totalBytes: 4,
totalIsEstimate: false,
activeConnections: 4,
requestedConnections: 8,
torrentUploadedBytes: 9,
uploadSpeed: '1 MiB/s',
torrentSeeders: 6,
torrentSeededSeconds: 12,
moveProgress: 0.5,
});
});
it('applies explicit secret changes without conflating unchanged fields', () => {
expect(applySecretPatch(undefined, 'existing')).toBe('existing');
expect(applySecretPatch({ kind: 'unchanged' }, 'existing')).toBe('existing');
expect(applySecretPatch({ kind: 'replace', value: 'new' }, 'existing')).toBe('new');
expect(applySecretPatch({ kind: 'clear' }, 'existing')).toBeUndefined();
expect(() => applySecretPatch({ kind: 'replace', value: 42 }, 'existing')).toThrow('Invalid secret value');
expect(() => applySecretPatch({ kind: 'unexpected' }, 'existing')).toThrow('Invalid secret patch');
});
expect(applySecretPatch({ kind: 'replace', value: 'new' }, 'existing')).toBe('new');
expect(applySecretPatch({ kind: 'clear' }, 'existing')).toBeUndefined();
expect(() => applySecretPatch({ kind: 'replace', value: 42 }, 'existing')).toThrow('Invalid secret value');
expect(() => applySecretPatch({ kind: 'unexpected' }, 'existing')).toThrow('Invalid secret patch');
});
it('derives truthful lifecycle commands from the current status', () => {
expect(getPropertiesLifecycleAction('downloading')).toBe('pause');
expect(getPropertiesLifecycleAction('retrying')).toBe('pause');
expect(getPropertiesLifecycleAction('paused')).toBe('resume');
expect(getPropertiesLifecycleAction('ready')).toBe('start');
expect(getPropertiesLifecycleAction('staged')).toBe('start');
expect(getPropertiesLifecycleAction('failed')).toBe('retry');
expect(getPropertiesLifecycleAction('completed')).toBeNull();
});
it('keeps the first action locked when a duplicate request is rejected', () => {
const inFlight = new Set<string>();
const release = beginExclusivePropertiesAction(inFlight, 'window:download');
expect(() => beginExclusivePropertiesAction(inFlight, 'window:download'))
.toThrow('Another Properties action is still in progress');
expect(inFlight.has('window:download')).toBe(true);
release();
release();
expect(inFlight.has('window:download')).toBe(false);
});
it('coalesces repeated snapshot requests to one callback per animation frame', () => {
const frames = new Map<number, FrameRequestCallback>();
const delivered: string[] = [];
let nextHandle = 0;
const coalescer = createFrameCoalescer(
key => delivered.push(key),
callback => {
const handle = ++nextHandle;
frames.set(handle, callback);
return handle;
},
handle => {
frames.delete(handle);
},
);
coalescer.schedule('properties-1');
coalescer.schedule('properties-1');
coalescer.schedule('properties-2');
expect(frames.size).toBe(2);
for (const [handle, callback] of [...frames]) {
frames.delete(handle);
callback(0);
}
expect(delivered).toEqual(['properties-1', 'properties-2']);
coalescer.schedule('properties-1');
coalescer.cancelAll();
expect(frames.size).toBe(0);
});
});
+183 -13
View File
@@ -1,5 +1,9 @@
import { emitTo } from '@tauri-apps/api/event';
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
import type { DownloadStatus } from './bindings/DownloadStatus';
import type { DownloadItem } from './store/useDownloadStore';
import { canPauseDownload } from './utils/downloadActions';
import type { DocumentAppearance } from './utils/documentAppearance';
import { invokeCommand as invoke } from './ipc';
export const PROPERTIES_WINDOW_READY = 'properties-window-ready' as const;
@@ -9,11 +13,77 @@ export const PROPERTIES_WINDOW_ACTION_RESULT = 'properties-window-action-result'
export const PROPERTIES_WINDOW_REMOVED = 'properties-window-removed' as const;
export const PROPERTIES_WINDOW_CLOSED = 'properties-window-closed' as const;
export type PropertiesSnapshot = Omit<DownloadItem, 'password' | 'cookies' | 'headers' | 'username'> & {
const PROPERTIES_SNAPSHOT_KEYS = [
'id',
'url',
'fileName',
'status',
'fraction',
'speed',
'eta',
'size',
'downloadedBytes',
'totalBytes',
'totalIsEstimate',
'category',
'dateAdded',
'resumable',
'connections',
'speedLimit',
'checksum',
'destination',
'isMedia',
'mediaFormatSelector',
'mediaQuality',
'queueId',
'queuePosition',
'hasBeenDispatched',
'lastError',
'lastTry',
'isTorrent',
'torrentFileIndices',
'torrentInfoHash',
'torrentSeedTime',
'torrentSeedRatio',
'torrentSeedRemaining',
'torrentUploadedBytes',
'torrentSeededSeconds',
'torrentRelocationCheckPending',
'torrentMoveDestination',
'torrentMoveRestoreStatus',
'torrentWebSeeds',
'torrentUploadLimit',
'torrentMaxPeers',
'torrentPeerSpeedLimit',
'torrentCheckIntegrity',
'torrentTrackers',
'torrentExcludeTrackers',
'torrentTrackerConnectTimeout',
'torrentTrackerTimeout',
'torrentTrackerInterval',
'torrentStopTimeout',
'torrentPrioritizePiece',
'torrentRemoveUnselectedFile',
'torrentEncryptionPolicy',
'torrentFileAllocation',
'torrentVerifyOnly',
'torrentVerifyRestoreStatus',
] as const satisfies readonly (keyof DownloadItem)[];
type SafePropertiesFields = Pick<DownloadItem, (typeof PROPERTIES_SNAPSHOT_KEYS)[number]>;
export type PropertiesSnapshot = SafePropertiesFields & {
appearance: DocumentAppearance;
activeConnections?: number;
requestedConnections?: number;
uploadSpeed?: string;
torrentSeeders?: number;
moveProgress?: number;
hasPassword: boolean;
hasCookies: boolean;
hasHeaders: boolean;
hasUsername: boolean;
hasMirrors: boolean;
};
export type SecretPatch =
@@ -31,10 +101,39 @@ export type PropertiesPatch = Partial<Omit<DownloadItem, 'password' | 'cookies'
export type PropertiesAction =
| 'apply-properties'
| 'pause-resume'
| 'verify-torrent'
| 'set-download-limit'
| 'set-torrent-upload-limit'
| 'set-torrent-peer-options';
export type PropertiesLifecycleAction = 'pause' | 'resume' | 'start' | 'retry';
export const getPropertiesLifecycleAction = (
status: DownloadStatus,
): PropertiesLifecycleAction | null => {
if (status === 'ready' || status === 'staged') return 'start';
if (canPauseDownload(status)) return 'pause';
if (status === 'paused') return 'resume';
if (status === 'failed') return 'retry';
return null;
};
export const beginExclusivePropertiesAction = (
inFlight: Set<string>,
key: string,
): (() => void) => {
if (inFlight.has(key)) {
throw new Error('Another Properties action is still in progress');
}
inFlight.add(key);
let released = false;
return () => {
if (released) return;
released = true;
inFlight.delete(key);
};
};
export type PropertiesWindowReady = {
windowLabel: string;
downloadId: string;
@@ -63,25 +162,96 @@ export type PropertiesSnapshotEvent = {
snapshot: PropertiesSnapshot;
};
const copyWithoutSecrets = (item: DownloadItem): PropertiesSnapshot => {
const {
password,
cookies,
headers,
username,
...safeItem
} = item;
const copyWithoutSecrets = (
item: DownloadItem,
appearance: DocumentAppearance,
live?: {
progress?: DownloadProgressEvent;
moveProgress?: number;
},
): PropertiesSnapshot => {
const safeItem = Object.fromEntries(
PROPERTIES_SNAPSHOT_KEYS.flatMap(key => (
Object.prototype.hasOwnProperty.call(item, key) ? [[key, item[key]]] : []
)),
) as SafePropertiesFields;
return {
...safeItem,
hasPassword: Boolean(password),
hasCookies: Boolean(cookies),
hasHeaders: Boolean(headers),
hasUsername: Boolean(username),
appearance,
...(live?.progress ? {
fraction: live.progress.fraction,
speed: item.status === 'seeding'
? live.progress.upload_speed ?? live.progress.speed
: live.progress.speed,
eta: item.status === 'seeding' ? '-' : live.progress.eta,
...(live.progress.size ? { size: live.progress.size } : {}),
...(live.progress.downloaded_bytes !== undefined
? { downloadedBytes: live.progress.downloaded_bytes }
: {}),
...(live.progress.total_bytes !== undefined
? { totalBytes: live.progress.total_bytes }
: {}),
...(live.progress.total_is_estimate !== undefined
? { totalIsEstimate: live.progress.total_is_estimate }
: {}),
...(live.progress.active_connections !== undefined
? { activeConnections: live.progress.active_connections }
: {}),
...(live.progress.requested_connections !== undefined
? { requestedConnections: live.progress.requested_connections }
: {}),
...(live.progress.uploaded_bytes !== undefined
? { torrentUploadedBytes: live.progress.uploaded_bytes }
: {}),
...(live.progress.upload_speed !== undefined
? { uploadSpeed: live.progress.upload_speed }
: {}),
...(live.progress.num_seeders !== undefined
? { torrentSeeders: live.progress.num_seeders }
: {}),
...(live.progress.torrent_seeded_seconds !== undefined
? { torrentSeededSeconds: live.progress.torrent_seeded_seconds }
: {}),
} : {}),
...(live?.moveProgress !== undefined ? { moveProgress: live.moveProgress } : {}),
hasPassword: Boolean(item.password),
hasCookies: Boolean(item.cookies),
hasHeaders: Boolean(item.headers),
hasUsername: Boolean(item.username),
hasMirrors: Boolean(item.mirrors),
};
};
export const sanitizePropertiesSnapshot = copyWithoutSecrets;
export const createFrameCoalescer = (
callback: (key: string) => void,
requestFrame: (callback: FrameRequestCallback) => number,
cancelFrame: (handle: number) => void,
) => {
const pending = new Map<string, number>();
return {
schedule(key: string) {
if (pending.has(key)) return;
const handle = requestFrame(() => {
pending.delete(key);
callback(key);
});
pending.set(key, handle);
},
cancel(key: string) {
const handle = pending.get(key);
if (handle === undefined) return;
pending.delete(key);
cancelFrame(handle);
},
cancelAll() {
for (const handle of pending.values()) cancelFrame(handle);
pending.clear();
},
};
};
export const openPropertiesWindow = (downloadId: string): Promise<string> =>
invoke('open_download_properties_window', { id: downloadId });
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { applyDocumentAppearance } from './documentAppearance';
const fakeDocument = () => {
const classes = new Set<string>(['theme-light', 'dark']);
const root = {
classList: {
add: (...values: string[]) => values.forEach(value => classes.add(value)),
remove: (...values: string[]) => values.forEach(value => classes.delete(value)),
},
dataset: {} as Record<string, string>,
style: {} as Record<string, string>,
lang: '',
dir: '',
};
return {
classes,
root,
document: { documentElement: root } as unknown as Document,
};
};
describe('document appearance synchronization', () => {
it('applies a complete dark RTL projection without retaining stale theme classes', () => {
const target = fakeDocument();
applyDocumentAppearance(target.document, {
theme: 'nord',
fontFamily: 'vazirmatn',
appFontSize: 'large',
listRowDensity: 'compact',
locale: 'fa',
}, false);
expect([...target.classes].sort()).toEqual(['dark', 'theme-nord']);
expect(target.root.dataset).toEqual({
resolvedTheme: 'dark',
fontFamily: 'vazirmatn',
fontSize: 'large',
listDensity: 'compact',
});
expect(target.root.style.colorScheme).toBe('dark');
expect(target.root.lang).toBe('fa');
expect(target.root.dir).toBe('rtl');
});
it('resolves system appearance while preserving an LTR locale', () => {
const target = fakeDocument();
applyDocumentAppearance(target.document, {
theme: 'system',
fontFamily: 'system',
appFontSize: 'standard',
listRowDensity: 'standard',
locale: 'en',
}, false);
expect([...target.classes]).toEqual(['theme-light']);
expect(target.root.dataset.resolvedTheme).toBe('light');
expect(target.root.style.colorScheme).toBe('light');
expect(target.root.lang).toBe('en');
expect(target.root.dir).toBe('ltr');
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { AppFontSize } from '../bindings/AppFontSize';
import type { FontFamily } from '../bindings/FontFamily';
import type { ListRowDensity } from '../bindings/ListRowDensity';
import type { Theme } from '../bindings/Theme';
import { localeDirection, resolveAppLocale, type AppLocale } from '../i18n/locales';
export type DocumentAppearance = {
theme: Theme;
fontFamily: FontFamily;
appFontSize: AppFontSize;
listRowDensity: ListRowDensity;
locale: AppLocale;
};
const DARK_THEMES: ReadonlySet<Theme> = new Set(['dark', 'dracula', 'nord']);
const THEME_CLASSES = ['theme-dark', 'theme-light', 'theme-dracula', 'theme-nord', 'dark'] as const;
export const applyDocumentAppearance = (
document: Document,
appearance: DocumentAppearance,
systemDark: boolean,
): void => {
const root = document.documentElement;
const resolvedTheme = appearance.theme === 'system'
? (systemDark ? 'dark' : 'light')
: (DARK_THEMES.has(appearance.theme) ? 'dark' : 'light');
const themeClass = appearance.theme === 'system'
? `theme-${resolvedTheme}`
: `theme-${appearance.theme}`;
root.classList.remove(...THEME_CLASSES);
root.classList.add(themeClass);
if (resolvedTheme === 'dark') root.classList.add('dark');
root.dataset.resolvedTheme = resolvedTheme;
root.style.colorScheme = resolvedTheme;
root.dataset.fontFamily = appearance.fontFamily;
root.dataset.fontSize = appearance.appFontSize;
root.dataset.listDensity = appearance.listRowDensity;
const locale = resolveAppLocale(appearance.locale);
root.lang = locale;
root.dir = localeDirection(locale);
};
export const synchronizeDocumentAppearance = (
window: Window,
appearance: DocumentAppearance,
): (() => void) => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => applyDocumentAppearance(window.document, appearance, media.matches);
apply();
if (appearance.theme !== 'system') return () => undefined;
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
};