mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-22 08:56:44 +00:00
64a836f09f
- Fence Properties actions and Torrent moves by current caller sessions. - Preserve move progress and authoritative destinations across stale events and recovery. - Enforce immutable identity fields and transactional queued-edit rejection. - Restore subtle theme surfaces and expand regression coverage.
743 lines
32 KiB
TypeScript
743 lines
32 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
|
import { useDownloadStore } from '../store/useDownloadStore';
|
|
import type { DownloadItem } from '../store/useDownloadStore';
|
|
import { useSettingsStore } from '../store/useSettingsStore';
|
|
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
|
import {
|
|
MAX_TORRENT_STOP_TIMEOUT,
|
|
isValidTorrentExcludeTrackerList,
|
|
isValidTorrentTrackerList,
|
|
normalizeSpeedLimitForBackend,
|
|
normalizeTorrentEncryptionPolicy,
|
|
normalizeTorrentFileAllocation,
|
|
normalizeTorrentPrioritizePiece,
|
|
normalizeTorrentTrackerInterval,
|
|
normalizeTorrentTrackerTimeout,
|
|
} from '../utils/downloads';
|
|
import {
|
|
PROPERTIES_WINDOW_ACTION_REQUEST,
|
|
PROPERTIES_WINDOW_CLOSED,
|
|
PROPERTIES_WINDOW_READY,
|
|
applySecretPatch,
|
|
attachAsyncPropertiesListener,
|
|
beginExclusivePropertiesAction,
|
|
classifyPropertiesActionRequest,
|
|
createFrameCoalescer,
|
|
decodePropertiesPatchValue,
|
|
enqueuePropertiesAction,
|
|
getPropertiesLifecycleAction,
|
|
propertiesActionRequestKey,
|
|
PROPERTIES_PATCH_CLEARABLE_KEYS,
|
|
sanitizePropertiesSnapshot,
|
|
redactPropertiesError,
|
|
sendPropertiesActionResult,
|
|
sendPropertiesRemoved,
|
|
sendPropertiesSnapshot,
|
|
propertiesWindowEventTarget,
|
|
type PropertiesActionRequest,
|
|
type PropertiesActionResult,
|
|
type PropertiesPatch,
|
|
type PropertiesWindowRegistration,
|
|
type PropertiesWindowReady,
|
|
} from '../propertiesBridge';
|
|
import { invokeCommand as invoke } from '../ipc';
|
|
import { getPlatformInfo } from '../utils/platform';
|
|
import { resolveWindowControlSide, resolveWindowControlStyle } from '../utils/windowControlStyle';
|
|
import i18n, { localeDirection, resolveAppLocale } from '../i18n';
|
|
|
|
const errorText = redactPropertiesError;
|
|
let lastPropertiesBridgeGeneration = 0;
|
|
|
|
const normalizeOptionalSpeed = (value: unknown, label: string): string | undefined => {
|
|
if (typeof value !== 'string') throw new Error(`Invalid ${label}`);
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return undefined;
|
|
const normalized = normalizeSpeedLimitForBackend(trimmed);
|
|
if (!normalized) throw new Error(`Invalid ${label}`);
|
|
return normalized;
|
|
};
|
|
|
|
export const copyEditablePropertiesPatch = (
|
|
rawPatch: PropertiesPatch,
|
|
item?: Pick<DownloadItem, 'isTorrent' | 'status'>,
|
|
): Partial<DownloadItem> => {
|
|
const safePatch: Partial<DownloadItem> = {};
|
|
const copy = (key: keyof PropertiesPatch) => {
|
|
if (Object.prototype.hasOwnProperty.call(rawPatch, key)) {
|
|
(safePatch as Record<string, unknown>)[key] = rawPatch[key];
|
|
}
|
|
};
|
|
for (const key of [
|
|
'fileName',
|
|
'destination',
|
|
'sftpHostKeyMd',
|
|
'connections',
|
|
'speedLimit',
|
|
'torrentTrackers',
|
|
'torrentExcludeTrackers',
|
|
'torrentSeedTime',
|
|
'torrentSeedRatio',
|
|
'torrentCheckIntegrity',
|
|
'torrentRemoveUnselectedFile',
|
|
'torrentUploadLimit',
|
|
'torrentMaxPeers',
|
|
'torrentPeerSpeedLimit',
|
|
'torrentTrackerConnectTimeout',
|
|
'torrentTrackerTimeout',
|
|
'torrentTrackerInterval',
|
|
'torrentStopTimeout',
|
|
'torrentPrioritizePiece',
|
|
'torrentEncryptionPolicy',
|
|
'torrentFileAllocation',
|
|
] as const) copy(key);
|
|
|
|
if (item && (item.isTorrent === true || !['ready', 'staged'].includes(item.status))) {
|
|
if (Object.prototype.hasOwnProperty.call(rawPatch, 'fileName')
|
|
|| Object.prototype.hasOwnProperty.call(rawPatch, 'destination')) {
|
|
throw new Error('File identity and destination are read-only for this download state');
|
|
}
|
|
}
|
|
|
|
for (const key of PROPERTIES_PATCH_CLEARABLE_KEYS) {
|
|
if (Object.prototype.hasOwnProperty.call(rawPatch, key)) {
|
|
const value = (rawPatch as Record<string, unknown>)[key];
|
|
(safePatch as Record<string, unknown>)[key] = decodePropertiesPatchValue(value);
|
|
}
|
|
}
|
|
|
|
if (safePatch.fileName !== undefined && typeof safePatch.fileName !== 'string') {
|
|
throw new Error('Invalid file name');
|
|
}
|
|
if (safePatch.destination !== undefined && typeof safePatch.destination !== 'string') {
|
|
throw new Error('Invalid destination');
|
|
}
|
|
if (safePatch.sftpHostKeyMd !== undefined) {
|
|
if (typeof safePatch.sftpHostKeyMd !== 'string') throw new Error('Invalid SFTP host-key fingerprint');
|
|
const fingerprint = safePatch.sftpHostKeyMd.trim().toLowerCase();
|
|
const valid = /^(md5|sha-1)=[0-9a-f]+$/.test(fingerprint)
|
|
&& ((fingerprint.startsWith('md5=') && fingerprint.length === 36)
|
|
|| (fingerprint.startsWith('sha-1=') && fingerprint.length === 45));
|
|
if (!valid) throw new Error('Invalid SFTP host-key fingerprint');
|
|
safePatch.sftpHostKeyMd = fingerprint;
|
|
}
|
|
|
|
if (safePatch.connections !== undefined
|
|
&& (!Number.isInteger(safePatch.connections) || safePatch.connections < 1 || safePatch.connections > 16)) {
|
|
throw new Error('Connections must be a whole number from 1 to 16');
|
|
}
|
|
if (safePatch.speedLimit !== undefined) {
|
|
safePatch.speedLimit = normalizeOptionalSpeed(safePatch.speedLimit, 'download speed limit');
|
|
}
|
|
if (safePatch.torrentUploadLimit !== undefined) {
|
|
safePatch.torrentUploadLimit = normalizeOptionalSpeed(safePatch.torrentUploadLimit, 'Torrent upload limit');
|
|
}
|
|
if (safePatch.torrentPeerSpeedLimit !== undefined) {
|
|
safePatch.torrentPeerSpeedLimit = normalizeOptionalSpeed(safePatch.torrentPeerSpeedLimit, 'Torrent peer speed limit');
|
|
}
|
|
if (safePatch.torrentMaxPeers !== undefined
|
|
&& (!Number.isInteger(safePatch.torrentMaxPeers) || safePatch.torrentMaxPeers < 0 || safePatch.torrentMaxPeers > 1000)) {
|
|
throw new Error('Torrent maximum peers must be a whole number from 0 to 1000');
|
|
}
|
|
for (const [key, minimum] of [
|
|
['torrentSeedTime', 0],
|
|
['torrentSeedRatio', 0],
|
|
] as const) {
|
|
const value = safePatch[key];
|
|
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value < minimum)) {
|
|
throw new Error(`Invalid ${key}`);
|
|
}
|
|
}
|
|
for (const key of ['torrentTrackerConnectTimeout', 'torrentTrackerTimeout'] as const) {
|
|
const value = safePatch[key];
|
|
if (value !== undefined && normalizeTorrentTrackerTimeout(value) === undefined) {
|
|
throw new Error(`Invalid ${key}`);
|
|
}
|
|
}
|
|
if (safePatch.torrentTrackerInterval !== undefined
|
|
&& normalizeTorrentTrackerInterval(safePatch.torrentTrackerInterval) === undefined) {
|
|
throw new Error('Invalid torrentTrackerInterval');
|
|
}
|
|
if (safePatch.torrentStopTimeout !== undefined
|
|
&& (!Number.isInteger(safePatch.torrentStopTimeout)
|
|
|| safePatch.torrentStopTimeout < 0
|
|
|| safePatch.torrentStopTimeout > MAX_TORRENT_STOP_TIMEOUT)) {
|
|
throw new Error('Invalid torrentStopTimeout');
|
|
}
|
|
if (safePatch.torrentPrioritizePiece !== undefined
|
|
&& normalizeTorrentPrioritizePiece(safePatch.torrentPrioritizePiece) == null) {
|
|
throw new Error('Invalid torrentPrioritizePiece');
|
|
}
|
|
if (safePatch.torrentEncryptionPolicy !== undefined
|
|
&& normalizeTorrentEncryptionPolicy(safePatch.torrentEncryptionPolicy) === undefined) {
|
|
throw new Error('Invalid torrentEncryptionPolicy');
|
|
}
|
|
if (safePatch.torrentFileAllocation !== undefined
|
|
&& normalizeTorrentFileAllocation(safePatch.torrentFileAllocation) === undefined) {
|
|
throw new Error('Invalid torrentFileAllocation');
|
|
}
|
|
for (const key of ['torrentCheckIntegrity', 'torrentRemoveUnselectedFile'] as const) {
|
|
if (safePatch[key] !== undefined && typeof safePatch[key] !== 'boolean') {
|
|
throw new Error(`Invalid ${key}`);
|
|
}
|
|
}
|
|
if (safePatch.torrentTrackers !== undefined
|
|
&& (typeof safePatch.torrentTrackers !== 'string' || !isValidTorrentTrackerList(safePatch.torrentTrackers))) {
|
|
throw new Error('Invalid Torrent tracker list');
|
|
}
|
|
if (typeof safePatch.torrentTrackers === 'string' && !safePatch.torrentTrackers.trim()) {
|
|
safePatch.torrentTrackers = undefined;
|
|
}
|
|
if (safePatch.torrentExcludeTrackers !== undefined
|
|
&& (typeof safePatch.torrentExcludeTrackers !== 'string' || !isValidTorrentExcludeTrackerList(safePatch.torrentExcludeTrackers))) {
|
|
throw new Error('Invalid excluded Torrent tracker list');
|
|
}
|
|
if (typeof safePatch.torrentExcludeTrackers === 'string' && !safePatch.torrentExcludeTrackers.trim()) {
|
|
safePatch.torrentExcludeTrackers = undefined;
|
|
}
|
|
if (safePatch.torrentFileIndices !== undefined
|
|
&& (!Array.isArray(safePatch.torrentFileIndices)
|
|
|| safePatch.torrentFileIndices.length === 0
|
|
|| safePatch.torrentFileIndices.some(index => !Number.isInteger(index) || index < 0))) {
|
|
throw new Error('Torrent file selection must contain at least one valid file');
|
|
}
|
|
return safePatch;
|
|
};
|
|
|
|
export const PropertiesWindowBridgeHost = () => {
|
|
useEffect(() => {
|
|
const mainWindowTarget = propertiesWindowEventTarget(getCurrentWindow().label);
|
|
const windows = new Map<string, PropertiesWindowRegistration>();
|
|
const snapshotRevisions = new Map<string, number>();
|
|
const actionsInFlight = new Set<string>();
|
|
const actionChains = new Map<string, Promise<void>>();
|
|
const actionOperations = new Map<string, Promise<void>>();
|
|
const actionResults = new Map<string, PropertiesActionResult>();
|
|
let platformOs = 'unknown';
|
|
const bridgeGeneration = Math.max(Date.now(), lastPropertiesBridgeGeneration + 1);
|
|
lastPropertiesBridgeGeneration = bridgeGeneration;
|
|
let disposed = false;
|
|
let unlistenReady: UnlistenFn | undefined;
|
|
let unlistenAction: UnlistenFn | undefined;
|
|
let unlistenClosed: UnlistenFn | undefined;
|
|
const snapshotCoalescer = createFrameCoalescer(
|
|
windowLabel => {
|
|
const registration = windows.get(windowLabel);
|
|
if (registration) void sendFor(windowLabel, registration.downloadId).catch(() => undefined);
|
|
},
|
|
callback => window.requestAnimationFrame(callback),
|
|
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;
|
|
const store = useDownloadStore.getState();
|
|
const item = store.downloads.find(download => download.id === downloadId);
|
|
if (!item) return false;
|
|
const queue = store.queues.find(candidate => candidate.id === item.queueId)
|
|
?? 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, {
|
|
windowLabel,
|
|
downloadId,
|
|
sessionId: registration.sessionId,
|
|
bridgeGeneration,
|
|
revision,
|
|
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],
|
|
}, {
|
|
queueName: queue?.name,
|
|
windowChrome,
|
|
}),
|
|
});
|
|
return true;
|
|
};
|
|
|
|
const synchronizeRegistration = (
|
|
windowLabel: string,
|
|
downloadId: string,
|
|
sessionId: string,
|
|
) => {
|
|
const previous = windows.get(windowLabel);
|
|
const sessionChanged = previous?.downloadId !== downloadId || previous.sessionId !== sessionId;
|
|
if (sessionChanged) clearWindowActionState(windowLabel);
|
|
windows.set(windowLabel, {
|
|
downloadId,
|
|
sessionId,
|
|
latestRequestId: sessionChanged ? 0 : (previous?.latestRequestId ?? 0),
|
|
});
|
|
if (sessionChanged) {
|
|
snapshotRevisions.set(windowLabel, 0);
|
|
} else if (!snapshotRevisions.has(windowLabel)) {
|
|
snapshotRevisions.set(windowLabel, 0);
|
|
}
|
|
};
|
|
|
|
const assertCurrentAction = async (request: PropertiesActionRequest) => {
|
|
await invoke('validate_properties_window_request', {
|
|
windowLabel: request.windowLabel,
|
|
downloadId: request.downloadId,
|
|
sessionId: request.sessionId,
|
|
requestId: request.requestId,
|
|
});
|
|
if (disposed) throw new Error('Properties bridge is no longer active');
|
|
const registration = windows.get(request.windowLabel);
|
|
if (!registration
|
|
|| registration.downloadId !== request.downloadId
|
|
|| registration.sessionId !== request.sessionId
|
|
|| registration.latestRequestId !== request.requestId) {
|
|
throw new Error('Properties action is stale');
|
|
}
|
|
};
|
|
|
|
const handleReady = async (payload: PropertiesWindowReady) => {
|
|
if (disposed) return;
|
|
try {
|
|
await invoke('validate_properties_window_request', payload);
|
|
if (disposed) return;
|
|
const item = useDownloadStore.getState().downloads.find(download => download.id === payload.downloadId);
|
|
if (!item) {
|
|
// The store subscription cannot see a window that never completed
|
|
// registration. Tear down the native registry entry here as well,
|
|
// otherwise a late ready event can leave an empty child window and
|
|
// a permanently reserved label for a deleted download.
|
|
void sendPropertiesRemoved(payload.windowLabel, payload.downloadId).catch(() => undefined);
|
|
await invoke('properties_window_registry_remove_for_download', { id: payload.downloadId });
|
|
return;
|
|
}
|
|
synchronizeRegistration(payload.windowLabel, payload.downloadId, payload.sessionId);
|
|
await sendFor(payload.windowLabel, payload.downloadId);
|
|
} catch {
|
|
// The child will show its own unavailable state. Do not log bridge
|
|
// payloads because they may contain URLs or other user data.
|
|
}
|
|
};
|
|
|
|
const processAction = async (request: PropertiesActionRequest) => {
|
|
let ok = false;
|
|
let error: string | undefined;
|
|
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
|
let releaseAction: (() => void) | undefined;
|
|
try {
|
|
// The request may have waited behind another action. Revalidate the
|
|
// native session and request ordering at dequeue time so a closed,
|
|
// reopened, or reloaded Properties window cannot apply stale work.
|
|
await assertCurrentAction(request);
|
|
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');
|
|
|
|
switch (request.action) {
|
|
case 'apply-properties': {
|
|
await assertCurrentAction(request);
|
|
const rawPatch = (request.payload ?? {}) as PropertiesPatch;
|
|
if (Object.prototype.hasOwnProperty.call(rawPatch, 'torrentFileIndices')) {
|
|
throw new Error('Torrent file selection requires the dedicated selection action');
|
|
}
|
|
const safePatch = copyEditablePropertiesPatch(rawPatch, item);
|
|
if ('password' in rawPatch) {
|
|
safePatch.password = applySecretPatch(rawPatch.password, item.password);
|
|
}
|
|
if ('cookies' in rawPatch) {
|
|
safePatch.cookies = applySecretPatch(rawPatch.cookies, item.cookies);
|
|
}
|
|
if ('headers' in rawPatch) {
|
|
safePatch.headers = applySecretPatch(rawPatch.headers, item.headers);
|
|
}
|
|
if ('username' in rawPatch) {
|
|
safePatch.username = applySecretPatch(rawPatch.username, item.username);
|
|
}
|
|
const torrentOptionKeys = [
|
|
'torrentFileIndices',
|
|
'torrentTrackers',
|
|
'torrentExcludeTrackers',
|
|
'torrentSeedTime',
|
|
'torrentSeedRatio',
|
|
'torrentCheckIntegrity',
|
|
'torrentRemoveUnselectedFile',
|
|
'torrentUploadLimit',
|
|
'torrentMaxPeers',
|
|
'torrentPeerSpeedLimit',
|
|
'torrentTrackerConnectTimeout',
|
|
'torrentTrackerTimeout',
|
|
'torrentTrackerInterval',
|
|
'torrentStopTimeout',
|
|
'torrentPrioritizePiece',
|
|
'torrentEncryptionPolicy',
|
|
'torrentFileAllocation',
|
|
] as const;
|
|
if (item.isTorrent !== true && torrentOptionKeys.some(key => Object.prototype.hasOwnProperty.call(rawPatch, key))) {
|
|
throw new Error('Torrent properties are only available for Torrent downloads');
|
|
}
|
|
if (item.isTorrent === true && Object.prototype.hasOwnProperty.call(rawPatch, 'connections')) {
|
|
throw new Error('Generic connection settings are not available for Torrent downloads');
|
|
}
|
|
await assertCurrentAction(request);
|
|
await store.applyProperties(request.downloadId, safePatch);
|
|
break;
|
|
}
|
|
case 'set-torrent-file-selection': {
|
|
if (item.isTorrent !== true
|
|
|| !request.payload
|
|
|| !('selectedIndices' in request.payload)) {
|
|
throw new Error('Torrent file selection is unavailable for this download');
|
|
}
|
|
const selectedIndices = request.payload.selectedIndices;
|
|
if (selectedIndices !== null
|
|
&& (!Array.isArray(selectedIndices)
|
|
|| selectedIndices.length === 0
|
|
|| selectedIndices.some(index => !Number.isInteger(index) || index < 1))) {
|
|
throw new Error('Torrent file selection must contain at least one valid file');
|
|
}
|
|
await assertCurrentAction(request);
|
|
const selection = await invoke('set_torrent_file_selection', {
|
|
id: request.downloadId,
|
|
selected_indices: selectedIndices,
|
|
});
|
|
const selected = selection.files.filter(file => file.selected).map(file => file.index);
|
|
const allSelected = selection.files.length > 0 && selected.length === selection.files.length;
|
|
await assertCurrentAction(request);
|
|
store.updateDownload(request.downloadId, {
|
|
torrentFileIndices: allSelected ? undefined : selected,
|
|
});
|
|
break;
|
|
}
|
|
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));
|
|
}
|
|
// resumeDownload returns after the lifecycle request has been
|
|
// accepted, while the backend may still be admitting a queue
|
|
// slot, rebinding a retained GID, or emitting the first active
|
|
// state. Do not inspect the store synchronously here: an event
|
|
// from the previous lifecycle can still leave the row paused
|
|
// for one turn even though the request was accepted.
|
|
if (!useDownloadStore.getState().downloads.some(download => download.id === request.downloadId)) {
|
|
throw new Error('Download was removed while starting');
|
|
}
|
|
}
|
|
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;
|
|
await assertCurrentAction(request);
|
|
store.updateDownload(request.downloadId, {
|
|
torrentVerifyOnly: true,
|
|
torrentVerifyRestoreStatus: item.status,
|
|
});
|
|
try {
|
|
await assertCurrentAction(request);
|
|
await invoke('verify_torrent_data', { id: request.downloadId });
|
|
} catch (verifyError) {
|
|
try {
|
|
await assertCurrentAction(request);
|
|
useDownloadStore.getState().updateDownload(request.downloadId, {
|
|
torrentVerifyOnly: previousVerifyOnly,
|
|
torrentVerifyRestoreStatus: previousRestoreStatus,
|
|
});
|
|
} catch {
|
|
// A newer Properties session owns the row now. Do not let a
|
|
// late verification failure roll back its marker.
|
|
}
|
|
throw verifyError;
|
|
}
|
|
break;
|
|
}
|
|
case 'set-download-limit':
|
|
await assertCurrentAction(request);
|
|
await store.setDownloadSpeedLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null);
|
|
break;
|
|
case 'set-torrent-upload-limit':
|
|
await assertCurrentAction(request);
|
|
await store.setTorrentUploadLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null);
|
|
break;
|
|
case 'set-torrent-peer-options': {
|
|
if (!request.payload || !('maxPeers' in request.payload)) throw new Error('Invalid Torrent peer options');
|
|
await assertCurrentAction(request);
|
|
await store.setTorrentPeerOptions(request.downloadId, request.payload.maxPeers, request.payload.peerSpeedLimit);
|
|
break;
|
|
}
|
|
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) 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, result);
|
|
} catch {
|
|
// 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;
|
|
try {
|
|
// The native command validates the caller, download binding, and
|
|
// renderer session. If a ready event is delayed or lost, this valid
|
|
// action can also establish the main-window registration.
|
|
await invoke('validate_properties_window_request', request);
|
|
if (disposed) return;
|
|
synchronizeRegistration(request.windowLabel, request.downloadId, request.sessionId);
|
|
} catch (error) {
|
|
// Stale renderer actions are deliberately ignored. The current child
|
|
// 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) {
|
|
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.
|
|
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(
|
|
listen<PropertiesWindowReady>(PROPERTIES_WINDOW_READY, event => {
|
|
if (!disposed) void handleReady(event.payload);
|
|
}, { target: mainWindowTarget }),
|
|
() => disposed,
|
|
value => { unlistenReady = value; },
|
|
);
|
|
attachAsyncPropertiesListener(
|
|
listen<PropertiesActionRequest>(PROPERTIES_WINDOW_ACTION_REQUEST, event => {
|
|
if (!disposed) void handleAction(event.payload);
|
|
}, { target: mainWindowTarget }),
|
|
() => disposed,
|
|
value => { unlistenAction = value; },
|
|
);
|
|
attachAsyncPropertiesListener(
|
|
listen<string>(PROPERTIES_WINDOW_CLOSED, event => {
|
|
if (disposed) return;
|
|
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}`);
|
|
}, { target: mainWindowTarget }),
|
|
() => disposed,
|
|
value => { unlistenClosed = value; },
|
|
);
|
|
|
|
const unsubscribeStore = useDownloadStore.subscribe((state, previous) => {
|
|
for (const [windowLabel, registration] of windows) {
|
|
const { downloadId } = registration;
|
|
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);
|
|
clearWindowActionState(windowLabel);
|
|
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
|
|
} else if (next !== before) {
|
|
snapshotCoalescer.schedule(windowLabel);
|
|
}
|
|
}
|
|
});
|
|
const unsubscribeProgress = useDownloadProgressStore.subscribe((state, previous) => {
|
|
for (const [windowLabel, registration] of windows) {
|
|
const { downloadId } = registration;
|
|
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
|
|
&& state.windowControlStyle === previous.windowControlStyle
|
|
&& state.sidebarPosition === previous.sidebarPosition) {
|
|
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);
|
|
|
|
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();
|
|
unsubscribeStore();
|
|
unsubscribeProgress();
|
|
unsubscribeSettings();
|
|
i18n.off('languageChanged', handleLanguageChanged);
|
|
unlistenReady?.();
|
|
unlistenAction?.();
|
|
unlistenClosed?.();
|
|
actionOperations.clear();
|
|
actionResults.clear();
|
|
actionChains.clear();
|
|
};
|
|
}, []);
|
|
|
|
return null;
|
|
};
|