mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-02 05:57:59 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user