feat(torrents): add encryption policy

This commit is contained in:
NimBold
2026-08-02 10:05:39 +03:30
parent b4da68655a
commit 1d27b5b0bf
19 changed files with 378 additions and 18 deletions
+10 -9
View File
@@ -37,6 +37,10 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar
- Optional `bt-prioritize-piece` preview policy for the head, tail, or both - Optional `bt-prioritize-piece` preview policy for the head, tail, or both
ends of every selected file. The constrained policy is validated, persisted, ends of every selected file. The constrained policy is validated, persisted,
normalized, and reapplied when a Torrent starts or retries. normalized, and reapplied when a Torrent starts or retries.
- One validated Torrent encryption policy mapped to Aria2's
`bt-force-encryption`, `bt-require-crypto`, and `bt-min-crypto-level`:
disabled, required obfuscated handshake, or forced ARC4 payload encryption.
The policy is persisted and reapplied when a Torrent starts or retries.
- Optional `bt-remove-unselected-file` cleanup after completion when a - Optional `bt-remove-unselected-file` cleanup after completion when a
selected-file subset is configured. Firelink requires explicit confirmation, selected-file subset is configured. Firelink requires explicit confirmation,
reserves the unselected paths against competing downloads, keeps those reserves the unselected paths against competing downloads, keeps those
@@ -44,9 +48,9 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar
after observing Aria2's completion cleanup (or on terminal failure, after observing Aria2's completion cleanup (or on terminal failure,
cancellation, or reconfiguration). cancellation, or reconfiguration).
- Deterministic local Aria2 smoke coverage for metadata resolution, selected - Deterministic local Aria2 smoke coverage for metadata resolution, selected
output, piece priority, pause/resume, ownership, cancellation/removal, output, piece priority, encryption policy, pause/resume, ownership,
unavailable trackers, daemon failure, and `bt-stop-timeout` terminal behavior; cancellation/removal, unavailable trackers, daemon failure, and
RPC-boundary coverage is separate. `bt-stop-timeout` terminal behavior; RPC-boundary coverage is separate.
## Priority tiers for remaining work ## Priority tiers for remaining work
@@ -61,10 +65,7 @@ No remaining Tier 0 items.
per-file priority option. Firelink therefore does not pretend that per-file priority option. Firelink therefore does not pretend that
`select-file` is file priority; this remains pending an engine capability or `select-file` is file priority; this remains pending an engine capability or
a safe product-level model. a safe product-level model.
2. **Encryption policy** — expose `bt-force-encryption`, 2. **Tracker timing controls** — expose tracker connect timeout, request
`bt-require-crypto`, and `bt-min-crypto-level` as one validated policy so
users cannot accidentally select contradictory combinations.
3. **Tracker timing controls** — expose tracker connect timeout, request
timeout, and interval only when their effect on battery/network behavior is timeout, and interval only when their effect on battery/network behavior is
explained and persisted. explained and persisted.
@@ -79,5 +80,5 @@ No remaining Tier 0 items.
The first implementation in this task was remote `.torrent` metadata intake; The first implementation in this task was remote `.torrent` metadata intake;
follow-up implementations add stall-timeout control, bounded peer diagnostics, follow-up implementations add stall-timeout control, bounded peer diagnostics,
persisted tracker exclusion, piece-preview priority, and safe unselected-file persisted tracker exclusion, piece-preview priority, safe unselected-file
removal. removal, and the validated encryption policy.
+25 -1
View File
@@ -628,10 +628,11 @@ async function main() {
const probeDir = path.join(tempRoot, 'probe'); const probeDir = path.join(tempRoot, 'probe');
const finalDir = path.join(tempRoot, 'final'); const finalDir = path.join(tempRoot, 'final');
const integrityDir = path.join(tempRoot, 'integrity'); const integrityDir = path.join(tempRoot, 'integrity');
const encryptionDir = path.join(tempRoot, 'encryption');
const cancelDir = path.join(tempRoot, 'cancel'); const cancelDir = path.join(tempRoot, 'cancel');
const removeUnselectedDir = path.join(tempRoot, 'remove-unselected'); const removeUnselectedDir = path.join(tempRoot, 'remove-unselected');
const stallDir = path.join(tempRoot, 'stall'); const stallDir = path.join(tempRoot, 'stall');
for (const directory of [seedRoot, probeDir, finalDir, integrityDir, cancelDir, removeUnselectedDir, stallDir]) fs.mkdirSync(directory, { recursive: true }); for (const directory of [seedRoot, probeDir, finalDir, integrityDir, encryptionDir, cancelDir, removeUnselectedDir, stallDir]) fs.mkdirSync(directory, { recursive: true });
const seederListenPort = await findAvailablePort(); const seederListenPort = await findAvailablePort();
const clientListenPort = await findAvailablePort(); const clientListenPort = await findAvailablePort();
@@ -765,6 +766,29 @@ async function main() {
assert(fs.readFileSync(integrityPath).equals(torrent.files[0].data), 'integrity check did not replace corrupted torrent data'); assert(fs.readFileSync(integrityPath).equals(torrent.files[0].data), 'integrity check did not replace corrupted torrent data');
console.log('[OK] check-integrity detected and repaired corrupted Torrent data'); console.log('[OK] check-integrity detected and repaired corrupted Torrent data');
const encryptionGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
savedTorrentBytes.toString('base64'),
[],
{
dir: encryptionDir,
'select-file': '1',
'index-out': indexOut,
'bt-force-encryption': 'true',
'bt-require-crypto': 'true',
'bt-min-crypto-level': 'arc4',
'seed-time': '0',
'auto-file-renaming': 'false',
},
]);
const encryptionOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [encryptionGid]);
assert(encryptionOptions['bt-force-encryption'] === 'true', 'Aria2 did not retain force encryption');
assert(encryptionOptions['bt-require-crypto'] === 'true', 'Aria2 did not retain the crypto requirement');
assert(encryptionOptions['bt-min-crypto-level'] === 'arc4', 'Aria2 did not retain the ARC4 minimum crypto level');
await waitForTerminal(client, encryptionGid, 30000);
const encryptedPath = path.join(encryptionDir, torrent.name, 'selected.bin');
assert(fs.readFileSync(encryptedPath).equals(torrent.files[0].data), 'encrypted Torrent output content differs');
console.log('[OK] Torrent encryption policy retained all three Aria2 options and completed');
const removalSkippedPath = path.join(removeUnselectedDir, torrent.name, 'skipped.bin'); const removalSkippedPath = path.join(removeUnselectedDir, torrent.name, 'skipped.bin');
fs.mkdirSync(path.dirname(removalSkippedPath), { recursive: true }); fs.mkdirSync(path.dirname(removalSkippedPath), { recursive: true });
fs.writeFileSync(removalSkippedPath, Buffer.from('pre-existing file owned outside the Torrent\n')); fs.writeFileSync(removalSkippedPath, Buffer.from('pre-existing file owned outside the Torrent\n'));
+3
View File
@@ -209,6 +209,9 @@ pub struct DownloadItem {
#[serde(default)] #[serde(default)]
#[ts(optional)] #[ts(optional)]
pub torrent_remove_unselected_file: Option<bool>, pub torrent_remove_unselected_file: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_encryption_policy: Option<String>,
} }
#[derive(Clone, Debug, Serialize, TS)] #[derive(Clone, Debug, Serialize, TS)]
+3
View File
@@ -5806,6 +5806,9 @@ async fn validate_torrent_enqueue(
item.torrent_prioritize_piece = queue::normalize_torrent_prioritize_piece( item.torrent_prioritize_piece = queue::normalize_torrent_prioritize_piece(
item.torrent_prioritize_piece.as_deref(), item.torrent_prioritize_piece.as_deref(),
)?; )?;
item.torrent_encryption_policy = queue::normalize_torrent_encryption_policy(
item.torrent_encryption_policy.as_deref(),
)?;
validate_enqueue_uris("", item.mirrors.as_deref()).await?; validate_enqueue_uris("", item.mirrors.as_deref()).await?;
if let Some(path) = item.torrent_path.as_deref() { if let Some(path) = item.torrent_path.as_deref() {
let path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, path)?; let path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, path)?;
+147
View File
@@ -221,6 +221,7 @@ pub struct SpawnPayload {
pub torrent_stop_timeout: Option<u32>, pub torrent_stop_timeout: Option<u32>,
pub torrent_prioritize_piece: Option<String>, pub torrent_prioritize_piece: Option<String>,
pub torrent_remove_unselected_file: bool, pub torrent_remove_unselected_file: bool,
pub torrent_encryption_policy: Option<String>,
} }
/// A sidecar spawner. In production this calls the real aria2/yt-dlp /// A sidecar spawner. In production this calls the real aria2/yt-dlp
@@ -3506,6 +3507,23 @@ pub(crate) fn normalize_torrent_exclude_trackers(
normalize_torrent_tracker_list(value, true) normalize_torrent_tracker_list(value, true)
} }
pub(crate) fn normalize_torrent_encryption_policy(
value: Option<&str>,
) -> Result<Option<String>, String> {
let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
match raw {
"disabled" => Ok(None),
"require-crypto" => Ok(Some("require-crypto".to_string())),
"force-encryption" => Ok(Some("force-encryption".to_string())),
_ => Err(
"torrent encryption policy must be disabled, require-crypto, or force-encryption"
.to_string(),
),
}
}
fn apply_aria2_torrent_options( fn apply_aria2_torrent_options(
options: &mut serde_json::Map<String, serde_json::Value>, options: &mut serde_json::Map<String, serde_json::Value>,
payload: &SpawnPayload, payload: &SpawnPayload,
@@ -3514,6 +3532,30 @@ fn apply_aria2_torrent_options(
return Ok(()); return Ok(());
} }
let encryption_policy =
normalize_torrent_encryption_policy(payload.torrent_encryption_policy.as_deref())?;
let (force_encryption, require_crypto, min_crypto_level) =
match encryption_policy.as_deref() {
Some("require-crypto") => (false, true, "plain"),
Some("force-encryption") => (true, true, "arc4"),
None => (false, false, "plain"),
Some(policy) => {
return Err(format!("unsupported normalized Torrent encryption policy: {policy}"));
}
};
options.insert(
"bt-force-encryption".to_string(),
serde_json::json!(force_encryption.to_string()),
);
options.insert(
"bt-require-crypto".to_string(),
serde_json::json!(require_crypto.to_string()),
);
options.insert(
"bt-min-crypto-level".to_string(),
serde_json::json!(min_crypto_level),
);
let seed_time = payload let seed_time = payload
.torrent_seed_time .torrent_seed_time
.map(|value| format_aria2_torrent_number(value, "seed time")) .map(|value| format_aria2_torrent_number(value, "seed time"))
@@ -4224,6 +4266,9 @@ pub struct EnqueueItem {
pub torrent_remove_unselected_file: Option<bool>, pub torrent_remove_unselected_file: Option<bool>,
#[serde(default)] #[serde(default)]
#[ts(optional)] #[ts(optional)]
pub torrent_encryption_policy: Option<String>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>, pub lifecycle_generation: Option<String>,
} }
@@ -4279,6 +4324,7 @@ impl EnqueueItem {
torrent_remove_unselected_file: self torrent_remove_unselected_file: self
.torrent_remove_unselected_file .torrent_remove_unselected_file
.unwrap_or(false), .unwrap_or(false),
torrent_encryption_policy: self.torrent_encryption_policy,
}, },
} }
} }
@@ -4343,6 +4389,87 @@ mod tests {
assert_eq!(options.get("seed-time"), Some(&serde_json::json!("0"))); assert_eq!(options.get("seed-time"), Some(&serde_json::json!("0")));
assert!(!options.contains_key("max-upload-limit")); assert!(!options.contains_key("max-upload-limit"));
assert_eq!(
options.get("bt-force-encryption"),
Some(&serde_json::json!("false"))
);
assert_eq!(
options.get("bt-require-crypto"),
Some(&serde_json::json!("false"))
);
assert_eq!(
options.get("bt-min-crypto-level"),
Some(&serde_json::json!("plain"))
);
}
#[test]
fn torrent_encryption_policy_maps_to_one_consistent_aria2_policy() {
let cases = [
(
Some("require-crypto"),
("false", "true", "plain"),
),
(
Some("force-encryption"),
("true", "true", "arc4"),
),
(None, ("false", "false", "plain")),
];
for (policy, expected) in cases {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_encryption_policy: policy.map(str::to_string),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-force-encryption"),
Some(&serde_json::json!(expected.0))
);
assert_eq!(
options.get("bt-require-crypto"),
Some(&serde_json::json!(expected.1))
);
assert_eq!(
options.get("bt-min-crypto-level"),
Some(&serde_json::json!(expected.2))
);
}
}
#[test]
fn torrent_encryption_policy_rejects_unknown_values() {
assert_eq!(normalize_torrent_encryption_policy(None).unwrap(), None);
assert_eq!(
normalize_torrent_encryption_policy(Some(" disabled ")).unwrap(),
None
);
assert_eq!(
normalize_torrent_encryption_policy(Some("require-crypto")).unwrap(),
Some("require-crypto".to_string())
);
assert!(normalize_torrent_encryption_policy(Some("arc4")).is_err());
assert!(normalize_torrent_encryption_policy(Some("true")).is_err());
}
#[test]
fn torrent_encryption_policy_is_not_applied_to_non_torrent_payloads() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
torrent_encryption_policy: Some("force-encryption".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert!(!options.contains_key("bt-force-encryption"));
assert!(!options.contains_key("bt-require-crypto"));
assert!(!options.contains_key("bt-min-crypto-level"));
} }
#[test] #[test]
@@ -4472,6 +4599,26 @@ mod tests {
assert!(item.into_task().payload.torrent_check_integrity); assert!(item.into_task().payload.torrent_check_integrity);
} }
#[test]
fn enqueue_item_preserves_torrent_encryption_policy() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "torrent-encryption",
"queue_id": "main",
"url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789",
"destination": "/tmp/downloads",
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_encryption_policy": "force-encryption"
}))
.expect("frontend enqueue payload should deserialize");
assert_eq!(
item.into_task().payload.torrent_encryption_policy.as_deref(),
Some("force-encryption")
);
}
#[test] #[test]
fn torrent_trackers_are_normalized_and_deduplicated() { fn torrent_trackers_are_normalized_and_deduplicated() {
assert_eq!( assert_eq!(
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus"; import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, }; export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, lifecycle_generation?: string, }; export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, lifecycle_generation?: string, };
+30 -1
View File
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc'; import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece } from '../utils/downloads'; import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import { import {
expandTilde, expandTilde,
@@ -234,6 +234,7 @@ export const AddDownloadsModal = () => {
const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState(''); const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState('');
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false); const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false); const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false);
const [torrentEncryptionPolicy, setTorrentEncryptionPolicy] = useState<TorrentEncryptionPolicy>(TORRENT_ENCRYPTION_POLICY_DISABLED);
const [torrentTrackers, setTorrentTrackers] = useState(''); const [torrentTrackers, setTorrentTrackers] = useState('');
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState(''); const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
@@ -1495,6 +1496,9 @@ export const AddDownloadsModal = () => {
torrentRemoveUnselectedFile: item.isTorrent && torrentRemoveUnselectedFile && hasPartialTorrentSelection(item) torrentRemoveUnselectedFile: item.isTorrent && torrentRemoveUnselectedFile && hasPartialTorrentSelection(item)
? true ? true
: undefined, : undefined,
torrentEncryptionPolicy: item.isTorrent && torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED
? torrentEncryptionPolicy
: undefined,
torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined, torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined,
torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : undefined, torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : undefined,
torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined, torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined,
@@ -2164,6 +2168,31 @@ export const AddDownloadsModal = () => {
</span> </span>
</span> </span>
</label> </label>
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-encryption-policy" className="text-text-muted">
{t($ => $.addDownloads.torrentEncryptionPolicy)}
</label>
<select
id="torrent-encryption-policy"
value={torrentEncryptionPolicy}
onChange={event => setTorrentEncryptionPolicy(event.currentTarget.value as TorrentEncryptionPolicy)}
aria-describedby="torrent-encryption-policy-hint"
className="app-control max-w-56 px-2 py-1 text-xs"
>
<option value={TORRENT_ENCRYPTION_POLICY_DISABLED}>
{t($ => $.addDownloads.torrentEncryptionDisabled)}
</option>
<option value={TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO}>
{t($ => $.addDownloads.torrentEncryptionRequireCrypto)}
</option>
<option value={TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION}>
{t($ => $.addDownloads.torrentEncryptionForceEncryption)}
</option>
</select>
<p id="torrent-encryption-policy-hint" className="col-span-2 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentEncryptionPolicyHint)}
</p>
</div>
<label className="flex items-start gap-2 text-text-primary pt-2 border-t border-border-modal/50"> <label className="flex items-start gap-2 text-text-primary pt-2 border-t border-border-modal/50">
<input <input
type="checkbox" type="checkbox"
+36 -1
View File
@@ -19,7 +19,7 @@ import {
formatDownloadTotal, formatDownloadTotal,
resolveDownloadSizeDisplay resolveDownloadSizeDisplay
} from '../utils/downloadProgress'; } from '../utils/downloadProgress';
import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, resolveDownloadConnections } from '../utils/downloads'; import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime'; import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus'; import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
@@ -88,6 +88,7 @@ export const PropertiesModal = () => {
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState(''); const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false); const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false); const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false);
const [torrentEncryptionPolicy, setTorrentEncryptionPolicy] = useState<TorrentEncryptionPolicy>(TORRENT_ENCRYPTION_POLICY_DISABLED);
const [torrentTrackers, setTorrentTrackers] = useState(''); const [torrentTrackers, setTorrentTrackers] = useState('');
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState(''); const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
@@ -193,6 +194,7 @@ export const PropertiesModal = () => {
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || ''); setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true); setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
setTorrentRemoveUnselectedFile(activeItem.torrentRemoveUnselectedFile === true); setTorrentRemoveUnselectedFile(activeItem.torrentRemoveUnselectedFile === true);
setTorrentEncryptionPolicy(normalizeTorrentEncryptionPolicy(activeItem.torrentEncryptionPolicy) || TORRENT_ENCRYPTION_POLICY_DISABLED);
setTorrentTrackers(activeItem.torrentTrackers || ''); setTorrentTrackers(activeItem.torrentTrackers || '');
setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || ''); setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || '');
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout)); setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
@@ -366,6 +368,10 @@ export const PropertiesModal = () => {
setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)); setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired));
return; return;
} }
if (item.isTorrent && !normalizeTorrentEncryptionPolicy(torrentEncryptionPolicy)) {
setErrorMessage(t($ => $.properties.torrentEncryptionPolicyInvalid));
return;
}
if ( if (
item.isTorrent item.isTorrent
&& torrentRemoveUnselectedFile && torrentRemoveUnselectedFile
@@ -398,6 +404,9 @@ export const PropertiesModal = () => {
torrentRemoveUnselectedFile: item.torrentFileIndices !== undefined torrentRemoveUnselectedFile: item.torrentFileIndices !== undefined
? torrentRemoveUnselectedFile ? torrentRemoveUnselectedFile
: undefined, : undefined,
torrentEncryptionPolicy: torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED
? torrentEncryptionPolicy
: undefined,
} }
: {}), : {}),
...(connectionsDirty ...(connectionsDirty
@@ -972,6 +981,32 @@ export const PropertiesModal = () => {
{t($ => $.properties.torrentPrioritizePieceHint)} {t($ => $.properties.torrentPrioritizePieceHint)}
</p> </p>
</div> </div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-encryption-policy-properties">
{t($ => $.properties.torrentEncryptionPolicy)}
</label>
<div>
<select
id="torrent-encryption-policy-properties"
value={torrentEncryptionPolicy}
onChange={event => setTorrentEncryptionPolicy(event.currentTarget.value as TorrentEncryptionPolicy)}
disabled={transferLocked}
aria-describedby="torrent-encryption-policy-properties-hint"
className="app-control max-w-56 px-2.5 py-1.5 text-xs disabled:opacity-50"
>
<option value={TORRENT_ENCRYPTION_POLICY_DISABLED}>
{t($ => $.properties.torrentEncryptionDisabled)}
</option>
<option value={TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO}>
{t($ => $.properties.torrentEncryptionRequireCrypto)}
</option>
<option value={TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION}>
{t($ => $.properties.torrentEncryptionForceEncryption)}
</option>
</select>
<p id="torrent-encryption-policy-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentEncryptionPolicyHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-check-integrity"> <label className="text-xs text-text-muted text-right" htmlFor="torrent-check-integrity">
{t($ => $.properties.torrentVerifyIntegrity)} {t($ => $.properties.torrentVerifyIntegrity)}
</label> </label>
+12
View File
@@ -268,6 +268,12 @@ const common = {
torrentPrioritizePiece: 'Prioritize Torrent pieces', torrentPrioritizePiece: 'Prioritize Torrent pieces',
torrentPrioritizePieceHint: 'Optional Aria2 preview policy: head, tail, or both; each may use a size such as 1M. Changes apply when the Torrent starts or retries.', torrentPrioritizePieceHint: 'Optional Aria2 preview policy: head, tail, or both; each may use a size such as 1M. Changes apply when the Torrent starts or retries.',
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M', torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
torrentEncryptionPolicy: 'Torrent encryption policy',
torrentEncryptionPolicyHint: 'Applied when this Torrent starts or retries. Choose one policy so the handshake and payload encryption settings stay consistent.',
torrentEncryptionDisabled: 'Disabled',
torrentEncryptionRequireCrypto: 'Require obfuscated handshake',
torrentEncryptionForceEncryption: 'Force encrypted payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Choose a valid Torrent encryption policy',
torrentRemoveUnselectedFile: 'Delete unselected Torrent files after completion', torrentRemoveUnselectedFile: 'Delete unselected Torrent files after completion',
torrentRemoveUnselectedFileHint: 'Only applies when a subset of files is selected. Aria2 permanently deletes the other files after the Torrent completes.', torrentRemoveUnselectedFileHint: 'Only applies when a subset of files is selected. Aria2 permanently deletes the other files after the Torrent completes.',
torrentRemoveUnselectedFileConfirm: 'Delete {{count}} unselected Torrent files after completion? This cannot be undone.', torrentRemoveUnselectedFileConfirm: 'Delete {{count}} unselected Torrent files after completion? This cannot be undone.',
@@ -534,6 +540,12 @@ const common = {
torrentPrioritizePiece: 'Prioritize Torrent pieces', torrentPrioritizePiece: 'Prioritize Torrent pieces',
torrentPrioritizePieceHint: 'Saved with this Torrent and applied on its next start or retry. Use head, tail, or both with optional K or M sizes.', torrentPrioritizePieceHint: 'Saved with this Torrent and applied on its next start or retry. Use head, tail, or both with optional K or M sizes.',
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M', torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
torrentEncryptionPolicy: 'Torrent encryption policy',
torrentEncryptionPolicyHint: 'Saved with this Torrent and applied on its next start or retry. The selected policy keeps Aria2 encryption settings consistent.',
torrentEncryptionDisabled: 'Disabled',
torrentEncryptionRequireCrypto: 'Require obfuscated handshake',
torrentEncryptionForceEncryption: 'Force encrypted payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Choose a valid Torrent encryption policy',
torrentRemoveUnselectedFile: 'Delete unselected Torrent files after completion', torrentRemoveUnselectedFile: 'Delete unselected Torrent files after completion',
torrentRemoveUnselectedFileHint: 'Only applies when a selected subset is configured. The unselected files are not Firelink-owned and are permanently removed when the Torrent completes.', torrentRemoveUnselectedFileHint: 'Only applies when a selected subset is configured. The unselected files are not Firelink-owned and are permanently removed when the Torrent completes.',
torrentRemoveUnselectedFileConfirm: 'Enable permanent deletion of unselected Torrent files after completion? This cannot be undone.', torrentRemoveUnselectedFileConfirm: 'Enable permanent deletion of unselected Torrent files after completion? This cannot be undone.',
+12
View File
@@ -268,6 +268,12 @@ const fa = {
torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت', torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت',
torrentPrioritizePieceHint: 'سیاست اختیاری پیش‌نمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام می‌توان اندازه‌ای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.', torrentPrioritizePieceHint: 'سیاست اختیاری پیش‌نمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام می‌توان اندازه‌ای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.',
torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد', torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
torrentEncryptionPolicyHint: 'هنگام شروع یا تلاش مجدد اعمال می‌شود. یک سیاست واحد انتخاب کنید تا تنظیمات handshake و رمزنگاری payload آریا۲ سازگار بمانند.',
torrentEncryptionDisabled: 'غیرفعال',
torrentEncryptionRequireCrypto: 'الزام handshake مبهم‌سازی‌شده',
torrentEncryptionForceEncryption: 'الزام payload رمزنگاری‌شده (ARC4)',
torrentEncryptionPolicyInvalid: 'یک سیاست معتبر برای رمزنگاری تورنت انتخاب کنید',
torrentRemoveUnselectedFile: 'حذف فایل‌های انتخاب‌نشده تورنت پس از تکمیل', torrentRemoveUnselectedFile: 'حذف فایل‌های انتخاب‌نشده تورنت پس از تکمیل',
torrentRemoveUnselectedFileHint: 'فقط وقتی اعمال می‌شود که زیرمجموعه‌ای از فایل‌ها انتخاب شده باشد. آریا۲ فایل‌های دیگر را پس از تکمیل تورنت برای همیشه حذف می‌کند.', torrentRemoveUnselectedFileHint: 'فقط وقتی اعمال می‌شود که زیرمجموعه‌ای از فایل‌ها انتخاب شده باشد. آریا۲ فایل‌های دیگر را پس از تکمیل تورنت برای همیشه حذف می‌کند.',
torrentRemoveUnselectedFileConfirm: '{{count}} فایل انتخاب‌نشده تورنت پس از تکمیل حذف شوند؟ این کار قابل بازگشت نیست.', torrentRemoveUnselectedFileConfirm: '{{count}} فایل انتخاب‌نشده تورنت پس از تکمیل حذف شوند؟ این کار قابل بازگشت نیست.',
@@ -534,6 +540,12 @@ const fa = {
torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت', torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت',
torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.', torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.',
torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد', torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
torrentEncryptionPolicyHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. سیاست انتخابی تنظیمات رمزنگاری آریا۲ را سازگار نگه می‌دارد.',
torrentEncryptionDisabled: 'غیرفعال',
torrentEncryptionRequireCrypto: 'الزام handshake مبهم‌سازی‌شده',
torrentEncryptionForceEncryption: 'الزام payload رمزنگاری‌شده (ARC4)',
torrentEncryptionPolicyInvalid: 'یک سیاست معتبر برای رمزنگاری تورنت انتخاب کنید',
torrentRemoveUnselectedFile: 'حذف فایل‌های انتخاب‌نشده تورنت پس از تکمیل', torrentRemoveUnselectedFile: 'حذف فایل‌های انتخاب‌نشده تورنت پس از تکمیل',
torrentRemoveUnselectedFileHint: 'فقط برای زیرمجموعه انتخاب‌شده اعمال می‌شود. فایل‌های انتخاب‌نشده متعلق به Firelink نیستند و هنگام تکمیل تورنت برای همیشه حذف می‌شوند.', torrentRemoveUnselectedFileHint: 'فقط برای زیرمجموعه انتخاب‌شده اعمال می‌شود. فایل‌های انتخاب‌نشده متعلق به Firelink نیستند و هنگام تکمیل تورنت برای همیشه حذف می‌شوند.',
torrentRemoveUnselectedFileConfirm: 'حذف دائمی فایل‌های انتخاب‌نشده تورنت پس از تکمیل فعال شود؟ این کار قابل بازگشت نیست.', torrentRemoveUnselectedFileConfirm: 'حذف دائمی فایل‌های انتخاب‌نشده تورنت پس از تکمیل فعال شود؟ این کار قابل بازگشت نیست.',
+12
View File
@@ -268,6 +268,12 @@ const he = {
torrentPrioritizePiece: 'תעדוף חלקי טורנט', torrentPrioritizePiece: 'תעדוף חלקי טורנט',
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.', torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M', torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
torrentEncryptionPolicyHint: 'מוחלת כשה-Torrent מתחיל או מנסה שוב. בחרו מדיניות אחת כדי לשמור על הגדרות handshake והצפנת payload עקביות.',
torrentEncryptionDisabled: 'מושבתת',
torrentEncryptionRequireCrypto: 'דרישת handshake מוסווה',
torrentEncryptionForceEncryption: 'כפיית payload מוצפן (ARC4)',
torrentEncryptionPolicyInvalid: 'בחרו מדיניות הצפנה תקפה ל-Torrent',
torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה', torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה',
torrentRemoveUnselectedFileHint: 'חל רק כאשר נבחרה קבוצת קבצים חלקית. Aria2 מוחק לצמיתות את שאר הקבצים לאחר השלמת ה-Torrent.', torrentRemoveUnselectedFileHint: 'חל רק כאשר נבחרה קבוצת קבצים חלקית. Aria2 מוחק לצמיתות את שאר הקבצים לאחר השלמת ה-Torrent.',
torrentRemoveUnselectedFileConfirm: 'למחוק {{count}} קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.', torrentRemoveUnselectedFileConfirm: 'למחוק {{count}} קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.',
@@ -534,6 +540,12 @@ const he = {
torrentPrioritizePiece: 'תעדוף חלקי טורנט', torrentPrioritizePiece: 'תעדוף חלקי טורנט',
torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.', torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.',
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M', torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
torrentEncryptionPolicyHint: 'נשמרת עם ה-Torrent ומוחלת בהפעלה או בניסיון חוזר. המדיניות שומרת על הגדרות ההצפנה של Aria2 עקביות.',
torrentEncryptionDisabled: 'מושבתת',
torrentEncryptionRequireCrypto: 'דרישת handshake מוסווה',
torrentEncryptionForceEncryption: 'כפיית payload מוצפן (ARC4)',
torrentEncryptionPolicyInvalid: 'בחרו מדיניות הצפנה תקפה ל-Torrent',
torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה', torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה',
torrentRemoveUnselectedFileHint: 'חל רק כאשר מוגדרת קבוצת קבצים חלקית. הקבצים שלא נבחרו אינם בבעלות Firelink ונמחקים לצמיתות כשה-Torrent מסתיים.', torrentRemoveUnselectedFileHint: 'חל רק כאשר מוגדרת קבוצת קבצים חלקית. הקבצים שלא נבחרו אינם בבעלות Firelink ונמחקים לצמיתות כשה-Torrent מסתיים.',
torrentRemoveUnselectedFileConfirm: 'להפעיל מחיקה לצמיתות של קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.', torrentRemoveUnselectedFileConfirm: 'להפעיל מחיקה לצמיתות של קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.',
+12
View File
@@ -268,6 +268,12 @@ const ru = {
torrentPrioritizePiece: 'Приоритет частей торрента', torrentPrioritizePiece: 'Приоритет частей торрента',
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.', torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M', torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
torrentEncryptionPolicy: 'Политика шифрования Torrent',
torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.',
torrentEncryptionDisabled: 'Отключено',
torrentEncryptionRequireCrypto: 'Требовать зашифрованное рукопожатие',
torrentEncryptionForceEncryption: 'Принудительно шифровать payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Выберите допустимую политику шифрования Torrent',
torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения', torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения',
torrentRemoveUnselectedFileHint: 'Применяется только при выборе части файлов. Aria2 навсегда удалит остальные файлы после завершения Torrent.', torrentRemoveUnselectedFileHint: 'Применяется только при выборе части файлов. Aria2 навсегда удалит остальные файлы после завершения Torrent.',
torrentRemoveUnselectedFileConfirm: 'Удалить {{count}} невыбранных файлов Torrent после завершения? Это действие нельзя отменить.', torrentRemoveUnselectedFileConfirm: 'Удалить {{count}} невыбранных файлов Torrent после завершения? Это действие нельзя отменить.',
@@ -534,6 +540,12 @@ const ru = {
torrentPrioritizePiece: 'Приоритет частей торрента', torrentPrioritizePiece: 'Приоритет частей торрента',
torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.', torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.',
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M', torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
torrentEncryptionPolicy: 'Политика шифрования Torrent',
torrentEncryptionPolicyHint: 'Сохраняется вместе с Torrent и применяется при следующем запуске или повторной попытке. Выбранная политика согласует параметры шифрования Aria2.',
torrentEncryptionDisabled: 'Отключено',
torrentEncryptionRequireCrypto: 'Требовать зашифрованное рукопожатие',
torrentEncryptionForceEncryption: 'Принудительно шифровать payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Выберите допустимую политику шифрования Torrent',
torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения', torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения',
torrentRemoveUnselectedFileHint: 'Применяется при настроенном выборе части файлов. Невыбранные файлы не принадлежат Firelink и навсегда удаляются после завершения Torrent.', torrentRemoveUnselectedFileHint: 'Применяется при настроенном выборе части файлов. Невыбранные файлы не принадлежат Firelink и навсегда удаляются после завершения Torrent.',
torrentRemoveUnselectedFileConfirm: 'Включить безвозвратное удаление невыбранных файлов Torrent после завершения? Это действие нельзя отменить.', torrentRemoveUnselectedFileConfirm: 'Включить безвозвратное удаление невыбранных файлов Torrent после завершения? Это действие нельзя отменить.',
+12
View File
@@ -268,6 +268,12 @@ const uk = {
torrentPrioritizePiece: 'Пріоритет частин торрента', torrentPrioritizePiece: 'Пріоритет частин торрента',
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.', torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M', torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
torrentEncryptionPolicy: 'Політика шифрування Torrent',
torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.',
torrentEncryptionDisabled: 'Вимкнено',
torrentEncryptionRequireCrypto: 'Вимагати зашифроване рукостискання',
torrentEncryptionForceEncryption: 'Примусово шифрувати payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Виберіть допустиму політику шифрування Torrent',
torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення', torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення',
torrentRemoveUnselectedFileHint: 'Застосовується лише після вибору частини файлів. Aria2 назавжди видалить решту файлів після завершення Torrent.', torrentRemoveUnselectedFileHint: 'Застосовується лише після вибору частини файлів. Aria2 назавжди видалить решту файлів після завершення Torrent.',
torrentRemoveUnselectedFileConfirm: 'Видалити {{count}} невибраних файлів Torrent після завершення? Цю дію не можна скасувати.', torrentRemoveUnselectedFileConfirm: 'Видалити {{count}} невибраних файлів Torrent після завершення? Цю дію не можна скасувати.',
@@ -534,6 +540,12 @@ const uk = {
torrentPrioritizePiece: 'Пріоритет частин торрента', torrentPrioritizePiece: 'Пріоритет частин торрента',
torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.', torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.',
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M', torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
torrentEncryptionPolicy: 'Політика шифрування Torrent',
torrentEncryptionPolicyHint: 'Зберігається разом із Torrent і застосовується під час наступного запуску або повторної спроби. Вибрана політика узгоджує параметри шифрування Aria2.',
torrentEncryptionDisabled: 'Вимкнено',
torrentEncryptionRequireCrypto: 'Вимагати зашифроване рукостискання',
torrentEncryptionForceEncryption: 'Примусово шифрувати payload (ARC4)',
torrentEncryptionPolicyInvalid: 'Виберіть допустиму політику шифрування Torrent',
torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення', torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення',
torrentRemoveUnselectedFileHint: 'Застосовується для налаштованого вибору частини файлів. Невибрані файли не належать Firelink і назавжди видаляються після завершення Torrent.', torrentRemoveUnselectedFileHint: 'Застосовується для налаштованого вибору частини файлів. Невибрані файли не належать Firelink і назавжди видаляються після завершення Torrent.',
torrentRemoveUnselectedFileConfirm: 'Увімкнути незворотне видалення невибраних файлів Torrent після завершення? Цю дію не можна скасувати.', torrentRemoveUnselectedFileConfirm: 'Увімкнути незворотне видалення невибраних файлів Torrent після завершення? Цю дію не можна скасувати.',
+12
View File
@@ -268,6 +268,12 @@ const zhCN = {
torrentPrioritizePiece: '优先下载 Torrent 片段', torrentPrioritizePiece: '优先下载 Torrent 片段',
torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。', torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小', torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
torrentEncryptionPolicy: 'Torrent 加密策略',
torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。',
torrentEncryptionDisabled: '已禁用',
torrentEncryptionRequireCrypto: '要求加密握手',
torrentEncryptionForceEncryption: '强制加密 payloadARC4',
torrentEncryptionPolicyInvalid: '请选择有效的 Torrent 加密策略',
torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件', torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件',
torrentRemoveUnselectedFileHint: '仅在选择了部分文件时生效。Torrent 完成后,Aria2 会永久删除其余文件。', torrentRemoveUnselectedFileHint: '仅在选择了部分文件时生效。Torrent 完成后,Aria2 会永久删除其余文件。',
torrentRemoveUnselectedFileConfirm: '完成后删除 {{count}} 个未选中的 Torrent 文件?此操作无法撤销。', torrentRemoveUnselectedFileConfirm: '完成后删除 {{count}} 个未选中的 Torrent 文件?此操作无法撤销。',
@@ -534,6 +540,12 @@ const zhCN = {
torrentPrioritizePiece: '优先下载 Torrent 片段', torrentPrioritizePiece: '优先下载 Torrent 片段',
torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。', torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。',
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小', torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
torrentEncryptionPolicy: 'Torrent 加密策略',
torrentEncryptionPolicyHint: '随 Torrent 保存,并在下次启动或重试时应用。所选策略会保持 Aria2 加密设置一致。',
torrentEncryptionDisabled: '已禁用',
torrentEncryptionRequireCrypto: '要求加密握手',
torrentEncryptionForceEncryption: '强制加密 payloadARC4',
torrentEncryptionPolicyInvalid: '请选择有效的 Torrent 加密策略',
torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件', torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件',
torrentRemoveUnselectedFileHint: '仅适用于配置了部分文件选择的 Torrent。未选中的文件不属于 Firelink,并会在 Torrent 完成后永久删除。', torrentRemoveUnselectedFileHint: '仅适用于配置了部分文件选择的 Torrent。未选中的文件不属于 Firelink,并会在 Torrent 完成后永久删除。',
torrentRemoveUnselectedFileConfirm: '启用完成后永久删除未选中的 Torrent 文件?此操作无法撤销。', torrentRemoveUnselectedFileConfirm: '启用完成后永久删除未选中的 Torrent 文件?此操作无法撤销。',
+5 -1
View File
@@ -858,7 +858,8 @@ describe('useDownloadStore', () => {
torrentExcludeTrackers: 123 as unknown as string, torrentExcludeTrackers: 123 as unknown as string,
torrentStopTimeout: 604801, torrentStopTimeout: 604801,
torrentPrioritizePiece: 'head=1G', torrentPrioritizePiece: 'head=1G',
torrentRemoveUnselectedFile: 'yes' as unknown as boolean torrentRemoveUnselectedFile: 'yes' as unknown as boolean,
torrentEncryptionPolicy: 'arc4'
}); });
expect(normalized.torrentMaxPeers).toBeUndefined(); expect(normalized.torrentMaxPeers).toBeUndefined();
@@ -869,6 +870,7 @@ describe('useDownloadStore', () => {
expect(normalized.torrentStopTimeout).toBeUndefined(); expect(normalized.torrentStopTimeout).toBeUndefined();
expect(normalized.torrentPrioritizePiece).toBeUndefined(); expect(normalized.torrentPrioritizePiece).toBeUndefined();
expect(normalized.torrentRemoveUnselectedFile).toBeUndefined(); expect(normalized.torrentRemoveUnselectedFile).toBeUndefined();
expect(normalized.torrentEncryptionPolicy).toBeUndefined();
}); });
it('normalizes proxy settings for download dispatch', async () => { it('normalizes proxy settings for download dispatch', async () => {
@@ -1524,6 +1526,7 @@ describe('useDownloadStore', () => {
torrentExcludeTrackers: '*', torrentExcludeTrackers: '*',
torrentStopTimeout: 300, torrentStopTimeout: 300,
torrentPrioritizePiece: 'head=1M,tail=1M', torrentPrioritizePiece: 'head=1M,tail=1M',
torrentEncryptionPolicy: 'force-encryption',
torrentFileIndices: [1], torrentFileIndices: [1],
torrentRemoveUnselectedFile: true torrentRemoveUnselectedFile: true
}, { type: 'start-now' }); }, { type: 'start-now' });
@@ -1541,6 +1544,7 @@ describe('useDownloadStore', () => {
torrent_exclude_trackers: '*', torrent_exclude_trackers: '*',
torrent_stop_timeout: 300, torrent_stop_timeout: 300,
torrent_prioritize_piece: 'head=1M,tail=1M', torrent_prioritize_piece: 'head=1M,tail=1M',
torrent_encryption_policy: 'force-encryption',
torrent_file_indices: [1], torrent_file_indices: [1],
torrent_remove_unselected_file: true torrent_remove_unselected_file: true
}) })
+9 -3
View File
@@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue'; import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore'; import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore'; import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { import {
resolveCategoryDestination resolveCategoryDestination
} from '../utils/downloadLocations'; } from '../utils/downloadLocations';
@@ -356,6 +356,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_stop_timeout: item.torrentStopTimeout, torrent_stop_timeout: item.torrentStopTimeout,
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined, torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
torrent_remove_unselected_file: item.torrentRemoveUnselectedFile, torrent_remove_unselected_file: item.torrentRemoveUnselectedFile,
torrent_encryption_policy: item.torrentEncryptionPolicy || undefined,
lifecycle_generation: lifecycleGeneration.toString(), lifecycle_generation: lifecycleGeneration.toString(),
}; };
@@ -659,6 +660,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
const normalizedRemoveUnselectedFile = typeof rawRemoveUnselectedFile === 'boolean' const normalizedRemoveUnselectedFile = typeof rawRemoveUnselectedFile === 'boolean'
? rawRemoveUnselectedFile ? rawRemoveUnselectedFile
: undefined; : undefined;
const rawEncryptionPolicy = download.torrentEncryptionPolicy as unknown;
const normalizedEncryptionPolicy = normalizeTorrentEncryptionPolicy(rawEncryptionPolicy);
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers || const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit || rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
rawCheckIntegrity !== normalizedCheckIntegrity || rawCheckIntegrity !== normalizedCheckIntegrity ||
@@ -666,7 +669,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawExcludeTrackers !== normalizedExcludeTrackers || rawExcludeTrackers !== normalizedExcludeTrackers ||
rawStopTimeout !== normalizedStopTimeout || rawStopTimeout !== normalizedStopTimeout ||
rawPrioritizePiece !== normalizedPrioritizePiece || rawPrioritizePiece !== normalizedPrioritizePiece ||
rawRemoveUnselectedFile !== normalizedRemoveUnselectedFile rawRemoveUnselectedFile !== normalizedRemoveUnselectedFile ||
rawEncryptionPolicy !== normalizedEncryptionPolicy
? { ? {
...download, ...download,
torrentMaxPeers: normalizedMaxPeers, torrentMaxPeers: normalizedMaxPeers,
@@ -676,7 +680,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
torrentExcludeTrackers: normalizedExcludeTrackers, torrentExcludeTrackers: normalizedExcludeTrackers,
torrentStopTimeout: normalizedStopTimeout, torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizedPrioritizePiece, torrentPrioritizePiece: normalizedPrioritizePiece,
torrentRemoveUnselectedFile: normalizedRemoveUnselectedFile torrentRemoveUnselectedFile: normalizedRemoveUnselectedFile,
torrentEncryptionPolicy: normalizedEncryptionPolicy
} }
: download; : download;
@@ -2213,6 +2218,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_stop_timeout: item.torrentStopTimeout, torrent_stop_timeout: item.torrentStopTimeout,
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined, torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
torrent_remove_unselected_file: item.torrentRemoveUnselectedFile, torrent_remove_unselected_file: item.torrentRemoveUnselectedFile,
torrent_encryption_policy: item.torrentEncryptionPolicy || undefined,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(), lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
}); });
} }
+15
View File
@@ -8,6 +8,7 @@ import {
canonicalizeDownloadFileName, canonicalizeDownloadFileName,
isValidTorrentExcludeTrackerList, isValidTorrentExcludeTrackerList,
isValidTorrentTrackerList, isValidTorrentTrackerList,
normalizeTorrentEncryptionPolicy,
normalizeTorrentPrioritizePiece, normalizeTorrentPrioritizePiece,
redactDownloadForPersistence, redactDownloadForPersistence,
resolveDownloadConnections resolveDownloadConnections
@@ -92,6 +93,20 @@ describe('Torrent piece priority validation', () => {
}); });
}); });
describe('Torrent encryption policy validation', () => {
it('accepts only the canonical policy states', () => {
expect(normalizeTorrentEncryptionPolicy('disabled')).toBe('disabled');
expect(normalizeTorrentEncryptionPolicy('require-crypto')).toBe('require-crypto');
expect(normalizeTorrentEncryptionPolicy('force-encryption')).toBe('force-encryption');
});
it('clears unknown or malformed persisted values', () => {
expect(normalizeTorrentEncryptionPolicy(undefined)).toBeUndefined();
expect(normalizeTorrentEncryptionPolicy('arc4')).toBeUndefined();
expect(normalizeTorrentEncryptionPolicy(true)).toBeUndefined();
});
});
describe('download connection resolution', () => { describe('download connection resolution', () => {
it('uses a clamped fallback for legacy rows without a saved value', () => { it('uses a clamped fallback for legacy rows without a saved value', () => {
expect(resolveDownloadConnections(undefined, 8)).toBe(8); expect(resolveDownloadConnections(undefined, 8)).toBe(8);
+21
View File
@@ -44,6 +44,27 @@ export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
export const DOWNLOAD_CONNECTIONS_MIN = 1; export const DOWNLOAD_CONNECTIONS_MIN = 1;
export const DOWNLOAD_CONNECTIONS_MAX = 16; export const DOWNLOAD_CONNECTIONS_MAX = 16;
export const TORRENT_ENCRYPTION_POLICY_DISABLED = 'disabled' as const;
export const TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO = 'require-crypto' as const;
export const TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION = 'force-encryption' as const;
export type TorrentEncryptionPolicy =
| typeof TORRENT_ENCRYPTION_POLICY_DISABLED
| typeof TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO
| typeof TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION;
export const normalizeTorrentEncryptionPolicy = (
value: unknown
): TorrentEncryptionPolicy | undefined => {
if (
value === TORRENT_ENCRYPTION_POLICY_DISABLED ||
value === TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO ||
value === TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION
) {
return value;
}
return undefined;
};
// Keep every filename component within the common cross-platform filesystem // Keep every filename component within the common cross-platform filesystem
// limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this // limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this
// bound is also conservative for Windows filename components. // bound is also conservative for Windows filename components.