From e9de193c9e2936844ac27dea3a3d244a6aee6a92 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 22 Aug 2026 01:47:13 +0330 Subject: [PATCH] fix(properties): route live controls through native mutations - route active normal and Torrent-safe fields to dedicated live consumers - fence unsupported statuses and mixed persisted patches at the bridge - add regression coverage for active status and media boundaries --- src/components/PropertiesWindowApp.tsx | 77 ++++++++++++------- src/components/PropertiesWindowBridgeHost.tsx | 63 ++++++++++++++- src/propertiesBridge.test.ts | 37 ++++++++- 3 files changed, 148 insertions(+), 29 deletions(-) diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index dbc50d0..0ab184f 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -73,6 +73,8 @@ const isTorrentDiagnosticsStatus = (status: string) => const isTorrentPollingStatus = isTorrentLiveStatus; const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'moving'].includes(status); +const isLiveNormalSpeedStatus = (status: string) => ['downloading', 'retrying'].includes(status); +const isLiveTorrentControlStatus = (status: string) => ['downloading', 'seeding', 'retrying'].includes(status); const isTorrentFileSelectionEditable = (status: string) => ['ready', 'staged', 'queued', 'paused', 'failed'].includes(status); @@ -932,7 +934,15 @@ export const PropertiesWindowApp = () => { }; const applyActiveTab = useCallback(async () => { - if (!snapshot || !isEditableStatus(snapshot.status)) { + if (!snapshot) return; + const canApplyLiveNormalSpeed = snapshot.isMedia !== true + && snapshot.isTorrent !== true + && isLiveNormalSpeedStatus(snapshot.status); + const canApplyLiveTorrentOptions = snapshot.isTorrent === true + && isLiveTorrentControlStatus(snapshot.status); + if (!isEditableStatus(snapshot.status) + && !((activeTab === 'transfer' && canApplyLiveNormalSpeed) + || (activeTab === 'options' && canApplyLiveTorrentOptions))) { closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; setErrorMessage(t($ => $.properties.editingUnavailable)); @@ -982,13 +992,16 @@ export const PropertiesWindowApp = () => { patch.torrentTrackerInterval = encodePropertiesPatchValue(trackerInterval.trim() ? Number(trackerInterval) : undefined); } } else if (activeTab === 'options' || activeTab === 'transfer') { - if (downloadLimit !== (snapshot.speedLimit ?? '')) patch.speedLimit = encodePropertiesPatchValue(downloadLimit.trim() ? downloadLimit : undefined); - if (activeTab === 'transfer' && !isTorrent && connections.trim()) { + const canApplyLiveTorrentSpeed = !isTorrent || isLiveNormalSpeedStatus(snapshot.status); + if (downloadLimit !== (snapshot.speedLimit ?? '') && canApplyLiveTorrentSpeed) { + patch.speedLimit = encodePropertiesPatchValue(downloadLimit.trim() ? downloadLimit : undefined); + } + if (isEditableStatus(snapshot.status) && activeTab === 'transfer' && !isTorrent && connections.trim()) { const nextConnections = Number(connections); if (nextConnections !== snapshot.connections) patch.connections = nextConnections; } if (isTorrent) { - if (removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0)) { + if (isEditableStatus(snapshot.status) && removeUnselectedFile && (!snapshot.torrentFileIndices || snapshot.torrentFileIndices.length === 0)) { setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)); closeAfterSaveRef.current = false; switchAfterSaveRef.current = null; @@ -999,22 +1012,24 @@ export const PropertiesWindowApp = () => { patch.torrentMaxPeers = encodePropertiesPatchValue(maxPeers.trim() ? Number(maxPeers) : undefined); } if (peerSpeedLimit !== (snapshot.torrentPeerSpeedLimit ?? '')) patch.torrentPeerSpeedLimit = encodePropertiesPatchValue(peerSpeedLimit.trim() ? peerSpeedLimit : undefined); - if (seedTime !== String(snapshot.torrentSeedTime ?? '')) { - patch.torrentSeedTime = encodePropertiesPatchValue(seedTime.trim() ? Number(seedTime) : undefined); + if (isEditableStatus(snapshot.status)) { + if (seedTime !== String(snapshot.torrentSeedTime ?? '')) { + patch.torrentSeedTime = encodePropertiesPatchValue(seedTime.trim() ? Number(seedTime) : undefined); + } + if (seedRatio !== String(snapshot.torrentSeedRatio ?? '')) { + patch.torrentSeedRatio = encodePropertiesPatchValue(seedRatio.trim() ? Number(seedRatio) : undefined); + } + if (checkIntegrity !== (snapshot.torrentCheckIntegrity === true)) patch.torrentCheckIntegrity = checkIntegrity; + if (removeUnselectedFile !== (snapshot.torrentRemoveUnselectedFile === true)) patch.torrentRemoveUnselectedFile = removeUnselectedFile; + if (stopTimeout !== String(snapshot.torrentStopTimeout ?? '')) { + patch.torrentStopTimeout = encodePropertiesPatchValue(stopTimeout.trim() ? Number(stopTimeout) : undefined); + } + if (prioritizePiece !== (snapshot.torrentPrioritizePiece ?? '')) patch.torrentPrioritizePiece = encodePropertiesPatchValue(prioritizePiece.trim() || undefined); + const nextEncryptionPolicy = encryptionPolicy || undefined; + if (nextEncryptionPolicy !== snapshot.torrentEncryptionPolicy) patch.torrentEncryptionPolicy = encodePropertiesPatchValue(nextEncryptionPolicy); + const nextFileAllocation = fileAllocation || undefined; + if (nextFileAllocation !== snapshot.torrentFileAllocation) patch.torrentFileAllocation = encodePropertiesPatchValue(nextFileAllocation); } - if (seedRatio !== String(snapshot.torrentSeedRatio ?? '')) { - patch.torrentSeedRatio = encodePropertiesPatchValue(seedRatio.trim() ? Number(seedRatio) : undefined); - } - if (checkIntegrity !== (snapshot.torrentCheckIntegrity === true)) patch.torrentCheckIntegrity = checkIntegrity; - if (removeUnselectedFile !== (snapshot.torrentRemoveUnselectedFile === true)) patch.torrentRemoveUnselectedFile = removeUnselectedFile; - if (stopTimeout !== String(snapshot.torrentStopTimeout ?? '')) { - patch.torrentStopTimeout = encodePropertiesPatchValue(stopTimeout.trim() ? Number(stopTimeout) : undefined); - } - if (prioritizePiece !== (snapshot.torrentPrioritizePiece ?? '')) patch.torrentPrioritizePiece = encodePropertiesPatchValue(prioritizePiece.trim() || undefined); - const nextEncryptionPolicy = encryptionPolicy || undefined; - if (nextEncryptionPolicy !== snapshot.torrentEncryptionPolicy) patch.torrentEncryptionPolicy = encodePropertiesPatchValue(nextEncryptionPolicy); - const nextFileAllocation = fileAllocation || undefined; - if (nextFileAllocation !== snapshot.torrentFileAllocation) patch.torrentFileAllocation = encodePropertiesPatchValue(nextFileAllocation); } } else if (activeTab === 'advanced') { if (isSftp && sftpHostKeyMd !== (snapshot.sftpHostKeyMd ?? '')) { @@ -1132,6 +1147,14 @@ export const PropertiesWindowApp = () => { } const editingEnabled = pendingAction === null && isEditableStatus(snapshot.status); + const liveNormalSpeedEnabled = pendingAction === null + && snapshot.isMedia !== true + && snapshot.isTorrent !== true + && isLiveNormalSpeedStatus(snapshot.status); + const liveTorrentOptionsEnabled = pendingAction === null + && snapshot.isTorrent === true + && isLiveTorrentControlStatus(snapshot.status); + const liveTorrentSpeedEnabled = liveTorrentOptionsEnabled && isLiveNormalSpeedStatus(snapshot.status); const identityEditingEnabled = editingEnabled && !isTorrent && ['ready', 'staged'].includes(snapshot.status); const torrentMoveAvailable = ['paused', 'completed', 'failed'].includes(snapshot.status); const progress = getPropertiesProgress(snapshot); @@ -1498,12 +1521,12 @@ export const PropertiesWindowApp = () => { $.properties.speedCap)} controlId="properties-transfer-speed-cap" - hint={t($ => $.properties.speedLimitHint)} + hint={liveNormalSpeedEnabled ? t($ => $.properties.liveSpeedLimitHint) : t($ => $.properties.speedLimitHint)} meta={downloadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} className="max-w-sm" format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })} > - { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} /> + { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!(editingEnabled || liveNormalSpeedEnabled)} /> {

{t($ => $.properties.liveTorrentPeerOptions)}

-

{t($ => $.properties.speedLimitHint)}

+

{t($ => $.properties.liveTorrentPeerOptionsHint)}

$.properties.speedCap)} controlId="properties-options-speed-cap" - hint={t($ => $.properties.speedLimitHint)} + hint={liveTorrentSpeedEnabled ? t($ => $.properties.liveSpeedLimitHint) : t($ => $.properties.speedLimitHint)} meta={downloadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })} > - { setDownloadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} /> + { setDownloadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!(editingEnabled || liveTorrentSpeedEnabled)} /> $.properties.liveTorrentUploadLimit)} @@ -1544,7 +1567,7 @@ export const PropertiesWindowApp = () => { meta={uploadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })} > - { setUploadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} /> + { setUploadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.liveTorrentUploadLimitPlaceholder)} disabled={!(editingEnabled || liveTorrentOptionsEnabled)} /> $.properties.torrentMaxPeers)} @@ -1553,7 +1576,7 @@ export const PropertiesWindowApp = () => { meta={maxPeers.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatMaxPeers) })} > - { setMaxPeers(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={t($ => $.properties.inputExampleMaxPeers)} disabled={!editingEnabled} /> + { setMaxPeers(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={t($ => $.properties.inputExampleMaxPeers)} disabled={!(editingEnabled || liveTorrentOptionsEnabled)} /> $.properties.torrentPeerSpeedLimit)} @@ -1562,7 +1585,7 @@ export const PropertiesWindowApp = () => { meta={peerSpeedLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })} > - { setPeerSpeedLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} /> + { setPeerSpeedLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!(editingEnabled || liveTorrentOptionsEnabled)} />
diff --git a/src/components/PropertiesWindowBridgeHost.tsx b/src/components/PropertiesWindowBridgeHost.tsx index 4349f17..9180560 100644 --- a/src/components/PropertiesWindowBridgeHost.tsx +++ b/src/components/PropertiesWindowBridgeHost.tsx @@ -205,6 +205,33 @@ export const copyEditablePropertiesPatch = ( return safePatch; }; +const LIVE_PROPERTIES_STATUSES = new Set(['downloading', 'seeding', 'retrying']); +const LIVE_PROPERTIES_KEYS = new Set([ + 'speedLimit', + 'torrentUploadLimit', + 'torrentMaxPeers', + 'torrentPeerSpeedLimit', +]); + +/** + * Active transfers may change only the controls whose native consumers expose + * an in-place Aria2 mutation. Keep this check at the main-webview boundary so + * an active Properties save cannot enter applyPropertiesInternal and detach a + * live lifecycle before being rejected by its status gate. + */ +export const isLivePropertiesPatch = ( + item: Pick, + patch: Partial, +): boolean => { + if (!LIVE_PROPERTIES_STATUSES.has(item.status) || item.isMedia === true) return false; + const keys = Object.keys(patch) as Array; + if (item.isTorrent !== true) { + return keys.every(key => key === 'speedLimit' && ['downloading', 'retrying'].includes(item.status)); + } + return keys.every(key => key !== 'speedLimit' || ['downloading', 'retrying'].includes(item.status)) + && keys.every(key => LIVE_PROPERTIES_KEYS.has(key)); +}; + export const PropertiesWindowBridgeHost = () => { useEffect(() => { const mainWindowTarget = propertiesWindowEventTarget(getCurrentWindow().label); @@ -435,7 +462,41 @@ export const PropertiesWindowBridgeHost = () => { throw new Error('Generic connection settings are not available for Torrent downloads'); } await assertCurrentAction(request); - await store.applyProperties(request.downloadId, safePatch); + if (LIVE_PROPERTIES_STATUSES.has(item.status)) { + if (!isLivePropertiesPatch(item, safePatch)) { + throw new Error(i18n.t($ => $.downloadTable.transferActive)); + } + if (Object.prototype.hasOwnProperty.call(safePatch, 'speedLimit')) { + await store.setDownloadSpeedLimit( + request.downloadId, + safePatch.speedLimit ?? null, + ); + } + if (item.isTorrent === true + && Object.prototype.hasOwnProperty.call(safePatch, 'torrentUploadLimit')) { + await store.setTorrentUploadLimit( + request.downloadId, + safePatch.torrentUploadLimit ?? null, + ); + } + if (item.isTorrent === true + && (Object.prototype.hasOwnProperty.call(safePatch, 'torrentMaxPeers') + || Object.prototype.hasOwnProperty.call(safePatch, 'torrentPeerSpeedLimit'))) { + const maxPeers = Object.prototype.hasOwnProperty.call(safePatch, 'torrentMaxPeers') + ? safePatch.torrentMaxPeers == null ? null : String(safePatch.torrentMaxPeers) + : item.torrentMaxPeers == null ? null : String(item.torrentMaxPeers); + const peerSpeedLimit = Object.prototype.hasOwnProperty.call(safePatch, 'torrentPeerSpeedLimit') + ? safePatch.torrentPeerSpeedLimit ?? null + : item.torrentPeerSpeedLimit ?? null; + await store.setTorrentPeerOptions( + request.downloadId, + maxPeers, + peerSpeedLimit, + ); + } + } else { + await store.applyProperties(request.downloadId, safePatch); + } break; } case 'set-torrent-file-selection': { diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index c8ab40d..9fd22a3 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -34,7 +34,7 @@ import { sendPropertiesSnapshot, shouldAcceptPropertiesActionRequest, } from './propertiesBridge'; -import { copyEditablePropertiesPatch } from './components/PropertiesWindowBridgeHost'; +import { copyEditablePropertiesPatch, isLivePropertiesPatch } from './components/PropertiesWindowBridgeHost'; describe('Properties window bridge', () => { it('keeps optional override resets explicit across the JSON IPC boundary', () => { @@ -415,6 +415,41 @@ describe('Properties window bridge', () => { })).toThrow('read-only'); }); + it('allows only native live controls for active Properties saves', () => { + expect(isLivePropertiesPatch( + { isMedia: false, isTorrent: false, status: 'downloading' }, + { speedLimit: '2M' }, + )).toBe(true); + expect(isLivePropertiesPatch( + { isMedia: false, isTorrent: true, status: 'seeding' }, + { torrentUploadLimit: '1M', torrentMaxPeers: 120, torrentPeerSpeedLimit: '256K' }, + )).toBe(true); + expect(isLivePropertiesPatch( + { isMedia: false, isTorrent: true, status: 'seeding' }, + { speedLimit: '2M' }, + )).toBe(false); + expect(isLivePropertiesPatch( + { isMedia: false, isTorrent: true, status: 'verifying' }, + { torrentUploadLimit: '1M' }, + )).toBe(false); + expect(isLivePropertiesPatch( + { isMedia: false, isTorrent: true, status: 'waitingToSeed' }, + { torrentMaxPeers: 120 }, + )).toBe(false); + expect(isLivePropertiesPatch( + { isMedia: false, isTorrent: true, status: 'downloading' }, + { torrentTrackers: 'https://tracker.example/announce' }, + )).toBe(false); + expect(isLivePropertiesPatch( + { isMedia: true, isTorrent: false, status: 'downloading' }, + { speedLimit: '2M' }, + )).toBe(false); + expect(isLivePropertiesPatch( + { isMedia: false, isTorrent: false, status: 'paused' }, + { speedLimit: '2M' }, + )).toBe(false); + }); + it('keeps Torrent peer-cap telemetry distinct from generic connections', () => { expect(propertiesTorrentPeerLimit(undefined)).toBe(55); expect(propertiesTorrentPeerLimit(120)).toBe(120);