mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-20 16:12:17 +00:00
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:
@@ -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?.();
|
||||
|
||||
Reference in New Issue
Block a user