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
This commit is contained in:
NimBold
2026-08-22 01:47:13 +03:30
parent 3a740db2f2
commit e9de193c9e
3 changed files with 148 additions and 29 deletions
+50 -27
View File
@@ -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 = () => {
<PropertiesField
label={t($ => $.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) })}
>
<input id="properties-transfer-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
<input id="properties-transfer-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!(editingEnabled || liveNormalSpeedEnabled)} />
</PropertiesField>
<PropertiesField
label={connectionControlLabel}
@@ -1524,18 +1547,18 @@ export const PropertiesWindowApp = () => {
<div className="properties-option-group-heading">
<div>
<h2 id="properties-options-limits-heading">{t($ => $.properties.liveTorrentPeerOptions)}</h2>
<p>{t($ => $.properties.speedLimitHint)}</p>
<p>{t($ => $.properties.liveTorrentPeerOptionsHint)}</p>
</div>
</div>
<div className="grid max-w-4xl gap-4 sm:grid-cols-2">
<PropertiesField
label={t($ => $.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) })}
>
<input id="properties-options-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
<input id="properties-options-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!(editingEnabled || liveTorrentSpeedEnabled)} />
</PropertiesField>
<PropertiesField
label={t($ => $.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) })}
>
<input id="properties-options-upload-limit" className="app-control w-full" value={uploadLimit} onChange={event => { setUploadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
<input id="properties-options-upload-limit" className="app-control w-full" value={uploadLimit} onChange={event => { setUploadLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.liveTorrentUploadLimitPlaceholder)} disabled={!(editingEnabled || liveTorrentOptionsEnabled)} />
</PropertiesField>
<PropertiesField
label={t($ => $.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) })}
>
<input id="properties-options-max-peers" className="app-control w-full" value={maxPeers} onChange={event => { setMaxPeers(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={t($ => $.properties.inputExampleMaxPeers)} disabled={!editingEnabled} />
<input id="properties-options-max-peers" className="app-control w-full" value={maxPeers} onChange={event => { setMaxPeers(event.target.value); setDraftTab('options'); }} inputMode="numeric" placeholder={t($ => $.properties.inputExampleMaxPeers)} disabled={!(editingEnabled || liveTorrentOptionsEnabled)} />
</PropertiesField>
<PropertiesField
label={t($ => $.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) })}
>
<input id="properties-options-peer-speed-limit" className="app-control w-full" value={peerSpeedLimit} onChange={event => { setPeerSpeedLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
<input id="properties-options-peer-speed-limit" className="app-control w-full" value={peerSpeedLimit} onChange={event => { setPeerSpeedLimit(event.target.value); setDraftTab('options'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!(editingEnabled || liveTorrentOptionsEnabled)} />
</PropertiesField>
</div>
</section>
+62 -1
View File
@@ -205,6 +205,33 @@ export const copyEditablePropertiesPatch = (
return safePatch;
};
const LIVE_PROPERTIES_STATUSES = new Set(['downloading', 'seeding', 'retrying']);
const LIVE_PROPERTIES_KEYS = new Set<keyof PropertiesPatch>([
'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<DownloadItem, 'isMedia' | 'isTorrent' | 'status'>,
patch: Partial<DownloadItem>,
): boolean => {
if (!LIVE_PROPERTIES_STATUSES.has(item.status) || item.isMedia === true) return false;
const keys = Object.keys(patch) as Array<keyof PropertiesPatch>;
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': {
+36 -1
View File
@@ -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);