mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 01:44:01 +00:00
446 lines
19 KiB
TypeScript
446 lines
19 KiB
TypeScript
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 { useSettingsStore } from '../store/useSettingsStore';
|
|
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
|
import {
|
|
isValidTorrentExcludeTrackerList,
|
|
isValidTorrentTrackerList,
|
|
normalizeSpeedLimitForBackend,
|
|
} from '../utils/downloads';
|
|
import {
|
|
PROPERTIES_WINDOW_ACTION_REQUEST,
|
|
PROPERTIES_WINDOW_CLOSED,
|
|
PROPERTIES_WINDOW_READY,
|
|
applySecretPatch,
|
|
attachAsyncPropertiesListener,
|
|
beginExclusivePropertiesAction,
|
|
createFrameCoalescer,
|
|
enqueuePropertiesAction,
|
|
getPropertiesLifecycleAction,
|
|
sanitizePropertiesSnapshot,
|
|
sendPropertiesActionResult,
|
|
sendPropertiesRemoved,
|
|
sendPropertiesSnapshot,
|
|
shouldAcceptPropertiesActionRequest,
|
|
type PropertiesActionRequest,
|
|
type PropertiesPatch,
|
|
type PropertiesWindowRegistration,
|
|
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);
|
|
|
|
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;
|
|
};
|
|
|
|
const copyEditablePatch = (rawPatch: PropertiesPatch): 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',
|
|
'connections',
|
|
'speedLimit',
|
|
'torrentFileIndices',
|
|
'torrentTrackers',
|
|
'torrentExcludeTrackers',
|
|
'torrentUploadLimit',
|
|
'torrentMaxPeers',
|
|
'torrentPeerSpeedLimit',
|
|
] as const) copy(key);
|
|
|
|
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.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');
|
|
}
|
|
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 windows = new Map<string, PropertiesWindowRegistration>();
|
|
const snapshotRevisions = new Map<string, number>();
|
|
const actionsInFlight = new Set<string>();
|
|
const actionChains = new Map<string, Promise<void>>();
|
|
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 sendFor = async (windowLabel: string, downloadId: string) => {
|
|
const registration = windows.get(windowLabel);
|
|
if (!registration || registration.downloadId !== downloadId || disposed) return false;
|
|
const item = useDownloadStore.getState().downloads.find(download => download.id === downloadId);
|
|
if (!item) 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,
|
|
sessionId: registration.sessionId,
|
|
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],
|
|
}),
|
|
});
|
|
return true;
|
|
};
|
|
|
|
const synchronizeRegistration = (
|
|
windowLabel: string,
|
|
downloadId: string,
|
|
sessionId: string,
|
|
) => {
|
|
const previous = windows.get(windowLabel);
|
|
const sessionChanged = previous?.downloadId !== downloadId || previous.sessionId !== sessionId;
|
|
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 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) {
|
|
await sendPropertiesRemoved(payload.windowLabel, 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) => {
|
|
if (disposed) return;
|
|
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 (disposed) return;
|
|
const registration = windows.get(request.windowLabel);
|
|
if (!registration
|
|
|| registration.downloadId !== request.downloadId
|
|
|| registration.sessionId !== request.sessionId) {
|
|
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');
|
|
|
|
switch (request.action) {
|
|
case 'apply-properties': {
|
|
const rawPatch = (request.payload ?? {}) as PropertiesPatch;
|
|
const safePatch = copyEditablePatch(rawPatch);
|
|
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);
|
|
}
|
|
await store.applyProperties(request.downloadId, safePatch);
|
|
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;
|
|
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;
|
|
case 'set-torrent-upload-limit':
|
|
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 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 (disposed) return;
|
|
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, {
|
|
windowLabel: request.windowLabel,
|
|
downloadId: request.downloadId,
|
|
sessionId: request.sessionId,
|
|
requestId: request.requestId,
|
|
ok,
|
|
...(error ? { error } : {}),
|
|
});
|
|
} catch {
|
|
// The window may have closed between the request and its result.
|
|
return;
|
|
}
|
|
};
|
|
|
|
const handleAction = async (request: PropertiesActionRequest) => {
|
|
if (disposed) return;
|
|
const actionKey = `${request.windowLabel}:${request.downloadId}`;
|
|
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 {
|
|
// Stale renderer actions are deliberately ignored. The current child
|
|
// session cannot safely consume a result for a superseded renderer.
|
|
return;
|
|
}
|
|
|
|
const registration = windows.get(request.windowLabel);
|
|
if (!registration) return;
|
|
if (!shouldAcceptPropertiesActionRequest(registration, request)) return;
|
|
registration.latestRequestId = request.requestId;
|
|
|
|
// 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.
|
|
await enqueuePropertiesAction(actionChains, actionKey, () => processAction(request));
|
|
};
|
|
|
|
attachAsyncPropertiesListener(
|
|
listen<PropertiesWindowReady>(PROPERTIES_WINDOW_READY, event => {
|
|
if (!disposed) void handleReady(event.payload);
|
|
}),
|
|
() => disposed,
|
|
value => { unlistenReady = value; },
|
|
);
|
|
attachAsyncPropertiesListener(
|
|
listen<PropertiesActionRequest>(PROPERTIES_WINDOW_ACTION_REQUEST, event => {
|
|
if (!disposed) void handleAction(event.payload);
|
|
}),
|
|
() => 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);
|
|
snapshotCoalescer.cancel(event.payload);
|
|
if (registration) actionsInFlight.delete(`${event.payload}:${registration.downloadId}`);
|
|
}),
|
|
() => 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);
|
|
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) {
|
|
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?.();
|
|
};
|
|
}, []);
|
|
|
|
return null;
|
|
};
|