Files
Firelink/src/components/PropertiesWindowBridgeHost.tsx
T
NimBold 64a836f09f fix(properties): harden lifecycle and session fencing
- 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.
2026-08-12 07:07:52 +03:30

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;
};