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
+183 -13
View File
@@ -1,5 +1,9 @@
import { emitTo } from '@tauri-apps/api/event';
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
import type { DownloadStatus } from './bindings/DownloadStatus';
import type { DownloadItem } from './store/useDownloadStore';
import { canPauseDownload } from './utils/downloadActions';
import type { DocumentAppearance } from './utils/documentAppearance';
import { invokeCommand as invoke } from './ipc';
export const PROPERTIES_WINDOW_READY = 'properties-window-ready' as const;
@@ -9,11 +13,77 @@ export const PROPERTIES_WINDOW_ACTION_RESULT = 'properties-window-action-result'
export const PROPERTIES_WINDOW_REMOVED = 'properties-window-removed' as const;
export const PROPERTIES_WINDOW_CLOSED = 'properties-window-closed' as const;
export type PropertiesSnapshot = Omit<DownloadItem, 'password' | 'cookies' | 'headers' | 'username'> & {
const PROPERTIES_SNAPSHOT_KEYS = [
'id',
'url',
'fileName',
'status',
'fraction',
'speed',
'eta',
'size',
'downloadedBytes',
'totalBytes',
'totalIsEstimate',
'category',
'dateAdded',
'resumable',
'connections',
'speedLimit',
'checksum',
'destination',
'isMedia',
'mediaFormatSelector',
'mediaQuality',
'queueId',
'queuePosition',
'hasBeenDispatched',
'lastError',
'lastTry',
'isTorrent',
'torrentFileIndices',
'torrentInfoHash',
'torrentSeedTime',
'torrentSeedRatio',
'torrentSeedRemaining',
'torrentUploadedBytes',
'torrentSeededSeconds',
'torrentRelocationCheckPending',
'torrentMoveDestination',
'torrentMoveRestoreStatus',
'torrentWebSeeds',
'torrentUploadLimit',
'torrentMaxPeers',
'torrentPeerSpeedLimit',
'torrentCheckIntegrity',
'torrentTrackers',
'torrentExcludeTrackers',
'torrentTrackerConnectTimeout',
'torrentTrackerTimeout',
'torrentTrackerInterval',
'torrentStopTimeout',
'torrentPrioritizePiece',
'torrentRemoveUnselectedFile',
'torrentEncryptionPolicy',
'torrentFileAllocation',
'torrentVerifyOnly',
'torrentVerifyRestoreStatus',
] as const satisfies readonly (keyof DownloadItem)[];
type SafePropertiesFields = Pick<DownloadItem, (typeof PROPERTIES_SNAPSHOT_KEYS)[number]>;
export type PropertiesSnapshot = SafePropertiesFields & {
appearance: DocumentAppearance;
activeConnections?: number;
requestedConnections?: number;
uploadSpeed?: string;
torrentSeeders?: number;
moveProgress?: number;
hasPassword: boolean;
hasCookies: boolean;
hasHeaders: boolean;
hasUsername: boolean;
hasMirrors: boolean;
};
export type SecretPatch =
@@ -31,10 +101,39 @@ export type PropertiesPatch = Partial<Omit<DownloadItem, 'password' | 'cookies'
export type PropertiesAction =
| 'apply-properties'
| 'pause-resume'
| 'verify-torrent'
| 'set-download-limit'
| 'set-torrent-upload-limit'
| 'set-torrent-peer-options';
export type PropertiesLifecycleAction = 'pause' | 'resume' | 'start' | 'retry';
export const getPropertiesLifecycleAction = (
status: DownloadStatus,
): PropertiesLifecycleAction | null => {
if (status === 'ready' || status === 'staged') return 'start';
if (canPauseDownload(status)) return 'pause';
if (status === 'paused') return 'resume';
if (status === 'failed') return 'retry';
return null;
};
export const beginExclusivePropertiesAction = (
inFlight: Set<string>,
key: string,
): (() => void) => {
if (inFlight.has(key)) {
throw new Error('Another Properties action is still in progress');
}
inFlight.add(key);
let released = false;
return () => {
if (released) return;
released = true;
inFlight.delete(key);
};
};
export type PropertiesWindowReady = {
windowLabel: string;
downloadId: string;
@@ -63,25 +162,96 @@ export type PropertiesSnapshotEvent = {
snapshot: PropertiesSnapshot;
};
const copyWithoutSecrets = (item: DownloadItem): PropertiesSnapshot => {
const {
password,
cookies,
headers,
username,
...safeItem
} = item;
const copyWithoutSecrets = (
item: DownloadItem,
appearance: DocumentAppearance,
live?: {
progress?: DownloadProgressEvent;
moveProgress?: number;
},
): PropertiesSnapshot => {
const safeItem = Object.fromEntries(
PROPERTIES_SNAPSHOT_KEYS.flatMap(key => (
Object.prototype.hasOwnProperty.call(item, key) ? [[key, item[key]]] : []
)),
) as SafePropertiesFields;
return {
...safeItem,
hasPassword: Boolean(password),
hasCookies: Boolean(cookies),
hasHeaders: Boolean(headers),
hasUsername: Boolean(username),
appearance,
...(live?.progress ? {
fraction: live.progress.fraction,
speed: item.status === 'seeding'
? live.progress.upload_speed ?? live.progress.speed
: live.progress.speed,
eta: item.status === 'seeding' ? '-' : live.progress.eta,
...(live.progress.size ? { size: live.progress.size } : {}),
...(live.progress.downloaded_bytes !== undefined
? { downloadedBytes: live.progress.downloaded_bytes }
: {}),
...(live.progress.total_bytes !== undefined
? { totalBytes: live.progress.total_bytes }
: {}),
...(live.progress.total_is_estimate !== undefined
? { totalIsEstimate: live.progress.total_is_estimate }
: {}),
...(live.progress.active_connections !== undefined
? { activeConnections: live.progress.active_connections }
: {}),
...(live.progress.requested_connections !== undefined
? { requestedConnections: live.progress.requested_connections }
: {}),
...(live.progress.uploaded_bytes !== undefined
? { torrentUploadedBytes: live.progress.uploaded_bytes }
: {}),
...(live.progress.upload_speed !== undefined
? { uploadSpeed: live.progress.upload_speed }
: {}),
...(live.progress.num_seeders !== undefined
? { torrentSeeders: live.progress.num_seeders }
: {}),
...(live.progress.torrent_seeded_seconds !== undefined
? { torrentSeededSeconds: live.progress.torrent_seeded_seconds }
: {}),
} : {}),
...(live?.moveProgress !== undefined ? { moveProgress: live.moveProgress } : {}),
hasPassword: Boolean(item.password),
hasCookies: Boolean(item.cookies),
hasHeaders: Boolean(item.headers),
hasUsername: Boolean(item.username),
hasMirrors: Boolean(item.mirrors),
};
};
export const sanitizePropertiesSnapshot = copyWithoutSecrets;
export const createFrameCoalescer = (
callback: (key: string) => void,
requestFrame: (callback: FrameRequestCallback) => number,
cancelFrame: (handle: number) => void,
) => {
const pending = new Map<string, number>();
return {
schedule(key: string) {
if (pending.has(key)) return;
const handle = requestFrame(() => {
pending.delete(key);
callback(key);
});
pending.set(key, handle);
},
cancel(key: string) {
const handle = pending.get(key);
if (handle === undefined) return;
pending.delete(key);
cancelFrame(handle);
},
cancelAll() {
for (const handle of pending.values()) cancelFrame(handle);
pending.clear();
},
};
};
export const openPropertiesWindow = (downloadId: string): Promise<string> =>
invoke('open_download_properties_window', { id: downloadId });