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
+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?.();