mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-09 09:16:57 +00:00
fix(app): harden release contracts across shell, queue, and engine boundaries
- Shell & UI: restore focus on sidebar reveal, guard window drag regions, contain context menu Escape keys, and fallback gracefully when a filtered queue is deleted. - Download table: fix sort order for estimated sizes, descending null values, 3-state sort cycle, and desktop keyboard row navigation. - Add window: harden duplicate resolution against unmanaged disk targets, draft id keying, and media credential isolation. - Properties window: add individual Torrent file path copy, horizontal scroll protection for narrow and RTL layouts, and strict numeric bounds on property edits. - Settings & scheduler: add draft buffering for integer inputs, sanitize persisted scheduler options, synchronize post-queue action countdown cancellation, and localize token regeneration. - Engine & backend: isolate loopback JSON-RPC from environment proxies with no_proxy, normalize media cookie sources, dispatch container formats case-insensitively, and preserve snake_case arguments in torrent Tauri commands. - Verification & packaging: isolate Companion git tag resolution from sandboxed git configs, and add regression tests for workflow normalization and portable packaging.
This commit is contained in:
@@ -23,3 +23,19 @@ test('macOS release verification uses the app mounted from the final DMG', () =>
|
||||
assert.match(releaseWorkflow, /node scripts\/verify-binaries\.js --search-root "\$APP"/);
|
||||
assert.doesNotMatch(releaseWorkflow, /verify:macos-signing -- --app "\$APP" --dmg/);
|
||||
});
|
||||
|
||||
test('release workflow normalizes all 6 distribution target artifacts', () => {
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.dmg' "Firelink_\$\{VERSION\}_macOS-ARM64\.dmg"/);
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.AppImage' "Firelink_\$\{VERSION\}_Linux-x64\.AppImage"/);
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.deb' "Firelink_\$\{VERSION\}_Linux-x64\.deb"/);
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.rpm' "Firelink_\$\{VERSION\}_Linux-x64\.rpm"/);
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.exe' "Firelink_\$\{VERSION\}_Windows-x64-setup\.exe"/);
|
||||
assert.match(releaseWorkflow, /rename_asset '\*\.zip' "Firelink_\$\{VERSION\}_Windows-x64-portable\.zip"/);
|
||||
});
|
||||
|
||||
test('Windows release job packages portable ZIP with portable.flag and data cleanup', () => {
|
||||
assert.match(releaseWorkflow, /Set-Content -Path \(Join-Path \$portableRoot 'portable\.flag'\) -Value 'portable'/);
|
||||
assert.match(releaseWorkflow, /node scripts\/smoke-packaged-app\.js --executable \$portableExe --assert-no-visible-child-windows --assert-portable-data/);
|
||||
assert.match(releaseWorkflow, /Remove-Item -Recurse -Force \$portableDataDir/);
|
||||
assert.match(releaseWorkflow, /refusing to package a ZIP containing runtime data/);
|
||||
});
|
||||
|
||||
@@ -12,12 +12,20 @@ function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
function exactVersionTag(extensionRoot, expectedTag) {
|
||||
export function exactVersionTag(extensionRoot, expectedTag) {
|
||||
try {
|
||||
const tags = execFileSync(
|
||||
'git',
|
||||
['-C', extensionRoot, 'tag', '--points-at', 'HEAD', '--list', '--', expectedTag],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
{
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL || (process.platform === 'win32' ? 'NUL' : '/dev/null'),
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
},
|
||||
}
|
||||
)
|
||||
.split(/\r?\n/)
|
||||
.map(tag => tag.trim())
|
||||
|
||||
@@ -3,7 +3,8 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { verifyCompanionRelease } from './verify-companion-release.js';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { exactVersionTag, verifyCompanionRelease } from './verify-companion-release.js';
|
||||
|
||||
function createFixture(packageVersion, manifestVersion) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-companion-release-'));
|
||||
@@ -116,3 +117,26 @@ test('rejects a Companion tag for another version', () => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('exactVersionTag resolves tag on HEAD with isolated git environment', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-git-test-'));
|
||||
try {
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@example.com',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@example.com',
|
||||
};
|
||||
execFileSync('git', ['init', root], { env: gitEnv, stdio: 'ignore' });
|
||||
execFileSync('git', ['-C', root, 'commit', '--allow-empty', '-m', 'test'], { env: gitEnv, stdio: 'ignore' });
|
||||
execFileSync('git', ['-C', root, 'tag', 'v2.0.7'], { env: gitEnv, stdio: 'ignore' });
|
||||
|
||||
assert.equal(exactVersionTag(root, 'v2.0.7'), 'v2.0.7');
|
||||
assert.equal(exactVersionTag(root, 'v2.0.8'), null);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -731,8 +731,9 @@ mod tests {
|
||||
use super::{
|
||||
acknowledge_extension_download, add_server_identity, claim_request_at,
|
||||
has_allowed_request_origin, is_valid_client_nonce, normalize_download,
|
||||
required_client_nonce, sign_server_proof, ExtensionCookieScope, ExtensionRequest,
|
||||
MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
normalize_url, required_client_nonce, same_origin_url, sanitize_filename,
|
||||
sign_server_proof, ExtensionCookieScope, ExtensionRequest, MAX_URL_COUNT,
|
||||
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
|
||||
};
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
@@ -1182,4 +1183,70 @@ mod tests {
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_filename_strips_path_traversal_and_rejects_empty_or_special_names() {
|
||||
assert_eq!(sanitize_filename("../../etc/passwd"), Some("passwd".to_string()));
|
||||
assert_eq!(
|
||||
sanitize_filename(r"..\..\Windows\System32\calc.exe"),
|
||||
Some("calc.exe".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_filename("valid_report.pdf"),
|
||||
Some("valid_report.pdf".to_string())
|
||||
);
|
||||
assert!(sanitize_filename(".").is_none());
|
||||
assert!(sanitize_filename("..").is_none());
|
||||
assert!(sanitize_filename("").is_none());
|
||||
assert!(sanitize_filename(" ").is_none());
|
||||
assert!(sanitize_filename(&"a".repeat(256)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_url_rejects_dangerous_or_unsupported_schemes() {
|
||||
assert!(normalize_url("file:///etc/passwd").is_none());
|
||||
assert!(normalize_url("javascript:alert(1)").is_none());
|
||||
assert!(normalize_url("data:text/html,<h1>test</h1>").is_none());
|
||||
assert!(normalize_url("blob:https://example.com/uuid").is_none());
|
||||
assert_eq!(
|
||||
normalize_url("https://example.com/file.zip"),
|
||||
Some("https://example.com/file.zip".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_url("http://example.com/file.zip"),
|
||||
Some("http://example.com/file.zip".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_url("ftp://example.com/file.zip"),
|
||||
Some("ftp://example.com/file.zip".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_url("sftp://example.com/file.zip"),
|
||||
Some("sftp://example.com/file.zip".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_url("magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"),
|
||||
Some("magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_origin_url_strictly_matches_scheme_host_and_port() {
|
||||
assert!(same_origin_url(
|
||||
"https://example.com/path1",
|
||||
"https://example.com/path2"
|
||||
));
|
||||
assert!(!same_origin_url(
|
||||
"http://example.com/path",
|
||||
"https://example.com/path"
|
||||
));
|
||||
assert!(!same_origin_url(
|
||||
"https://example.com:8443/path",
|
||||
"https://example.com/path"
|
||||
));
|
||||
assert!(!same_origin_url(
|
||||
"https://other.example/path",
|
||||
"https://example.com/path"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +570,7 @@ pub enum ListRowDensity {
|
||||
Relaxed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub enum PostQueueAction {
|
||||
|
||||
+115
-32
@@ -2640,6 +2640,13 @@ async fn fetch_media_metadata(
|
||||
) -> Result<MediaMetadata, String> {
|
||||
properties_window::ensure_main_window(&caller)?;
|
||||
validate_http_url_route(&url)?;
|
||||
let cookie_browser = normalize_media_cookie_source(cookie_browser.as_deref())?;
|
||||
let user_agent = user_agent.map(|ua| ua.trim().to_string()).filter(|ua| !ua.is_empty());
|
||||
let username = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty());
|
||||
let password = password.filter(|p| !p.is_empty());
|
||||
let headers = headers.map(|h| h.trim().to_string()).filter(|h| !h.is_empty());
|
||||
let cookies = cookies.map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
|
||||
let proxy = proxy.map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
|
||||
let cache_key = media_metadata_cache_key(
|
||||
&url,
|
||||
&cookie_browser,
|
||||
@@ -2777,6 +2784,13 @@ async fn fetch_media_playlist_metadata(
|
||||
) -> Result<MediaPlaylistMetadata, String> {
|
||||
properties_window::ensure_main_window(&caller)?;
|
||||
validate_http_url_route(&url)?;
|
||||
let cookie_browser = normalize_media_cookie_source(cookie_browser.as_deref())?;
|
||||
let user_agent = user_agent.map(|ua| ua.trim().to_string()).filter(|ua| !ua.is_empty());
|
||||
let username = username.map(|u| u.trim().to_string()).filter(|u| !u.is_empty());
|
||||
let password = password.filter(|p| !p.is_empty());
|
||||
let headers = headers.map(|h| h.trim().to_string()).filter(|h| !h.is_empty());
|
||||
let cookies = cookies.map(|c| c.trim().to_string()).filter(|c| !c.is_empty());
|
||||
let proxy = proxy.map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
|
||||
|
||||
let result = fetch_media_playlist_metadata_uncached(
|
||||
app_handle.clone(),
|
||||
@@ -2862,8 +2876,8 @@ async fn fetch_media_playlist_metadata_uncached(
|
||||
.arg("no-youtube-unavailable-videos");
|
||||
|
||||
if let Some(browser) = cookie_browser.as_deref() {
|
||||
if !browser.is_empty() {
|
||||
cmd = cmd.arg("--cookies-from-browser").arg(browser);
|
||||
if !browser.is_empty() && browser != "none" {
|
||||
cmd = cmd.arg("--cookies-from-browser").arg(ytdlp_cookie_browser_arg(browser));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2973,8 +2987,8 @@ async fn fetch_media_metadata_uncached(
|
||||
.arg("%(.{title,duration,thumbnail,formats})j");
|
||||
|
||||
if let Some(browser) = cookie_browser.as_deref() {
|
||||
if !browser.is_empty() {
|
||||
cmd = cmd.arg("--cookies-from-browser").arg(browser);
|
||||
if !browser.is_empty() && browser != "none" {
|
||||
cmd = cmd.arg("--cookies-from-browser").arg(ytdlp_cookie_browser_arg(browser));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4048,6 +4062,7 @@ pub async fn rpc_call(
|
||||
payload.insert("params".to_string(), serde_json::json!(p));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(3))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -4748,6 +4763,49 @@ fn normalize_media_cookie_source(source: Option<&str>) -> Result<Option<String>,
|
||||
Err("Unsupported media browser-cookie source".to_string())
|
||||
}
|
||||
|
||||
fn ytdlp_cookie_browser_arg(browser: &str) -> String {
|
||||
let trimmed = browser.trim();
|
||||
if trimmed.eq_ignore_ascii_case("safari") {
|
||||
"safari:".to_string()
|
||||
} else {
|
||||
trimmed.to_ascii_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
fn media_format_and_container_args(format: &str, safe_filename: &str) -> Vec<String> {
|
||||
let mut args = vec!["-f".to_string(), format.to_string()];
|
||||
let lower_filename = safe_filename.to_ascii_lowercase();
|
||||
if lower_filename.ends_with(".mp3") {
|
||||
args.extend(["-x".to_string(), "--audio-format".to_string(), "mp3".to_string()]);
|
||||
} else if lower_filename.ends_with(".m4a") {
|
||||
args.extend(["-x".to_string(), "--audio-format".to_string(), "m4a".to_string()]);
|
||||
} else if lower_filename.ends_with(".opus") {
|
||||
args.extend(["-x".to_string(), "--audio-format".to_string(), "opus".to_string()]);
|
||||
} else if lower_filename.ends_with(".flac") {
|
||||
args.extend(["-x".to_string(), "--audio-format".to_string(), "flac".to_string()]);
|
||||
} else if lower_filename.ends_with(".aac") {
|
||||
args.extend(["-x".to_string(), "--audio-format".to_string(), "aac".to_string()]);
|
||||
} else if lower_filename.ends_with(".wav") {
|
||||
args.extend(["-x".to_string(), "--audio-format".to_string(), "wav".to_string()]);
|
||||
} else if lower_filename.ends_with(".alac") {
|
||||
args.extend(["-x".to_string(), "--audio-format".to_string(), "alac".to_string()]);
|
||||
} else if lower_filename.ends_with(".ogg") {
|
||||
args.extend(["-x".to_string(), "--audio-format".to_string(), "vorbis".to_string()]);
|
||||
} else if lower_filename.ends_with(".mp4") {
|
||||
args.extend(["--merge-output-format".to_string(), "mp4".to_string()]);
|
||||
} else if lower_filename.ends_with(".webm") {
|
||||
args.extend(["--merge-output-format".to_string(), "webm".to_string()]);
|
||||
} else {
|
||||
args.extend([
|
||||
"--merge-output-format".to_string(),
|
||||
"mkv".to_string(),
|
||||
"--remux-video".to_string(),
|
||||
"mkv".to_string(),
|
||||
]);
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn start_media_download_internal(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -4904,12 +4962,8 @@ pub(crate) async fn start_media_download_internal(
|
||||
}
|
||||
|
||||
if let Some(cs) = effective_cookie_source.as_ref() {
|
||||
let mut cs = cs.clone();
|
||||
if !cs.is_empty() && cs != "none" {
|
||||
if cs == "safari" {
|
||||
cs = "safari:".to_string()
|
||||
}
|
||||
cmd = cmd.arg("--cookies-from-browser").arg(cs);
|
||||
cmd = cmd.arg("--cookies-from-browser").arg(ytdlp_cookie_browser_arg(cs));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4924,27 +4978,8 @@ pub(crate) async fn start_media_download_internal(
|
||||
}
|
||||
|
||||
if let Some(format) = format_selector.as_ref() {
|
||||
cmd = cmd.arg("-f").arg(format);
|
||||
if safe_filename.ends_with(".mp3") {
|
||||
cmd = cmd.arg("-x").arg("--audio-format").arg("mp3");
|
||||
} else if safe_filename.ends_with(".m4a") {
|
||||
cmd = cmd.arg("-x").arg("--audio-format").arg("m4a");
|
||||
} else if safe_filename.ends_with(".opus") {
|
||||
cmd = cmd.arg("-x").arg("--audio-format").arg("opus");
|
||||
} else if safe_filename.ends_with(".mp4") {
|
||||
cmd = cmd.arg("--merge-output-format").arg("mp4");
|
||||
} else if safe_filename.ends_with(".webm") {
|
||||
cmd = cmd.arg("--merge-output-format").arg("webm");
|
||||
} else {
|
||||
// `--merge-output-format` only affects split video/audio
|
||||
// selections. A progressive MP4/WebM stream already includes
|
||||
// audio, so also request remuxing to keep an MKV option from
|
||||
// producing a mismatched file extension and container.
|
||||
cmd = cmd
|
||||
.arg("--merge-output-format")
|
||||
.arg("mkv")
|
||||
.arg("--remux-video")
|
||||
.arg("mkv");
|
||||
for arg in media_format_and_container_args(format, &safe_filename) {
|
||||
cmd = cmd.arg(arg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10116,7 +10151,7 @@ async fn set_torrent_upload_limit(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(rename_all = "snake_case")]
|
||||
async fn set_torrent_peer_options(
|
||||
caller: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
@@ -10379,7 +10414,7 @@ async fn get_torrent_file_selection(
|
||||
Ok(torrent_file_selection_snapshot(&metadata, selected.as_deref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tauri::command(rename_all = "snake_case")]
|
||||
async fn set_torrent_file_selection(
|
||||
caller: tauri::WebviewWindow,
|
||||
properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>,
|
||||
@@ -14120,6 +14155,8 @@ mod tests {
|
||||
parse_media_playlist_metadata,
|
||||
normalize_media_connections,
|
||||
normalize_media_cookie_source,
|
||||
media_format_and_container_args,
|
||||
ytdlp_cookie_browser_arg,
|
||||
validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id,
|
||||
validate_torrent_metadata_network_policy,
|
||||
aria2_gid_not_found,
|
||||
@@ -17688,6 +17725,52 @@ mod tests {
|
||||
assert!(normalize_media_cookie_source(Some("unknown-browser")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ytdlp_cookie_browser_arg_formats_safari_and_other_browsers() {
|
||||
assert_eq!(ytdlp_cookie_browser_arg("safari"), "safari:");
|
||||
assert_eq!(ytdlp_cookie_browser_arg(" Safari "), "safari:");
|
||||
assert_eq!(ytdlp_cookie_browser_arg("chrome"), "chrome");
|
||||
assert_eq!(ytdlp_cookie_browser_arg(" Firefox "), "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_format_and_container_args_dispatches_audio_and_video_correctly() {
|
||||
// Audio extractions (case-insensitive)
|
||||
let mp3_args = media_format_and_container_args("ba", "song.MP3");
|
||||
assert_eq!(mp3_args, vec!["-f", "ba", "-x", "--audio-format", "mp3"]);
|
||||
|
||||
let m4a_args = media_format_and_container_args("140", "song.m4a");
|
||||
assert_eq!(m4a_args, vec!["-f", "140", "-x", "--audio-format", "m4a"]);
|
||||
|
||||
let opus_args = media_format_and_container_args("251", "track.Opus");
|
||||
assert_eq!(opus_args, vec!["-f", "251", "-x", "--audio-format", "opus"]);
|
||||
|
||||
let flac_args = media_format_and_container_args("ba", "lossless.flac");
|
||||
assert_eq!(flac_args, vec!["-f", "ba", "-x", "--audio-format", "flac"]);
|
||||
|
||||
let aac_args = media_format_and_container_args("ba", "audio.aac");
|
||||
assert_eq!(aac_args, vec!["-f", "ba", "-x", "--audio-format", "aac"]);
|
||||
|
||||
let wav_args = media_format_and_container_args("ba", "recording.wav");
|
||||
assert_eq!(wav_args, vec!["-f", "ba", "-x", "--audio-format", "wav"]);
|
||||
|
||||
let ogg_args = media_format_and_container_args("ba", "audio.ogg");
|
||||
assert_eq!(ogg_args, vec!["-f", "ba", "-x", "--audio-format", "vorbis"]);
|
||||
|
||||
// Video containers
|
||||
let mp4_args = media_format_and_container_args("137+140", "video.MP4");
|
||||
assert_eq!(mp4_args, vec!["-f", "137+140", "--merge-output-format", "mp4"]);
|
||||
|
||||
let webm_args = media_format_and_container_args("248+251", "video.webm");
|
||||
assert_eq!(webm_args, vec!["-f", "248+251", "--merge-output-format", "webm"]);
|
||||
|
||||
let mkv_args = media_format_and_container_args("301+251", "video.mkv");
|
||||
assert_eq!(
|
||||
mkv_args,
|
||||
vec!["-f", "301+251", "--merge-output-format", "mkv", "--remux-video", "mkv"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires network and a local yt-dlp executable"]
|
||||
fn filters_live_youtube_metadata_from_env() {
|
||||
|
||||
+138
-1
@@ -454,6 +454,9 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
|
||||
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
|
||||
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
|
||||
sanitize_integer_setting(state, "minimumNormalDownloadSpeedKiB", |value| value.as_u64().is_some());
|
||||
sanitize_integer_setting(state, "lastCustomSpeedLimitKiB", |value| value.as_u64().is_some());
|
||||
sanitize_allowed_string(state, "lastCustomSpeedLimitUnit", &["KB/s", "MB/s"]);
|
||||
sanitize_integer_setting(state, "proxyPort", |value| {
|
||||
value
|
||||
.as_u64()
|
||||
@@ -483,19 +486,78 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
})
|
||||
});
|
||||
for key in [
|
||||
"categorySubfoldersEnabled",
|
||||
"logsEnabled",
|
||||
"isSidebarVisible",
|
||||
"isFoldersCollapsed",
|
||||
"schedulerRunning",
|
||||
"retryNotFoundErrors",
|
||||
"adaptiveMirrorSelection",
|
||||
"showNotifications",
|
||||
"playCompletionSound",
|
||||
"autoAddClipboardLinks",
|
||||
"showDockBadge",
|
||||
"showMenuBarIcon",
|
||||
"torrentEnableDht",
|
||||
"torrentEnableDht6",
|
||||
"torrentEnablePex",
|
||||
"torrentEnableLpd",
|
||||
"torrentSeparateSeedSlots",
|
||||
"torrentIpv6Enabled",
|
||||
"askWhereToSaveEachFile",
|
||||
"rememberLastUsedDownloadDirectory",
|
||||
"preventsSleepWhileDownloading",
|
||||
"preventsDisplaySleepWhileDownloading",
|
||||
"autoCheckUpdates",
|
||||
"keychainAccessGranted",
|
||||
] {
|
||||
sanitize_boolean_setting(state, key);
|
||||
}
|
||||
for key in ["proxyHost", "customUserAgent"] {
|
||||
for key in [
|
||||
"proxyHost",
|
||||
"customUserAgent",
|
||||
"globalSpeedLimit",
|
||||
"torrentOverallUploadLimit",
|
||||
"baseDownloadFolder",
|
||||
"schedulerLastStartKey",
|
||||
"schedulerLastStopKey",
|
||||
] {
|
||||
sanitize_string_setting(state, key);
|
||||
}
|
||||
if let Some(presets) = state.get("speedLimitPresetValues") {
|
||||
if !presets.is_array() {
|
||||
state.remove("speedLimitPresetValues");
|
||||
} else if let Some(presets_arr) = state.get_mut("speedLimitPresetValues").and_then(Value::as_array_mut) {
|
||||
presets_arr.retain(|v| v.as_f64().is_some_and(|f| f.is_finite() && f > 0.0));
|
||||
if presets_arr.is_empty() {
|
||||
state.remove("speedLimitPresetValues");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(roots) = state.get("approvedDownloadRoots") {
|
||||
if !roots.is_array() {
|
||||
state.remove("approvedDownloadRoots");
|
||||
} else if let Some(roots_arr) = state.get_mut("approvedDownloadRoots").and_then(Value::as_array_mut) {
|
||||
roots_arr.retain(|v| v.as_str().is_some());
|
||||
}
|
||||
}
|
||||
if let Some(active_ids) = state.get("schedulerActiveDownloadIds") {
|
||||
if !active_ids.is_array() {
|
||||
state.remove("schedulerActiveDownloadIds");
|
||||
} else if let Some(ids_arr) = state.get_mut("schedulerActiveDownloadIds").and_then(Value::as_array_mut) {
|
||||
ids_arr.retain(|v| v.as_str().is_some());
|
||||
}
|
||||
}
|
||||
if let Some(overrides) = state.get("categoryDirectoryOverrides") {
|
||||
if !overrides.is_object() {
|
||||
state.remove("categoryDirectoryOverrides");
|
||||
}
|
||||
}
|
||||
if let Some(subfolders) = state.get("categorySubfolders") {
|
||||
if !subfolders.is_object() {
|
||||
state.remove("categorySubfolders");
|
||||
}
|
||||
}
|
||||
sanitize_torrent_network_string(state, "torrentListenPort", |value| {
|
||||
crate::queue::normalize_torrent_port_spec(Some(value), "TCP listen ports").is_ok()
|
||||
});
|
||||
@@ -590,6 +652,32 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
"postQueueAction",
|
||||
&["none", "sleep", "restart", "shutdown"],
|
||||
);
|
||||
for key in ["enabled", "stopTimeEnabled", "everyday"] {
|
||||
sanitize_boolean_setting(scheduler, key);
|
||||
}
|
||||
for key in ["startTime", "stopTime"] {
|
||||
sanitize_string_setting(scheduler, key);
|
||||
}
|
||||
if let Some(days) = scheduler.get("selectedDays") {
|
||||
if !days.is_array() {
|
||||
scheduler.remove("selectedDays");
|
||||
} else if let Some(days_arr) = scheduler.get_mut("selectedDays").and_then(Value::as_array_mut) {
|
||||
days_arr.retain(|v| v.as_u64().is_some_and(|n| n <= 6));
|
||||
if days_arr.is_empty() {
|
||||
scheduler.remove("selectedDays");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(queue_ids) = scheduler.get("selectedQueueIds") {
|
||||
if !queue_ids.is_array() {
|
||||
scheduler.remove("selectedQueueIds");
|
||||
} else if let Some(queue_ids_arr) = scheduler.get_mut("selectedQueueIds").and_then(Value::as_array_mut) {
|
||||
queue_ids_arr.retain(|v| v.as_str().is_some_and(|s| !s.trim().is_empty()));
|
||||
if queue_ids_arr.is_empty() {
|
||||
scheduler.remove("selectedQueueIds");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(logins) = state.get_mut("siteLogins").and_then(Value::as_array_mut) {
|
||||
@@ -762,6 +850,19 @@ fn validate_settings(settings: &mut PersistedSettings) {
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
settings.last_custom_speed_limit_ki_b = settings.last_custom_speed_limit_ki_b.clamp(1, 10_485_760);
|
||||
settings.speed_limit_preset_values.retain(|v| v.is_finite() && *v > 0.0);
|
||||
if settings.speed_limit_preset_values.is_empty() {
|
||||
settings.speed_limit_preset_values = default_settings().speed_limit_preset_values;
|
||||
}
|
||||
settings.scheduler.selected_days.retain(|d| (0..=6).contains(d));
|
||||
if settings.scheduler.selected_days.is_empty() {
|
||||
settings.scheduler.selected_days = default_settings().scheduler.selected_days;
|
||||
}
|
||||
settings.scheduler.selected_queue_ids.retain(|q| !q.trim().is_empty());
|
||||
if settings.scheduler.selected_queue_ids.is_empty() {
|
||||
settings.scheduler.selected_queue_ids = default_settings().scheduler.selected_queue_ids;
|
||||
}
|
||||
if !matches!(
|
||||
settings.last_custom_speed_limit_unit.as_str(),
|
||||
"KB/s" | "MB/s"
|
||||
@@ -1396,6 +1497,42 @@ mod tests {
|
||||
assert!(settings.is_sidebar_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_malformed_speed_and_scheduler_settings_without_error() {
|
||||
let stored = json!({
|
||||
"state": {
|
||||
"minimumNormalDownloadSpeedKiB": "very-fast",
|
||||
"lastCustomSpeedLimitKiB": -50,
|
||||
"lastCustomSpeedLimitUnit": "TB/s",
|
||||
"speedLimitPresetValues": ["not-a-number", -1.0, 0.0],
|
||||
"approvedDownloadRoots": 12345,
|
||||
"scheduler": {
|
||||
"enabled": "yes",
|
||||
"postQueueAction": "explode",
|
||||
"selectedDays": [99, "monday"],
|
||||
"selectedQueueIds": ["", " "]
|
||||
},
|
||||
"schedulerRunning": "active",
|
||||
"schedulerActiveDownloadIds": "none"
|
||||
},
|
||||
"version": 6
|
||||
});
|
||||
|
||||
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
|
||||
|
||||
assert_eq!(settings.minimum_normal_download_speed_ki_b, 0);
|
||||
assert_eq!(settings.last_custom_speed_limit_ki_b, 1024);
|
||||
assert_eq!(settings.last_custom_speed_limit_unit, "MB/s");
|
||||
assert_eq!(settings.speed_limit_preset_values, default_settings().speed_limit_preset_values);
|
||||
assert_eq!(settings.approved_download_roots, default_settings().approved_download_roots);
|
||||
assert!(!settings.scheduler.enabled);
|
||||
assert_eq!(settings.scheduler.post_queue_action, crate::ipc::PostQueueAction::None);
|
||||
assert_eq!(settings.scheduler.selected_days, default_settings().scheduler.selected_days);
|
||||
assert_eq!(settings.scheduler.selected_queue_ids, default_settings().scheduler.selected_queue_ids);
|
||||
assert!(!settings.scheduler_running);
|
||||
assert!(settings.scheduler_active_download_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_valid_torrent_network_settings() {
|
||||
let stored = json!({
|
||||
|
||||
@@ -123,3 +123,73 @@ fn headless_queue_lifecycle_eligibility_and_retry_contracts_hold() {
|
||||
);
|
||||
assert!(is_permanent_network_error("HTTP 403 Forbidden"));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, PartialEq, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
struct TorrentPeerOptionsArgs {
|
||||
id: String,
|
||||
max_peers: Option<i64>,
|
||||
peer_speed_limit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, PartialEq, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
struct TorrentFileSelectionArgs {
|
||||
id: String,
|
||||
selected_indices: Option<Vec<u32>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, PartialEq, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BuggyTorrentPeerOptionsArgs {
|
||||
id: String,
|
||||
max_peers: Option<i64>,
|
||||
peer_speed_limit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, PartialEq, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BuggyTorrentFileSelectionArgs {
|
||||
id: String,
|
||||
selected_indices: Option<Vec<u32>>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipc_snake_case_deserialization_contract_preserves_torrent_arguments() {
|
||||
let peer_payload = serde_json::json!({
|
||||
"id": "dl-123",
|
||||
"max_peers": 42,
|
||||
"peer_speed_limit": "2M"
|
||||
});
|
||||
|
||||
// The fixed snake_case contract receives and preserves the frontend's arguments:
|
||||
let fixed_peer: TorrentPeerOptionsArgs = serde_json::from_value(peer_payload.clone())
|
||||
.expect("snake_case deserializer should parse torrent peer options");
|
||||
assert_eq!(fixed_peer.id, "dl-123");
|
||||
assert_eq!(fixed_peer.max_peers, Some(42));
|
||||
assert_eq!(fixed_peer.peer_speed_limit.as_deref(), Some("2M"));
|
||||
|
||||
// The buggy default camelCase contract dropped the arguments to None silently:
|
||||
let buggy_peer: BuggyTorrentPeerOptionsArgs = serde_json::from_value(peer_payload)
|
||||
.expect("camelCase deserializer parses but silently drops snake_case keys");
|
||||
assert_eq!(buggy_peer.id, "dl-123");
|
||||
assert_eq!(buggy_peer.max_peers, None);
|
||||
assert_eq!(buggy_peer.peer_speed_limit, None);
|
||||
|
||||
let selection_payload = serde_json::json!({
|
||||
"id": "dl-456",
|
||||
"selected_indices": [1, 3, 5]
|
||||
});
|
||||
|
||||
// The fixed snake_case contract receives and preserves selected file indices:
|
||||
let fixed_selection: TorrentFileSelectionArgs = serde_json::from_value(selection_payload.clone())
|
||||
.expect("snake_case deserializer should parse selected indices");
|
||||
assert_eq!(fixed_selection.id, "dl-456");
|
||||
assert_eq!(fixed_selection.selected_indices, Some(vec![1, 3, 5]));
|
||||
|
||||
// The buggy default camelCase contract dropped selected indices to None (selecting all files):
|
||||
let buggy_selection: BuggyTorrentFileSelectionArgs = serde_json::from_value(selection_payload)
|
||||
.expect("camelCase deserializer parses but silently drops snake_case keys");
|
||||
assert_eq!(buggy_selection.id, "dl-456");
|
||||
assert_eq!(buggy_selection.selected_indices, None);
|
||||
}
|
||||
|
||||
@@ -94,3 +94,35 @@ async fn production_rpc_client_preserves_http_gateway_context() {
|
||||
);
|
||||
stop_server(shutdown, task).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_rpc_client_bypasses_environment_proxy() {
|
||||
let app = Router::new().route("/jsonrpc", post(successful_rpc));
|
||||
let (address, shutdown, task) = start_server(app).await;
|
||||
|
||||
// Even if an invalid or hostile HTTP proxy is set in the environment,
|
||||
// loopback JSON-RPC calls must bypass the proxy and connect directly to loopback.
|
||||
struct EnvGuard(&'static str, Option<String>);
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.1 {
|
||||
Some(val) => std::env::set_var(self.0, val),
|
||||
None => std::env::remove_var(self.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
let _guard = EnvGuard("HTTP_PROXY", std::env::var("HTTP_PROXY").ok());
|
||||
std::env::set_var("HTTP_PROXY", "http://192.0.2.1:8080");
|
||||
|
||||
let result = rpc_call(
|
||||
address.port(),
|
||||
"test-secret",
|
||||
"aria2.getVersion",
|
||||
json!([{"include": "version"}]),
|
||||
)
|
||||
.await
|
||||
.expect("RPC client must bypass HTTP_PROXY and succeed over loopback");
|
||||
|
||||
assert_eq!(result, json!({"version": "test"}));
|
||||
stop_server(shutdown, task).await;
|
||||
}
|
||||
|
||||
+77
-29
@@ -42,12 +42,18 @@ import { formatDownloadBytes } from './utils/downloadProgress';
|
||||
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
|
||||
import { createMainWindowSizePersistence } from './utils/mainWindowState';
|
||||
import { createSidebarResizeSession } from './utils/sidebarResize';
|
||||
import {
|
||||
resolveFallbackFilter,
|
||||
shouldRestoreSidebarRevealFocus,
|
||||
shouldRestoreSidebarToggleFocus
|
||||
} from './utils/sidebarFocus';
|
||||
import type { MainWindowSize } from './bindings/MainWindowSize';
|
||||
import {
|
||||
beginSchedulerControl,
|
||||
consumeSchedulerHandoffIds,
|
||||
handoffSupersededSchedulerIds,
|
||||
isSchedulerControlCurrent
|
||||
isSchedulerControlCurrent,
|
||||
registerPostActionCanceller
|
||||
} from './utils/schedulerControl';
|
||||
import { createSerialTaskQueue } from './utils/serialTaskQueue';
|
||||
|
||||
@@ -202,7 +208,9 @@ function App() {
|
||||
});
|
||||
const sidebarResizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const sidebarRevealRef = useRef<HTMLButtonElement>(null);
|
||||
const sidebarToggleRef = useRef<HTMLButtonElement>(null);
|
||||
const restoreSidebarFocusRef = useRef(false);
|
||||
const restoreRevealFocusRef = useRef(false);
|
||||
|
||||
const theme = useSettingsStore(state => state.theme);
|
||||
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
|
||||
@@ -244,6 +252,18 @@ function App() {
|
||||
const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen);
|
||||
const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen);
|
||||
const downloads = useDownloadStore(state => state.downloads);
|
||||
const queues = useDownloadStore(state => state.queues);
|
||||
|
||||
useEffect(() => {
|
||||
const fallback = resolveFallbackFilter(
|
||||
filter,
|
||||
queues.map(queue => queue.id),
|
||||
queues.length > 0,
|
||||
);
|
||||
if (fallback !== filter) {
|
||||
setFilter(fallback as SidebarFilter);
|
||||
}
|
||||
}, [filter, queues]);
|
||||
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
||||
const queuedCount = downloads.filter(download =>
|
||||
download.status === 'queued' || download.status === 'staged'
|
||||
@@ -262,6 +282,8 @@ function App() {
|
||||
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
|
||||
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
||||
const pendingPostActionTimer = useRef<number | null>(null);
|
||||
const pendingPostActionToastId = useRef<string | null>(null);
|
||||
const pendingForceActionToastId = useRef<string | null>(null);
|
||||
const startupResumeStarted = useRef(false);
|
||||
const startupInputReady = useRef(false);
|
||||
const extensionProcessing = useRef(createSerialTaskQueue());
|
||||
@@ -308,7 +330,15 @@ function App() {
|
||||
window.clearTimeout(pendingPostActionTimer.current);
|
||||
pendingPostActionTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
if (pendingPostActionToastId.current !== null) {
|
||||
removeToast(pendingPostActionToastId.current);
|
||||
pendingPostActionToastId.current = null;
|
||||
}
|
||||
if (pendingForceActionToastId.current !== null) {
|
||||
removeToast(pendingForceActionToastId.current);
|
||||
pendingForceActionToastId.current = null;
|
||||
}
|
||||
}, [removeToast]);
|
||||
|
||||
const queueFrontendReadyUpdate = useCallback((ready: boolean) => {
|
||||
const update = frontendReadyUpdate.current
|
||||
@@ -341,13 +371,15 @@ function App() {
|
||||
|
||||
const actionLabel = t($ => $.scheduler.postActions[action]);
|
||||
let timerId: number | null = null;
|
||||
let toastId: string | null = null;
|
||||
const showForceActionToast = () => {
|
||||
let forceToastId: string | null = null;
|
||||
if (pendingForceActionToastId.current !== null) {
|
||||
removeToast(pendingForceActionToastId.current);
|
||||
pendingForceActionToastId.current = null;
|
||||
}
|
||||
const proceed = () => {
|
||||
if (forceToastId !== null) {
|
||||
removeToast(forceToastId);
|
||||
forceToastId = null;
|
||||
if (pendingForceActionToastId.current !== null) {
|
||||
removeToast(pendingForceActionToastId.current);
|
||||
pendingForceActionToastId.current = null;
|
||||
}
|
||||
invoke('perform_system_action', { action, force: true }).catch(error => {
|
||||
console.error('Forced scheduled post action failed:', error);
|
||||
@@ -358,7 +390,7 @@ function App() {
|
||||
});
|
||||
});
|
||||
};
|
||||
forceToastId = addToast({
|
||||
pendingForceActionToastId.current = addToast({
|
||||
variant: 'warning',
|
||||
isActionable: true,
|
||||
duration: 0,
|
||||
@@ -391,16 +423,8 @@ function App() {
|
||||
});
|
||||
});
|
||||
};
|
||||
const cancel = () => {
|
||||
clearPendingPostActionTimer();
|
||||
timerId = null;
|
||||
if (toastId !== null) {
|
||||
removeToast(toastId);
|
||||
toastId = null;
|
||||
}
|
||||
};
|
||||
|
||||
toastId = addToast({
|
||||
const toastId = addToast({
|
||||
variant: 'warning',
|
||||
isActionable: true,
|
||||
onDismiss: clearPendingPostActionTimer,
|
||||
@@ -410,18 +434,19 @@ function App() {
|
||||
<button
|
||||
type="button"
|
||||
className="app-button px-2 py-1"
|
||||
onClick={cancel}
|
||||
onClick={clearPendingPostActionTimer}
|
||||
>
|
||||
{t($ => $.actions.cancel)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
pendingPostActionToastId.current = toastId;
|
||||
|
||||
timerId = window.setTimeout(() => {
|
||||
if (toastId !== null) {
|
||||
if (pendingPostActionToastId.current === toastId) {
|
||||
removeToast(toastId);
|
||||
toastId = null;
|
||||
pendingPostActionToastId.current = null;
|
||||
}
|
||||
if (pendingPostActionTimer.current === timerId) {
|
||||
pendingPostActionTimer.current = null;
|
||||
@@ -466,24 +491,43 @@ function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSidebarVisible) return;
|
||||
if (restoreSidebarFocusRef.current) {
|
||||
restoreSidebarFocusRef.current = false;
|
||||
sidebarRevealRef.current?.focus({ preventScroll: true });
|
||||
if (!isSidebarVisible) {
|
||||
if (restoreSidebarFocusRef.current) {
|
||||
restoreSidebarFocusRef.current = false;
|
||||
sidebarRevealRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (restoreRevealFocusRef.current) {
|
||||
restoreRevealFocusRef.current = false;
|
||||
sidebarToggleRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [isSidebarVisible]);
|
||||
|
||||
const handleSidebarToggle = () => {
|
||||
const activeElement = document.activeElement;
|
||||
if (isSidebarVisible) {
|
||||
const activeElement = document.activeElement;
|
||||
restoreSidebarFocusRef.current = activeElement instanceof HTMLElement
|
||||
&& Boolean(activeElement.closest('.app-sidebar-shell'));
|
||||
restoreSidebarFocusRef.current = shouldRestoreSidebarRevealFocus(
|
||||
activeElement,
|
||||
document.querySelector('.app-sidebar-shell'),
|
||||
);
|
||||
restoreRevealFocusRef.current = false;
|
||||
} else {
|
||||
restoreRevealFocusRef.current = shouldRestoreSidebarToggleFocus(
|
||||
activeElement,
|
||||
sidebarRevealRef.current,
|
||||
);
|
||||
restoreSidebarFocusRef.current = false;
|
||||
}
|
||||
toggleSidebar();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return clearPendingPostActionTimer;
|
||||
const unregister = registerPostActionCanceller(clearPendingPostActionTimer);
|
||||
return () => {
|
||||
unregister();
|
||||
clearPendingPostActionTimer();
|
||||
};
|
||||
}, [clearPendingPostActionTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1209,6 +1253,7 @@ function App() {
|
||||
>
|
||||
<Sidebar
|
||||
selectedFilter={filter}
|
||||
toggleButtonRef={sidebarToggleRef}
|
||||
onToggleSidebar={handleSidebarToggle}
|
||||
onSelectFilter={(f) => {
|
||||
setFilter(f);
|
||||
@@ -1234,7 +1279,10 @@ function App() {
|
||||
<button
|
||||
type="button"
|
||||
ref={sidebarRevealRef}
|
||||
onClick={toggleSidebar}
|
||||
data-tauri-drag-region="false"
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
onMouseDown={event => event.stopPropagation()}
|
||||
onClick={handleSidebarToggle}
|
||||
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
||||
title={t($ => $.actions.showSidebar)}
|
||||
aria-label={t($ => $.actions.showSidebar)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, headerNameHasCredentialMaterial, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, headerNameHasCredentialMaterial, isMediaUrl, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
|
||||
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
expandTilde,
|
||||
@@ -228,7 +228,7 @@ export const AddDownloadsModal = () => {
|
||||
const modalRef = useModalFocus(isAddModalOpen);
|
||||
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
|
||||
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
|
||||
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<number, string>>({});
|
||||
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<string | number, string>>({});
|
||||
const [resolvedLocation, setResolvedLocation] = useState('');
|
||||
const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -322,15 +322,28 @@ export const AddDownloadsModal = () => {
|
||||
const requestContextForUrl = (url: string) =>
|
||||
pendingAddRequestContexts[normalizeComparableUrl(url)];
|
||||
const hasExtensionRequestContext = Object.keys(pendingAddRequestContexts).length > 0;
|
||||
const headersForRow = (sourceUrl: string) => {
|
||||
const headersForRow = (sourceUrl: string, isMedia = false) => {
|
||||
if (headersManuallyEditedRef.current) return headers.trim();
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
const media = isMedia || context?.media === true || isMediaUrl(sourceUrl);
|
||||
if (media) {
|
||||
const raw = context ? extensionHeaders(context) : (hasExtensionRequestContext ? '' : headers.trim());
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.filter(line => {
|
||||
const separator = line.indexOf(':');
|
||||
return separator > 0 && !headerNameHasCredentialMaterial(line.slice(0, separator));
|
||||
})
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
if (context) return extensionHeaders(context).trim();
|
||||
return hasExtensionRequestContext ? '' : headers.trim();
|
||||
};
|
||||
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => {
|
||||
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl, isMedia = false) => {
|
||||
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (isMedia || context?.media === true || isMediaUrl(sourceUrl)) return '';
|
||||
const scopedCookies = cookieScopeForUrl(context, targetUrl);
|
||||
if (scopedCookies) return scopedCookies;
|
||||
if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return '';
|
||||
@@ -440,7 +453,9 @@ export const AddDownloadsModal = () => {
|
||||
pendingAddHeaders
|
||||
].filter(Boolean).join('\n'));
|
||||
headersManuallyEditedRef.current = false;
|
||||
setCookies(initialContext?.cookies || pendingAddCookies);
|
||||
const isSingleInitialMedia = initialContext?.media === true
|
||||
|| (initialUrlLines.length === 1 && isMediaUrl(initialUrlLines[0]));
|
||||
setCookies(isSingleInitialMedia ? '' : (initialContext?.cookies || pendingAddCookies));
|
||||
cookiesManuallyEditedRef.current = false;
|
||||
setMirrors('');
|
||||
setIsQueueMenuOpen(false);
|
||||
@@ -1011,6 +1026,11 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to select folder:", e);
|
||||
addToast({
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1178,7 +1198,7 @@ export const AddDownloadsModal = () => {
|
||||
++folderPickerRequestRef.current;
|
||||
let finalLocation = saveLocation;
|
||||
let useSharedDestination = isSaveLocationManual;
|
||||
const destinationOverrides: Record<number, string> = {};
|
||||
const destinationOverrides: Record<string | number, string> = {};
|
||||
const settings = useSettingsStore.getState();
|
||||
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
|
||||
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
|
||||
@@ -1201,6 +1221,7 @@ export const AddDownloadsModal = () => {
|
||||
if (selected && typeof selected === 'string') {
|
||||
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
|
||||
destinationOverrides[index] = approvedPath;
|
||||
destinationOverrides[item.id] = approvedPath;
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
if (currentSettings.rememberLastUsedDownloadDirectory) {
|
||||
pendingLastUsedDownloadDirectoryRef.current = approvedPath;
|
||||
@@ -1213,6 +1234,11 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to select folder:", e);
|
||||
addToast({
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
pendingLastUsedDownloadDirectoryRef.current = null;
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
@@ -1257,7 +1283,7 @@ export const AddDownloadsModal = () => {
|
||||
);
|
||||
if (urlMatch) {
|
||||
newConflicts.push({
|
||||
id: i.toString(),
|
||||
id: item.id,
|
||||
fileName: finalFile,
|
||||
reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) },
|
||||
resolution: 'rename',
|
||||
@@ -1266,7 +1292,7 @@ export const AddDownloadsModal = () => {
|
||||
});
|
||||
} else if (hasBatchConflict) {
|
||||
newConflicts.push({
|
||||
id: i.toString(),
|
||||
id: item.id,
|
||||
fileName: finalFile,
|
||||
reason: { type: 'file', msg: t($ => $.addDownloads.destinationConflict) },
|
||||
resolution: 'rename',
|
||||
@@ -1313,7 +1339,7 @@ export const AddDownloadsModal = () => {
|
||||
const canReplace = !reservedFilenameMatchIds.has(filenameMatch.id)
|
||||
&& !isTransferLocked(filenameMatch.status);
|
||||
newConflicts.push({
|
||||
id: i.toString(),
|
||||
id: item.id,
|
||||
fileName: finalFile,
|
||||
reason: { type: 'file', msg: t($ => $.addDownloads.matchingDownloadFilename) },
|
||||
resolution: canReplace ? 'replace' : 'rename',
|
||||
@@ -1368,7 +1394,7 @@ export const AddDownloadsModal = () => {
|
||||
: false;
|
||||
if (existingDownload || fileExistsOnDisk || hasFirelinkOwnedTarget) {
|
||||
newConflicts.push({
|
||||
id: i.toString(),
|
||||
id: item.id,
|
||||
fileName: finalFile,
|
||||
reason: {
|
||||
type: 'file',
|
||||
@@ -1416,7 +1442,7 @@ export const AddDownloadsModal = () => {
|
||||
resolution: 'rename' | 'replace' | 'skip';
|
||||
replaceFingerprint?: string;
|
||||
}[],
|
||||
destinationOverrides: Record<number, string> = {}
|
||||
destinationOverrides: Record<string | number, string> = {}
|
||||
) => {
|
||||
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
|
||||
item.selected === false ? null : item
|
||||
@@ -1426,10 +1452,14 @@ export const AddDownloadsModal = () => {
|
||||
|
||||
if (resolutions) {
|
||||
for (const res of resolutions) {
|
||||
const idx = parseInt(res.id);
|
||||
const idx = itemsToAdd.findIndex((candidate, index) =>
|
||||
candidate !== null && (candidate.id === res.id || String(index) === res.id)
|
||||
);
|
||||
if (idx === -1) continue;
|
||||
const item = itemsToAdd[idx];
|
||||
if (!item) continue;
|
||||
const conflict = conflicts.find(c => c.id === res.id);
|
||||
const itemOverride = destinationOverrides[item.id] ?? destinationOverrides[idx];
|
||||
|
||||
if (res.resolution === 'skip') {
|
||||
itemsToAdd[idx] = null;
|
||||
@@ -1442,7 +1472,7 @@ export const AddDownloadsModal = () => {
|
||||
finalFile,
|
||||
finalLocation,
|
||||
useSharedDestination,
|
||||
destinationOverrides[idx],
|
||||
itemOverride,
|
||||
item.isTorrent === true
|
||||
);
|
||||
|
||||
@@ -1455,11 +1485,12 @@ export const AddDownloadsModal = () => {
|
||||
const candidateFile = candidate.isMedia
|
||||
? mediaFileNameForSelectedFormat(candidate.file, candidate)
|
||||
: canonicalizeDownloadFileName(candidate.file);
|
||||
const candidateOverride = destinationOverrides[candidate.id] ?? destinationOverrides[candidateIndex];
|
||||
const candidateLocation = await destinationForFile(
|
||||
candidateFile,
|
||||
finalLocation,
|
||||
useSharedDestination,
|
||||
destinationOverrides[candidateIndex],
|
||||
candidateOverride,
|
||||
candidate.isTorrent === true
|
||||
);
|
||||
batchTargets.push({ location: candidateLocation, fileName: candidateFile });
|
||||
@@ -1522,7 +1553,7 @@ export const AddDownloadsModal = () => {
|
||||
finalFile,
|
||||
finalLocation,
|
||||
useSharedDestination,
|
||||
destinationOverrides[idx],
|
||||
itemOverride,
|
||||
item.isTorrent === true
|
||||
);
|
||||
const store = useDownloadStore.getState();
|
||||
@@ -1530,7 +1561,7 @@ export const AddDownloadsModal = () => {
|
||||
? store.downloads.find(download => download.id === conflict.existingDownloadId)
|
||||
: undefined;
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
if (!existingItem && !conflict?.existingDownloadId) {
|
||||
if (!existingItem) {
|
||||
for (const download of store.downloads) {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(currentSettings, download.category);
|
||||
@@ -1549,58 +1580,89 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem && isTransferLocked(existingItem.status)) {
|
||||
throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName }));
|
||||
}
|
||||
if (existingItem && isTransferLocked(existingItem.status)) {
|
||||
throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName }));
|
||||
}
|
||||
|
||||
if (!existingItem) {
|
||||
if (!res.replaceFingerprint || conflict?.existingDownloadId) {
|
||||
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
||||
}
|
||||
itemsToAdd[idx] = {
|
||||
...item,
|
||||
replaceExistingFingerprint: res.replaceFingerprint
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
||||
const mediaFormatChanged = item.isMedia
|
||||
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
||||
const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
|
||||
if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
|
||||
// Completed replacements must remove the old file so the
|
||||
// new transfer cannot be treated as an already-complete
|
||||
// aria2 target. A torrent replacement also needs a fresh
|
||||
// identity because its cached metadata is keyed by the
|
||||
// new row ID and its output contract differs from a normal
|
||||
// file transfer. Unfinished ordinary rows use the in-place
|
||||
// path to preserve their resumable assets and progress.
|
||||
await store.removeDownload(existingItem.id, true, false);
|
||||
} else {
|
||||
const contextUrl = requestContextUrlForRow(item);
|
||||
const replaced = await store.replaceDownload(existingItem.id, {
|
||||
url: item.downloadUrl,
|
||||
username: useAuth ? username.trim() : undefined,
|
||||
password: useAuth ? password.trim() : undefined,
|
||||
headers: headersForRow(contextUrl) || undefined,
|
||||
cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
lastError: undefined
|
||||
}, pendingAction);
|
||||
if (!replaced) {
|
||||
const rejected = useDownloadStore.getState().downloads.find(download => download.id === existingItem.id);
|
||||
throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart));
|
||||
}
|
||||
if (!existingItem) {
|
||||
let diskTargetKind: string | null = null;
|
||||
let diskTargetFingerprint: string | undefined;
|
||||
let diskTargetOwner: string | undefined;
|
||||
try {
|
||||
const targetInfo = await invoke('inspect_download_target', {
|
||||
path: await resolveDownloadFilePath(itemLocation, finalFile)
|
||||
});
|
||||
diskTargetKind = targetInfo.kind;
|
||||
diskTargetFingerprint = targetInfo.fingerprint;
|
||||
diskTargetOwner = targetInfo.ownedBy;
|
||||
} catch (e) {
|
||||
console.error("Failed to check if file exists on disk:", e);
|
||||
}
|
||||
|
||||
// The existing row was updated in place; do not create a
|
||||
// second identity for the same filename.
|
||||
itemsToAdd[idx] = null;
|
||||
updatedCount += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (diskTargetKind === 'regularFile' && diskTargetFingerprint && !diskTargetOwner) {
|
||||
itemsToAdd[idx] = {
|
||||
...item,
|
||||
replaceExistingFingerprint: diskTargetFingerprint
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (diskTargetKind === 'missing' || !diskTargetKind) {
|
||||
itemsToAdd[idx] = {
|
||||
...item,
|
||||
replaceExistingFingerprint: undefined
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (res.replaceFingerprint && diskTargetFingerprint === res.replaceFingerprint) {
|
||||
itemsToAdd[idx] = {
|
||||
...item,
|
||||
replaceExistingFingerprint: res.replaceFingerprint
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
|
||||
}
|
||||
const incomingMediaFormat = mediaFormatSelectorForRow(item);
|
||||
const mediaFormatChanged = item.isMedia
|
||||
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
|
||||
const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
|
||||
if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
|
||||
// Completed replacements must remove the old file so the
|
||||
// new transfer cannot be treated as an already-complete
|
||||
// aria2 target. A torrent replacement also needs a fresh
|
||||
// identity because its cached metadata is keyed by the
|
||||
// new row ID and its output contract differs from a normal
|
||||
// file transfer. Unfinished ordinary rows use the in-place
|
||||
// path to preserve their resumable assets and progress.
|
||||
await store.removeDownload(existingItem.id, true, false);
|
||||
} else {
|
||||
const contextUrl = requestContextUrlForRow(item);
|
||||
const replaced = await store.replaceDownload(existingItem.id, {
|
||||
url: item.downloadUrl,
|
||||
username: useAuth ? username.trim() : undefined,
|
||||
password: useAuth ? password.trim() : undefined,
|
||||
headers: headersForRow(contextUrl, item.isMedia) || undefined,
|
||||
cookies: cookiesForRow(contextUrl, item.downloadUrl, item.isMedia) || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
lastError: undefined
|
||||
}, pendingAction);
|
||||
if (!replaced) {
|
||||
const rejected = useDownloadStore.getState().downloads.find(download => download.id === existingItem.id);
|
||||
throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart));
|
||||
}
|
||||
|
||||
// The existing row was updated in place; do not create a
|
||||
// second identity for the same filename.
|
||||
itemsToAdd[idx] = null;
|
||||
updatedCount += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
const failures: string[] = [];
|
||||
@@ -1629,8 +1691,8 @@ export const AddDownloadsModal = () => {
|
||||
id,
|
||||
cache: true,
|
||||
proxy: proxy ?? undefined,
|
||||
headers: headersForRow(contextUrl) || undefined,
|
||||
cookies: cookiesForRow(contextUrl, item.sourceUrl) || undefined,
|
||||
headers: headersForRow(contextUrl, item.isMedia) || undefined,
|
||||
cookies: cookiesForRow(contextUrl, item.sourceUrl, item.isMedia) || undefined,
|
||||
cookieScopes: requestContextForUrl(contextUrl)?.cookieScopes || undefined,
|
||||
torrent: true
|
||||
});
|
||||
@@ -1642,6 +1704,7 @@ export const AddDownloadsModal = () => {
|
||||
: canonicalizeDownloadFileName(item.file);
|
||||
let formatSelector = mediaFormatSelectorForRow(item);
|
||||
const category = categoryForFileName(finalFile, item.isTorrent === true);
|
||||
const itemOverride = destinationOverrides[item.id] ?? destinationOverrides[itemIndex];
|
||||
const added = await addDownload({
|
||||
id,
|
||||
url: item.downloadUrl,
|
||||
@@ -1658,18 +1721,18 @@ export const AddDownloadsModal = () => {
|
||||
sftpHostKeyMd: !item.isTorrent && item.sourceUrl.trim().toLowerCase().startsWith('sftp:')
|
||||
? sftpHostKeyMd.trim() || undefined
|
||||
: undefined,
|
||||
headers: item.isTorrent ? undefined : headersForRow(contextUrl) || undefined,
|
||||
headers: item.isTorrent ? undefined : headersForRow(contextUrl, item.isMedia) || undefined,
|
||||
checksum: checksumEnabled && checksumValue.trim()
|
||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||
: undefined,
|
||||
cookies: item.isTorrent ? undefined : cookiesForRow(contextUrl, item.downloadUrl) || undefined,
|
||||
cookies: item.isTorrent ? undefined : cookiesForRow(contextUrl, item.downloadUrl, item.isMedia) || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
destination: useSharedDestination || saveInDedicatedFolder || destinationOverrides[itemIndex]
|
||||
destination: useSharedDestination || saveInDedicatedFolder || itemOverride
|
||||
? await destinationForFile(
|
||||
finalFile,
|
||||
finalLocation,
|
||||
useSharedDestination,
|
||||
destinationOverrides[itemIndex],
|
||||
itemOverride,
|
||||
item.isTorrent === true
|
||||
)
|
||||
: undefined,
|
||||
|
||||
@@ -50,6 +50,7 @@ interface DownloadItemProps {
|
||||
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
|
||||
onQueueDragStart: (id: string, event: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
|
||||
onRowKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>, download: DownloadItemType) => void;
|
||||
}
|
||||
|
||||
export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
@@ -74,6 +75,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
onMoveInQueue,
|
||||
onQueueDragStart,
|
||||
onClick,
|
||||
onRowKeyDown,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||
@@ -518,7 +520,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX || rect.left;
|
||||
const y = e.clientY || rect.bottom + 4;
|
||||
setContextMenu({ x, y, id: download.id });
|
||||
}}
|
||||
className="app-icon-button main-control-button"
|
||||
title={t($ => $.downloads.actions.options)}
|
||||
@@ -584,11 +589,21 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onMoveInQueue(download.id, event.key === 'ArrowUp' ? 'up' : 'down');
|
||||
return;
|
||||
}
|
||||
onRowKeyDown?.(event, download);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
||||
const isKeyboard = (e.clientX === 0 && e.clientY === 0) || (e.button === 0 && e.detail === 0);
|
||||
let x = e.clientX;
|
||||
let y = e.clientY;
|
||||
if (isKeyboard && rowRef.current) {
|
||||
const rect = rowRef.current.getBoundingClientRect();
|
||||
x = rect.left + 40;
|
||||
y = rect.top + rect.height / 2;
|
||||
}
|
||||
setContextMenu({ x, y, id: download.id });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -739,6 +739,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
persistColumnWidths(widths);
|
||||
persistColumnOrder(order);
|
||||
persistColumnAlignments(alignments);
|
||||
setQueueSortConfig(null);
|
||||
setColumnMenu(null);
|
||||
};
|
||||
|
||||
@@ -749,6 +750,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
};
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (contextMenuRef.current || columnMenuRef.current) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
setContextMenu(null);
|
||||
setColumnMenu(null);
|
||||
}
|
||||
@@ -1752,8 +1757,19 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
}, [sortedDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
setContextMenu(null);
|
||||
setColumnMenu(null);
|
||||
setQueueSortConfig(null);
|
||||
}, [filter, isQueueFilter]);
|
||||
|
||||
const writeToClipboard = useCallback(async (text: string): Promise<void> => {
|
||||
try {
|
||||
await writeClipboardText(text);
|
||||
} catch {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
|
||||
if (suppressQueueClickRef.current) {
|
||||
clearQueueClickSuppression();
|
||||
@@ -1789,6 +1805,82 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
setContextMenu(menu);
|
||||
}, [clampMenuPosition]);
|
||||
|
||||
const handleRowKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>, item: DownloadItem) => {
|
||||
if (e.target instanceof Element && e.target.closest('button, a, input, textarea, select')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const row = queueRowForId(item.id);
|
||||
const rect = row?.getBoundingClientRect();
|
||||
const x = rect ? rect.left + 40 : 100;
|
||||
const y = rect ? rect.top + rect.height / 2 : 100;
|
||||
handleContextMenu({ x, y, id: item.id });
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDownloadDoubleClick(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const nextSelection = updateDownloadSelection({
|
||||
orderedIds: sortedDownloadsRef.current.map(d => d.id),
|
||||
selectedIds: selectedIdsRef.current,
|
||||
lastSelectedId: lastSelectedIdRef.current,
|
||||
targetId: item.id,
|
||||
extendRange: e.shiftKey,
|
||||
toggle: true,
|
||||
});
|
||||
setSelectedIds(nextSelection.selectedIds);
|
||||
setLastSelectedId(nextSelection.lastSelectedId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
(e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End')
|
||||
) {
|
||||
const items = sortedDownloadsRef.current;
|
||||
const currentIndex = items.findIndex(d => d.id === item.id);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
let targetIndex = currentIndex;
|
||||
if (e.key === 'ArrowDown') targetIndex = Math.min(items.length - 1, currentIndex + 1);
|
||||
else if (e.key === 'ArrowUp') targetIndex = Math.max(0, currentIndex - 1);
|
||||
else if (e.key === 'Home') targetIndex = 0;
|
||||
else if (e.key === 'End') targetIndex = items.length - 1;
|
||||
|
||||
if (targetIndex !== currentIndex) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const targetItem = items[targetIndex];
|
||||
const nextSelection = updateDownloadSelection({
|
||||
orderedIds: items.map(d => d.id),
|
||||
selectedIds: selectedIdsRef.current,
|
||||
lastSelectedId: lastSelectedIdRef.current,
|
||||
targetId: targetItem.id,
|
||||
extendRange: e.shiftKey,
|
||||
toggle: false,
|
||||
});
|
||||
setSelectedIds(nextSelection.selectedIds);
|
||||
setLastSelectedId(nextSelection.lastSelectedId);
|
||||
const targetElement = queueRowForId(targetItem.id);
|
||||
targetElement?.focus({ preventScroll: false });
|
||||
targetElement?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [handleContextMenu, handleDownloadDoubleClick]);
|
||||
|
||||
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
|
||||
if (
|
||||
queueDragStateRef.current ||
|
||||
@@ -1821,15 +1913,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
}, [moveInQueue, showInteractionError, t]);
|
||||
|
||||
const handleSort = (column: DownloadSortColumn) => {
|
||||
const update = (current: DownloadSortConfig | null): DownloadSortConfig =>
|
||||
current?.column === column
|
||||
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { column, direction: 'asc' };
|
||||
|
||||
if (isQueueFilter) {
|
||||
setQueueSortConfig(update);
|
||||
setQueueSortConfig(current => {
|
||||
if (current?.column !== column) {
|
||||
return { column, direction: 'asc' };
|
||||
}
|
||||
if (current.direction === 'asc') {
|
||||
return { column, direction: 'desc' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
} else {
|
||||
setSortConfig(current => update(current));
|
||||
setSortConfig(current =>
|
||||
current?.column === column
|
||||
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { column, direction: 'asc' }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2302,6 +2401,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
onMoveInQueue={handleMoveInQueue}
|
||||
onQueueDragStart={stableHandleQueueDragStart}
|
||||
onClick={handleItemClick}
|
||||
onRowKeyDown={handleRowKeyDown}
|
||||
/>
|
||||
))}
|
||||
<div className="flex-1 min-h-0 bg-transparent pointer-events-none" />
|
||||
@@ -2346,6 +2446,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
className="download-column-menu app-modal fixed z-[70] min-w-[188px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
|
||||
style={{ top: columnMenuPosition?.y, left: columnMenuPosition?.x }}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const menu = columnMenuRef.current;
|
||||
if (!menu) return;
|
||||
const buttons = Array.from(menu.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));
|
||||
if (buttons.length === 0) return;
|
||||
const activeIdx = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const nextIdx = e.key === 'ArrowDown'
|
||||
? (activeIdx + 1) % buttons.length
|
||||
: (activeIdx <= 0 ? buttons.length - 1 : activeIdx - 1);
|
||||
buttons[nextIdx]?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="download-column-menu-title px-3 py-1.5 text-text-muted">
|
||||
{columnLabels.get(columnMenu.key) ?? columnMenu.key}
|
||||
@@ -2394,6 +2509,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
left: contextMenuPosition?.x,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const menu = contextMenuRef.current;
|
||||
if (!menu) return;
|
||||
const buttons = Array.from(menu.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));
|
||||
if (buttons.length === 0) return;
|
||||
const activeIdx = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const nextIdx = e.key === 'ArrowDown'
|
||||
? (activeIdx + 1) % buttons.length
|
||||
: (activeIdx <= 0 ? buttons.length - 1 : activeIdx - 1);
|
||||
buttons[nextIdx]?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedIds.size > 1 ? (() => {
|
||||
const selectedDownloads = Array.from(selectedIds)
|
||||
@@ -2457,7 +2587,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
.map(id => downloads.find(d => d.id === id)?.url)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
navigator.clipboard.writeText(urls).catch(error => {
|
||||
writeToClipboard(urls).catch(error => {
|
||||
showInteractionError(t($ => $.downloadTable.copyAddressesFailed), error);
|
||||
});
|
||||
}}
|
||||
@@ -2564,7 +2694,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
navigator.clipboard.writeText(contextItem.url).catch(error => {
|
||||
writeToClipboard(contextItem.url).catch(error => {
|
||||
showInteractionError(t($ => $.downloadTable.copyAddressFailed), error);
|
||||
});
|
||||
}}
|
||||
@@ -2579,7 +2709,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
setContextMenu(null);
|
||||
try {
|
||||
const magnet = await invoke('get_torrent_magnet_link', { id: contextItem.id });
|
||||
await writeClipboardText(magnet);
|
||||
await writeToClipboard(magnet);
|
||||
} catch (error) {
|
||||
showInteractionError(t($ => $.downloadTable.copyMagnetFailed), error);
|
||||
}
|
||||
@@ -2600,7 +2730,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullPath);
|
||||
await writeToClipboard(fullPath);
|
||||
} catch (error) {
|
||||
showInteractionError(t($ => $.downloadTable.copyPathFailed), error);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREA
|
||||
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
|
||||
import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle';
|
||||
import { isTorrentWaitingForPeers } from '../utils/torrentPresentation';
|
||||
import { copyTorrentFilePath } from '../utils/torrentFilePath';
|
||||
import { WindowControls } from './WindowControls';
|
||||
import {
|
||||
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
||||
@@ -1324,7 +1325,7 @@ export const PropertiesWindowApp = () => {
|
||||
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{connectionHeaderLabel}</span>{connectionValue}</div></div>}
|
||||
{isTorrent && <>
|
||||
<div className="properties-metric-card"><Upload size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentUploaded)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
||||
<div className="properties-metric-card"><Activity size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentRatio)}</span><strong className="properties-metric-value">{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
|
||||
<div className="properties-metric-card"><Activity size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentRatio)}</span><strong className="properties-metric-value">{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, snapshot.appearance.locale)}</strong></div></div>
|
||||
</>}
|
||||
</div>
|
||||
<div className="properties-window-destination" title={snapshot.destination || undefined}><MapPin size={13} /><span>{snapshot.destination || '—'}</span></div>
|
||||
@@ -1447,7 +1448,7 @@ export const PropertiesWindowApp = () => {
|
||||
|
||||
{activeTab === 'files' && isTorrent && <div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { const all = fileProgress?.files.map(file => file.index) ?? []; setSelectedFiles(all); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionAll)}</button><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { setSelectedFiles([]); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionClear)}</button><button type="button" className="app-button px-3 text-xs" aria-busy={diagnosticsLoading || diagnosticsRefreshing} onClick={() => downloadId && void refreshDiagnostics('files', downloadId, true)}><RefreshCw size={14} className={diagnosticsLoading || diagnosticsRefreshing ? 'animate-spin motion-reduce:animate-none' : undefined} />{t($ => $.properties.torrentFileProgressRefresh)}</button></div>
|
||||
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index} ${file.relativePath}`} /></td><td className="p-2">{file.index}</td><td className="max-w-[420px] truncate p-2" dir="auto">{file.relativePath}</td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="properties-data-value p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
|
||||
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index} ${file.relativePath}`} /></td><td className="p-2">{file.index}</td><td className="max-w-[420px] p-2" dir="auto" title={file.relativePath}><div className="flex items-center gap-1.5 min-w-0"><span className="truncate flex-1 min-w-0">{file.relativePath}</span><button type="button" className="app-icon-button shrink-0 opacity-70 hover:opacity-100 focus-visible:opacity-100" aria-label={t($ => $.downloadTable.copyFilePath)} title={t($ => $.downloadTable.copyFilePath)} onClick={event => { event.preventDefault(); event.stopPropagation(); void copyTorrentFilePath(file.relativePath, writeClipboardText).then(() => setNotice(t($ => $.logs.copied))).catch(() => setErrorMessage(t($ => $.downloadTable.copyPathFailed))); }}><Copy size={12} aria-hidden="true" /></button></div></td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="properties-data-value p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
|
||||
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressLoading)}</p>}
|
||||
{diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressUnavailable)}</p>}
|
||||
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
|
||||
|
||||
@@ -205,6 +205,8 @@ export default function SchedulerView() {
|
||||
variant: 'success'
|
||||
});
|
||||
} else {
|
||||
useSettingsStore.getState().setSchedulerRunning(false);
|
||||
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
|
||||
addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' });
|
||||
}
|
||||
};
|
||||
@@ -213,17 +215,30 @@ export default function SchedulerView() {
|
||||
const generation = beginSchedulerControl();
|
||||
const savedQueueIds = savedSettings.selectedQueueIds
|
||||
.filter(queueId => availableQueueIds.has(queueId));
|
||||
const savedQueueSet = new Set(savedQueueIds);
|
||||
const trackedIdsOutsideSavedQueues = useSettingsStore.getState().schedulerActiveDownloadIds
|
||||
.filter(id => {
|
||||
const queueId = useDownloadStore.getState().downloads.find(download => download.id === id)?.queueId || MAIN_QUEUE_ID;
|
||||
return !savedQueueSet.has(queueId);
|
||||
});
|
||||
const targetQueueIds = new Set<string>([
|
||||
...savedQueueIds,
|
||||
...effectiveSelectedQueueIds
|
||||
]);
|
||||
const trackedDownloadIds = useSettingsStore.getState().schedulerActiveDownloadIds;
|
||||
const downloads = useDownloadStore.getState().downloads;
|
||||
for (const id of trackedDownloadIds) {
|
||||
const queueId = downloads.find(d => d.id === id)?.queueId || MAIN_QUEUE_ID;
|
||||
if (availableQueueIds.has(queueId)) {
|
||||
targetQueueIds.add(queueId);
|
||||
}
|
||||
}
|
||||
const targetQueueList = Array.from(targetQueueIds);
|
||||
const targetQueueSet = new Set(targetQueueList);
|
||||
const trackedIdsOutsideQueues = trackedDownloadIds.filter(id => {
|
||||
const queueId = downloads.find(d => d.id === id)?.queueId || MAIN_QUEUE_ID;
|
||||
return !targetQueueSet.has(queueId);
|
||||
});
|
||||
|
||||
const counts = await Promise.all(
|
||||
savedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
|
||||
targetQueueList.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
|
||||
);
|
||||
const directPauseResults = await Promise.allSettled(
|
||||
trackedIdsOutsideSavedQueues.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
trackedIdsOutsideQueues.map(id => useDownloadStore.getState().pauseDownload(id))
|
||||
);
|
||||
if (!isSchedulerControlCurrent(generation)) return;
|
||||
const count = counts.reduce((total, queueCount) => total + queueCount, 0)
|
||||
|
||||
@@ -466,6 +466,12 @@ const engineRunId = useRef(0);
|
||||
const [maxConcurrentDownloadsInput, setMaxConcurrentDownloadsInput] = useState(
|
||||
() => String(settings.maxConcurrentDownloads)
|
||||
);
|
||||
const [maxAutomaticRetriesInput, setMaxAutomaticRetriesInput] = useState(
|
||||
() => String(settings.maxAutomaticRetries)
|
||||
);
|
||||
const [minimumNormalDownloadSpeedKiBInput, setMinimumNormalDownloadSpeedKiBInput] = useState(
|
||||
() => String(settings.minimumNormalDownloadSpeedKiB)
|
||||
);
|
||||
const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort));
|
||||
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
|
||||
() => String(settings.torrentMaxOpenFiles)
|
||||
@@ -490,6 +496,14 @@ const engineRunId = useRef(0);
|
||||
setMaxConcurrentDownloadsInput(String(settings.maxConcurrentDownloads));
|
||||
}, [settings.maxConcurrentDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxAutomaticRetriesInput(String(settings.maxAutomaticRetries));
|
||||
}, [settings.maxAutomaticRetries]);
|
||||
|
||||
useEffect(() => {
|
||||
setMinimumNormalDownloadSpeedKiBInput(String(settings.minimumNormalDownloadSpeedKiB));
|
||||
}, [settings.minimumNormalDownloadSpeedKiB]);
|
||||
|
||||
useEffect(() => {
|
||||
setProxyPortInput(String(settings.proxyPort));
|
||||
}, [settings.proxyPort]);
|
||||
@@ -1068,14 +1082,24 @@ runEngineChecks(false);
|
||||
</div>
|
||||
<input
|
||||
type="number" min="0" max="10"
|
||||
value={settings.maxAutomaticRetries}
|
||||
onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))}
|
||||
onBlur={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
if (val < 0) settings.setMaxAutomaticRetries(0);
|
||||
if (val > 10) settings.setMaxAutomaticRetries(10);
|
||||
value={maxAutomaticRetriesInput}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setMaxAutomaticRetriesInput(value);
|
||||
if (value !== '' && Number.isFinite(Number(value))) {
|
||||
settings.setMaxAutomaticRetries(Number(value));
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => commitBoundedIntegerInput(
|
||||
e.target.value,
|
||||
settings.maxAutomaticRetries,
|
||||
0,
|
||||
10,
|
||||
settings.setMaxAutomaticRetries,
|
||||
setMaxAutomaticRetriesInput
|
||||
)}
|
||||
className="app-control w-24 text-center"
|
||||
aria-label={t($ => $.settings.downloads.automaticRetries)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mac-settings-row">
|
||||
@@ -1085,8 +1109,22 @@ runEngineChecks(false);
|
||||
</div>
|
||||
<input
|
||||
type="number" min="0" max="1048576"
|
||||
value={settings.minimumNormalDownloadSpeedKiB}
|
||||
onChange={(event) => settings.setMinimumNormalDownloadSpeedKiB(Number(event.target.value))}
|
||||
value={minimumNormalDownloadSpeedKiBInput}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setMinimumNormalDownloadSpeedKiBInput(value);
|
||||
if (value !== '' && Number.isFinite(Number(value))) {
|
||||
settings.setMinimumNormalDownloadSpeedKiB(Number(value));
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => commitBoundedIntegerInput(
|
||||
event.target.value,
|
||||
settings.minimumNormalDownloadSpeedKiB,
|
||||
0,
|
||||
1048576,
|
||||
settings.setMinimumNormalDownloadSpeedKiB,
|
||||
setMinimumNormalDownloadSpeedKiBInput
|
||||
)}
|
||||
className="app-control w-24 text-center"
|
||||
aria-label={t($ => $.settings.downloads.minimumNormalDownloadSpeed)}
|
||||
/>
|
||||
@@ -2260,7 +2298,7 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
|
||||
}}
|
||||
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
<RefreshCw size={11} /> Regenerate
|
||||
<RefreshCw size={11} /> {t($ => $.settings.integrations.regenerateToken)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,10 +21,11 @@ interface SidebarProps {
|
||||
selectedFilter: SidebarFilter;
|
||||
onToggleSidebar?: () => void;
|
||||
onSelectFilter: (filter: SidebarFilter) => void;
|
||||
toggleButtonRef?: React.Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const { selectedFilter, onToggleSidebar, onSelectFilter } = props;
|
||||
const { selectedFilter, onToggleSidebar, onSelectFilter, toggleButtonRef } = props;
|
||||
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
|
||||
const {
|
||||
activeView,
|
||||
@@ -116,7 +117,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
useEffect(() => {
|
||||
const handleCloseMenu = () => setContextMenu(null);
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setContextMenu(null);
|
||||
if (event.key === 'Escape' && contextMenuRef.current) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenu(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', handleCloseMenu);
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
@@ -388,6 +393,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
ref={toggleButtonRef}
|
||||
data-tauri-drag-region="false"
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
onMouseDown={event => event.stopPropagation()}
|
||||
onClick={onToggleSidebar ?? toggleSidebar}
|
||||
className="sidebar-toggle-button"
|
||||
title={t($ => $.actions.hideSidebar)}
|
||||
|
||||
@@ -1121,6 +1121,7 @@ const common = {
|
||||
tokenCopied: 'Token copied to clipboard!',
|
||||
tokenCopyFailed: 'Could not copy token: {{detail}}',
|
||||
pairingTokenRegenerated: 'Pairing token regenerated',
|
||||
regenerateToken: 'Regenerate Token',
|
||||
regenerateFailed: 'Could not regenerate pairing token: {{detail}}',
|
||||
getExtension: 'Get Extension',
|
||||
extensionDescription: 'Install Firelink Companion for Firefox or Chromium browsers.',
|
||||
|
||||
@@ -1121,6 +1121,7 @@ const fa = {
|
||||
tokenCopied: 'توکن در کلیپبورد کپی شد!',
|
||||
tokenCopyFailed: 'توکن کپی نشد: {{detail}}',
|
||||
pairingTokenRegenerated: 'توکن جفتسازی دوباره ایجاد شد',
|
||||
regenerateToken: 'ایجاد دوباره توکن',
|
||||
regenerateFailed: 'ایجاد دوباره توکن جفتسازی ناموفق بود: {{detail}}',
|
||||
getExtension: 'دریافت افزونه',
|
||||
extensionDescription: 'Firelink Companion را برای مرورگرهای Firefox یا Chromium نصب کنید.',
|
||||
|
||||
@@ -1121,6 +1121,7 @@ const he = {
|
||||
tokenCopied: 'האסימון הועתק ללוח!',
|
||||
tokenCopyFailed: 'לא ניתן להעתיק את האסימון: {{detail}}',
|
||||
pairingTokenRegenerated: 'אסימון הצימוד נוצר מחדש',
|
||||
regenerateToken: 'צור אסימון מחדש',
|
||||
regenerateFailed: 'לא ניתן ליצור מחדש אסימון צימוד: {{detail}}',
|
||||
getExtension: 'קבלת התוסף',
|
||||
extensionDescription: 'התקן את Firelink Companion עבור דפדפני Firefox או Chromium.',
|
||||
|
||||
@@ -1121,6 +1121,7 @@ const ru = {
|
||||
tokenCopied: 'Токен скопирован в буфер обмена!',
|
||||
tokenCopyFailed: 'Не удалось скопировать токен: {{detail}}',
|
||||
pairingTokenRegenerated: 'Токен сопряжения сгенерирован заново',
|
||||
regenerateToken: 'Сгенерировать токен заново',
|
||||
regenerateFailed: 'Не удалось сгенерировать токен сопряжения заново: {{detail}}',
|
||||
getExtension: 'Получить расширение',
|
||||
extensionDescription: 'Установите Firelink Companion для браузеров Firefox или Chromium.',
|
||||
|
||||
@@ -1121,6 +1121,7 @@ const uk = {
|
||||
tokenCopied: 'Токен скопійовано в буфер обміну!',
|
||||
tokenCopyFailed: 'Не вдалося скопіювати токен: {{detail}}',
|
||||
pairingTokenRegenerated: 'Токен підключення згенеровано наново',
|
||||
regenerateToken: 'Згенерувати токен наново',
|
||||
regenerateFailed: 'Не вдалося згенерувати токен підключення наново: {{detail}}',
|
||||
getExtension: 'Отримати розширення',
|
||||
extensionDescription: 'Встановіть Firelink Companion для браузерів Firefox або Chromium.',
|
||||
|
||||
@@ -1121,6 +1121,7 @@ const zhCN = {
|
||||
tokenCopied: '令牌已复制到剪贴板!',
|
||||
tokenCopyFailed: '无法复制令牌:{{detail}}',
|
||||
pairingTokenRegenerated: '配对令牌已重新生成',
|
||||
regenerateToken: '重新生成令牌',
|
||||
regenerateFailed: '无法重新生成配对令牌:{{detail}}',
|
||||
getExtension: '获取扩展',
|
||||
extensionDescription: '安装适用于 Firefox 或 Chromium 浏览器的 Firelink Companion。',
|
||||
|
||||
@@ -2701,6 +2701,7 @@ html[data-list-density="relaxed"] {
|
||||
color: hsl(var(--text-secondary));
|
||||
background: transparent;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.sidebar-toggle-button:hover {
|
||||
|
||||
@@ -154,6 +154,7 @@ type CommandMap = {
|
||||
get_file_category: { args: { filename: string }; result: DownloadCategory };
|
||||
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
|
||||
get_supported_media_domains: { args: undefined; result: string[] };
|
||||
is_supported_media: { args: { url: string }; result: boolean };
|
||||
db_save_settings: { args: { data: string }; result: void };
|
||||
db_load_settings: { args: undefined; result: string | null };
|
||||
canonicalize_torrent_network_setting: {
|
||||
|
||||
@@ -641,4 +641,56 @@ describe('Properties window bridge', () => {
|
||||
expect(assigned).toBe(unlisten);
|
||||
expect(unlisten).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('strictly validates numeric boundaries, speed limits, and tracker syntax in patch copies', () => {
|
||||
const baseItem = { isTorrent: false, status: 'ready' as const };
|
||||
const torrentItem = { isTorrent: true, status: 'paused' as const };
|
||||
|
||||
// Connections bounds (1 to 16, whole numbers)
|
||||
expect(() => copyEditablePropertiesPatch({ connections: 0 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
|
||||
expect(() => copyEditablePropertiesPatch({ connections: 17 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
|
||||
expect(() => copyEditablePropertiesPatch({ connections: 1.5 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
|
||||
expect(() => copyEditablePropertiesPatch({ connections: Number.NaN }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
|
||||
expect(copyEditablePropertiesPatch({ connections: 1 }, baseItem)).toMatchObject({ connections: 1 });
|
||||
expect(copyEditablePropertiesPatch({ connections: 16 }, baseItem)).toMatchObject({ connections: 16 });
|
||||
|
||||
// Torrent max peers bounds (0 to 1000, whole numbers)
|
||||
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: -1 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
|
||||
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: 1001 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
|
||||
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: 10.5 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
|
||||
expect(copyEditablePropertiesPatch({ torrentMaxPeers: 0 }, torrentItem)).toMatchObject({ torrentMaxPeers: 0 });
|
||||
expect(copyEditablePropertiesPatch({ torrentMaxPeers: 1000 }, torrentItem)).toMatchObject({ torrentMaxPeers: 1000 });
|
||||
|
||||
// Speed limit normalization and invalid formats
|
||||
expect(() => copyEditablePropertiesPatch({ speedLimit: 'invalid' }, baseItem)).toThrow('Invalid download speed limit');
|
||||
expect(() => copyEditablePropertiesPatch({ speedLimit: '0M' }, baseItem)).toThrow('Invalid download speed limit');
|
||||
expect(() => copyEditablePropertiesPatch({ speedLimit: '-5M' }, baseItem)).toThrow('Invalid download speed limit');
|
||||
expect(copyEditablePropertiesPatch({ speedLimit: '2M' }, baseItem)).toMatchObject({ speedLimit: '2M' });
|
||||
expect(copyEditablePropertiesPatch({ speedLimit: ' 500K ' }, baseItem)).toMatchObject({ speedLimit: '500K' });
|
||||
expect(copyEditablePropertiesPatch({ speedLimit: '' }, baseItem).speedLimit).toBeUndefined();
|
||||
|
||||
// Torrent seed settings
|
||||
expect(() => copyEditablePropertiesPatch({ torrentSeedTime: -1 }, torrentItem)).toThrow('Invalid torrentSeedTime');
|
||||
expect(() => copyEditablePropertiesPatch({ torrentSeedTime: Number.NaN }, torrentItem)).toThrow('Invalid torrentSeedTime');
|
||||
expect(() => copyEditablePropertiesPatch({ torrentSeedRatio: -0.1 }, torrentItem)).toThrow('Invalid torrentSeedRatio');
|
||||
expect(copyEditablePropertiesPatch({ torrentSeedTime: 0 }, torrentItem)).toMatchObject({ torrentSeedTime: 0 });
|
||||
expect(copyEditablePropertiesPatch({ torrentSeedRatio: 1.5 }, torrentItem)).toMatchObject({ torrentSeedRatio: 1.5 });
|
||||
|
||||
// Torrent stop timeout
|
||||
expect(() => copyEditablePropertiesPatch({ torrentStopTimeout: -1 }, torrentItem)).toThrow('Invalid torrentStopTimeout');
|
||||
expect(() => copyEditablePropertiesPatch({ torrentStopTimeout: 7 * 24 * 60 * 60 + 1 }, torrentItem)).toThrow('Invalid torrentStopTimeout');
|
||||
expect(copyEditablePropertiesPatch({ torrentStopTimeout: 3600 }, torrentItem)).toMatchObject({ torrentStopTimeout: 3600 });
|
||||
|
||||
// Torrent trackers validation
|
||||
expect(() => copyEditablePropertiesPatch({ torrentTrackers: 'not-a-url' }, torrentItem)).toThrow('Invalid Torrent tracker list');
|
||||
expect(() => copyEditablePropertiesPatch({ torrentTrackers: 'ftp://unsupported.tracker/announce' }, torrentItem)).toThrow('Invalid Torrent tracker list');
|
||||
expect(copyEditablePropertiesPatch({ torrentTrackers: 'https://tracker.example/announce' }, torrentItem))
|
||||
.toMatchObject({ torrentTrackers: 'https://tracker.example/announce' });
|
||||
|
||||
// Torrent policies
|
||||
expect(() => copyEditablePropertiesPatch({ torrentEncryptionPolicy: 'invalid' as any }, torrentItem)).toThrow('Invalid torrentEncryptionPolicy');
|
||||
expect(() => copyEditablePropertiesPatch({ torrentFileAllocation: 'invalid' as any }, torrentItem)).toThrow('Invalid torrentFileAllocation');
|
||||
expect(copyEditablePropertiesPatch({ torrentEncryptionPolicy: 'require-crypto' }, torrentItem)).toMatchObject({ torrentEncryptionPolicy: 'require-crypto' });
|
||||
expect(copyEditablePropertiesPatch({ torrentFileAllocation: 'prealloc' }, torrentItem)).toMatchObject({ torrentFileAllocation: 'prealloc' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,6 +331,27 @@ describe('useDownloadStore', () => {
|
||||
expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false);
|
||||
});
|
||||
|
||||
it('discards legacy cookies and sensitive headers for explicit media and media domains', () => {
|
||||
useDownloadStore.getState().toggleAddModal(false);
|
||||
useDownloadStore.getState().openAddModalWithUrls(
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://www.youtube.com',
|
||||
'video.mp4',
|
||||
'Authorization: Bearer secret\nUser-Agent: FirelinkTest',
|
||||
'session=leak',
|
||||
true,
|
||||
[{ url: 'https://www.youtube.com', cookies: 'session=leak' }]
|
||||
);
|
||||
|
||||
const state = useDownloadStore.getState();
|
||||
expect(state.pendingAddCookies).toBe('');
|
||||
const context = state.pendingAddRequestContexts['https://www.youtube.com/watch?v=dQw4w9WgXcQ'];
|
||||
expect(context?.cookies).toBe('');
|
||||
expect(context?.cookieScopes).toBeUndefined();
|
||||
expect(context?.headers).toBe('User-Agent: FirelinkTest');
|
||||
expect(context?.media).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces a paused download URL in place and preserves its progress', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, headerNameHasCredentialMaterial, headersWithoutCredentialMaterial, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
||||
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, headerNameHasCredentialMaterial, headersWithoutCredentialMaterial, isActiveDownloadStatus, isMediaUrl, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
||||
import {
|
||||
resolveCategoryDestination
|
||||
} from '../utils/downloadLocations';
|
||||
@@ -1844,8 +1844,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
|
||||
const cleanReferer = referer?.trim() || '';
|
||||
const cleanFilename = filename?.trim() || '';
|
||||
const cleanHeaders = headers?.trim() || '';
|
||||
const cleanCookies = cookies?.trim() || '';
|
||||
const isExplicitMedia = media === true;
|
||||
const cleanHeaders = isExplicitMedia
|
||||
? stripSensitiveMediaHeaders(headers)
|
||||
: (headers?.trim() || '');
|
||||
const cleanCookies = isExplicitMedia ? '' : (cookies?.trim() || '');
|
||||
// Keep the first modal request's grouping decision stable while later
|
||||
// handoffs append URLs. This avoids moving an already-visible destination
|
||||
// when a second request races with the user's Add-window setup.
|
||||
@@ -1853,12 +1856,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
const nextBatchName = nextBatch
|
||||
? (isAppending ? state.pendingAddBatchName : batchName?.trim() || '')
|
||||
: '';
|
||||
const cleanCookieScopes = cookieScopes
|
||||
?.map(scope => ({
|
||||
url: scope.url.trim(),
|
||||
cookies: scope.cookies.trim()
|
||||
}))
|
||||
.filter(scope => scope.url && scope.cookies);
|
||||
const cleanCookieScopes = isExplicitMedia
|
||||
? undefined
|
||||
: cookieScopes
|
||||
?.map(scope => ({
|
||||
url: scope.url.trim(),
|
||||
cookies: scope.cookies.trim()
|
||||
}))
|
||||
.filter(scope => scope.url && scope.cookies);
|
||||
const requestVersion = state.pendingAddRequestVersion + 1;
|
||||
const pendingAddRequestContexts = isAppending
|
||||
? { ...state.pendingAddRequestContexts }
|
||||
@@ -1876,14 +1881,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
} catch {
|
||||
// The Add modal will mark malformed input invalid; retain its original key here.
|
||||
}
|
||||
const isItemMedia = isExplicitMedia || isMediaUrl(trimmedUrl);
|
||||
pendingAddRequestContexts[key] = {
|
||||
version: requestVersion,
|
||||
referer: cleanReferer,
|
||||
filename: cleanFilename,
|
||||
headers: cleanHeaders,
|
||||
cookies: cleanCookies,
|
||||
...(cleanCookieScopes?.length ? { cookieScopes: cleanCookieScopes } : {}),
|
||||
media,
|
||||
headers: isItemMedia ? stripSensitiveMediaHeaders(cleanHeaders) : cleanHeaders,
|
||||
cookies: isItemMedia ? '' : cleanCookies,
|
||||
...(cleanCookieScopes?.length && !isItemMedia ? { cookieScopes: cleanCookieScopes } : {}),
|
||||
media: isItemMedia,
|
||||
...(torrent ? { torrent: true } : {})
|
||||
};
|
||||
}
|
||||
@@ -1899,7 +1905,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
pendingAddReferer: cleanReferer,
|
||||
pendingAddFilename: cleanFilename,
|
||||
pendingAddHeaders: cleanHeaders,
|
||||
pendingAddCookies: cleanCookies,
|
||||
pendingAddCookies: isExplicitMedia ? '' : cleanCookies,
|
||||
pendingAddMediaUrls,
|
||||
pendingAddTorrentUrls,
|
||||
pendingAddBatch: nextBatch,
|
||||
|
||||
@@ -91,6 +91,64 @@ describe('durable main-window and sidebar preferences', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('pins volatile navigation and sanitizes scheduler and cookie source during hydration', () => {
|
||||
const merge = useSettingsStore.persist.getOptions().merge;
|
||||
expect(merge).toBeTypeOf('function');
|
||||
const current = useSettingsStore.getState();
|
||||
|
||||
const result = merge?.({
|
||||
activeView: 'logs' as any,
|
||||
showKeychainModal: true as any,
|
||||
mediaCookieSource: 'internet-explorer' as any,
|
||||
lastCustomSpeedLimitUnit: 'GB/s',
|
||||
scheduler: {
|
||||
enabled: 'yes' as any,
|
||||
postQueueAction: 'self-destruct' as any,
|
||||
selectedDays: [10, -1, 3],
|
||||
selectedQueueIds: [' ', 'queue-1']
|
||||
},
|
||||
schedulerRunning: 'running' as any,
|
||||
schedulerActiveDownloadIds: 'all' as any
|
||||
}, current);
|
||||
|
||||
expect(result?.activeView).toBe(current.activeView);
|
||||
expect(result?.showKeychainModal).toBe(false);
|
||||
expect(result?.mediaCookieSource).toBe('none');
|
||||
expect(result?.lastCustomSpeedLimitUnit).toBe(current.lastCustomSpeedLimitUnit);
|
||||
expect(result?.scheduler.enabled).toBe(current.scheduler.enabled);
|
||||
expect(result?.scheduler.postQueueAction).toBe('none');
|
||||
expect(result?.scheduler.selectedDays).toEqual([3]);
|
||||
expect(result?.scheduler.selectedQueueIds).toEqual(['queue-1']);
|
||||
expect(result?.schedulerRunning).toBe(current.schedulerRunning);
|
||||
expect(result?.schedulerActiveDownloadIds).toEqual(current.schedulerActiveDownloadIds);
|
||||
|
||||
const fallbackResult = merge?.({
|
||||
scheduler: {
|
||||
selectedDays: [-5, 99] as any,
|
||||
selectedQueueIds: [' ', ''] as any
|
||||
}
|
||||
}, current);
|
||||
expect(fallbackResult?.scheduler.selectedDays).toEqual(current.scheduler.selectedDays);
|
||||
expect(fallbackResult?.scheduler.selectedQueueIds).toEqual(current.scheduler.selectedQueueIds);
|
||||
});
|
||||
|
||||
it('sanitizes setter calls for enum and numeric settings', () => {
|
||||
useSettingsStore.getState().setMediaCookieSource('invalid-browser' as any);
|
||||
expect(useSettingsStore.getState().mediaCookieSource).toBe('none');
|
||||
|
||||
useSettingsStore.getState().setTheme('invalid-theme' as any);
|
||||
expect(useSettingsStore.getState().theme).toBe('system');
|
||||
|
||||
useSettingsStore.getState().setLastCustomSpeedLimitKiB(-500);
|
||||
expect(useSettingsStore.getState().lastCustomSpeedLimitKiB).toBe(1);
|
||||
|
||||
useSettingsStore.getState().setLastCustomSpeedLimitKiB(20_000_000);
|
||||
expect(useSettingsStore.getState().lastCustomSpeedLimitKiB).toBe(10_485_760);
|
||||
|
||||
useSettingsStore.getState().setLastCustomSpeedLimitUnit('PB/s' as any);
|
||||
expect(useSettingsStore.getState().lastCustomSpeedLimitUnit).toBe('MB/s');
|
||||
});
|
||||
|
||||
it('uses the legacy localStorage value only when durable state is absent', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
|
||||
@@ -126,6 +126,7 @@ const MEDIA_COOKIE_SOURCE_VALUES = [
|
||||
const SETTINGS_TAB_VALUES = [
|
||||
'downloads', 'lookandfeel', 'network', 'locations', 'sitelogins', 'power', 'engine', 'integrations', 'about'
|
||||
] as const;
|
||||
const POST_QUEUE_ACTION_VALUES = ['none', 'sleep', 'restart', 'shutdown'] as const;
|
||||
|
||||
type PersistedSettingsSnapshot = PersistedSettings & {
|
||||
language: AppLocalePreference;
|
||||
@@ -498,20 +499,31 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
autoCheckUpdates: true,
|
||||
showKeychainModal: false,
|
||||
|
||||
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
|
||||
setTheme: (theme) => {
|
||||
const safe = isAllowedSetting(THEME_VALUES, theme) ? theme : 'system';
|
||||
info('Settings updated: theme');
|
||||
set({ theme: safe });
|
||||
},
|
||||
setFontFamily: (fontFamily) => {
|
||||
const safe = isAllowedSetting(FONT_FAMILY_VALUES, fontFamily) ? fontFamily : 'system';
|
||||
info('Settings updated: fontFamily');
|
||||
set({ fontFamily });
|
||||
set({ fontFamily: safe });
|
||||
},
|
||||
setWindowControlStyle: (windowControlStyle) => {
|
||||
const safe = isAllowedSetting(WINDOW_CONTROL_STYLE_VALUES, windowControlStyle) ? windowControlStyle : 'auto';
|
||||
info('Settings updated: windowControlStyle');
|
||||
set({ windowControlStyle });
|
||||
set({ windowControlStyle: safe });
|
||||
},
|
||||
setCalendarPreference: (calendarPreference) => {
|
||||
const safe = isCalendarPreference(calendarPreference) ? calendarPreference : DEFAULT_CALENDAR_PREFERENCE;
|
||||
info('Settings updated: calendarPreference');
|
||||
set({ calendarPreference });
|
||||
set({ calendarPreference: safe });
|
||||
},
|
||||
setLanguage: (language) => {
|
||||
const safe = isAppLocalePreference(language) ? language : 'system';
|
||||
info('Settings updated: language');
|
||||
set({ language: safe });
|
||||
},
|
||||
setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
|
||||
setBaseDownloadFolder: (path) => {
|
||||
info('Settings updated: baseDownloadFolder');
|
||||
set({ baseDownloadFolder: path });
|
||||
@@ -561,7 +573,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
},
|
||||
setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }),
|
||||
setLogsEnabled: (logsEnabled) => set({ logsEnabled }),
|
||||
setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }),
|
||||
setSidebarPosition: (sidebarPosition) => {
|
||||
const safe = isAllowedSetting(SIDEBAR_POSITION_VALUES, sidebarPosition) ? sidebarPosition : 'auto';
|
||||
set({ sidebarPosition: safe });
|
||||
},
|
||||
setFoldersCollapsed: (isFoldersCollapsed) => set({ isFoldersCollapsed }),
|
||||
toggleFoldersCollapsed: () => set(state => ({ isFoldersCollapsed: !state.isFoldersCollapsed })),
|
||||
setMainWindowSize: (size) => {
|
||||
@@ -569,14 +584,23 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (normalized) set({ mainWindowSize: normalized });
|
||||
},
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
|
||||
setActiveSettingsTab: (activeSettingsTab) => {
|
||||
const safe = isAllowedSetting(SETTINGS_TAB_VALUES, activeSettingsTab) ? activeSettingsTab : 'downloads';
|
||||
set({ activeSettingsTab: safe });
|
||||
},
|
||||
setScheduler: (scheduler) => set({ scheduler }),
|
||||
setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }),
|
||||
setSchedulerActiveDownloadIds: (schedulerActiveDownloadIds) => set({ schedulerActiveDownloadIds }),
|
||||
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
|
||||
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
|
||||
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
|
||||
setLastCustomSpeedLimitUnit: (lastCustomSpeedLimitUnit) => set({ lastCustomSpeedLimitUnit }),
|
||||
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({
|
||||
lastCustomSpeedLimitKiB: clampSettingInteger(lastCustomSpeedLimitKiB, 1, 10_485_760, 1024)
|
||||
}),
|
||||
setLastCustomSpeedLimitUnit: (lastCustomSpeedLimitUnit) => set({
|
||||
lastCustomSpeedLimitUnit: lastCustomSpeedLimitUnit === 'KB/s' || lastCustomSpeedLimitUnit === 'MB/s'
|
||||
? lastCustomSpeedLimitUnit
|
||||
: 'MB/s'
|
||||
}),
|
||||
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
|
||||
|
||||
setPerServerConnections: (perServerConnections) => set({
|
||||
@@ -598,8 +622,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setShowNotifications: (showNotifications) => set({ showNotifications }),
|
||||
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
|
||||
setAutoAddClipboardLinks: (autoAddClipboardLinks) => set({ autoAddClipboardLinks }),
|
||||
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
||||
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
|
||||
setAppFontSize: (appFontSize) => {
|
||||
const safe = isAllowedSetting(APP_FONT_SIZE_VALUES, appFontSize) ? appFontSize : 'standard';
|
||||
set({ appFontSize: safe });
|
||||
},
|
||||
setListRowDensity: (listRowDensity) => {
|
||||
const safe = isAllowedSetting(LIST_ROW_DENSITY_VALUES, listRowDensity) ? listRowDensity : 'standard';
|
||||
set({ listRowDensity: safe });
|
||||
},
|
||||
setShowDockBadge: (showDockBadge) => {
|
||||
set(state => ({
|
||||
showDockBadge,
|
||||
@@ -607,7 +637,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}));
|
||||
},
|
||||
setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }),
|
||||
setProxyMode: (proxyMode) => set({ proxyMode }),
|
||||
setProxyMode: (proxyMode) => {
|
||||
const safe = isAllowedSetting(PROXY_MODE_VALUES, proxyMode) ? proxyMode : 'none';
|
||||
set({ proxyMode: safe });
|
||||
},
|
||||
setProxyHost: (proxyHost) => set({ proxyHost }),
|
||||
setProxyPort: (proxyPort) => set({
|
||||
proxyPort: Number.isFinite(proxyPort)
|
||||
@@ -687,7 +720,11 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
info('Settings updated: preventsDisplaySleepWhileDownloading');
|
||||
set({ preventsDisplaySleepWhileDownloading });
|
||||
},
|
||||
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
|
||||
setMediaCookieSource: (mediaCookieSource) => {
|
||||
const safe = isAllowedSetting(MEDIA_COOKIE_SOURCE_VALUES, mediaCookieSource) ? mediaCookieSource : 'none';
|
||||
info('Settings updated: mediaCookieSource');
|
||||
set({ mediaCookieSource: safe });
|
||||
},
|
||||
setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => {
|
||||
info('Settings updated: rememberLastUsedDownloadDirectory');
|
||||
set({
|
||||
@@ -924,6 +961,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
// Never hydrate the remembered Add-window path from persisted data.
|
||||
lastUsedDownloadDirectory: currentState.lastUsedDownloadDirectory,
|
||||
keychainAccessReady: currentState.keychainAccessReady,
|
||||
activeView: currentState.activeView,
|
||||
showKeychainModal: currentState.showKeychainModal,
|
||||
dockBadgeSyncVersion: currentState.dockBadgeSyncVersion,
|
||||
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
|
||||
? persisted.theme
|
||||
: currentState.theme,
|
||||
@@ -1118,12 +1158,34 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
: currentState.approvedDownloadRoots,
|
||||
scheduler: {
|
||||
...currentState.scheduler,
|
||||
...persisted.scheduler,
|
||||
selectedQueueIds: Array.isArray(persisted.scheduler?.selectedQueueIds)
|
||||
&& persisted.scheduler.selectedQueueIds.length > 0
|
||||
? persisted.scheduler.selectedQueueIds
|
||||
: currentState.scheduler.selectedQueueIds
|
||||
...(isRecord(persisted.scheduler) ? persisted.scheduler : {}),
|
||||
enabled: persistedBoolean(persisted.scheduler?.enabled, currentState.scheduler.enabled),
|
||||
startTime: persistedString(persisted.scheduler?.startTime, currentState.scheduler.startTime),
|
||||
stopTimeEnabled: persistedBoolean(persisted.scheduler?.stopTimeEnabled, currentState.scheduler.stopTimeEnabled),
|
||||
stopTime: persistedString(persisted.scheduler?.stopTime, currentState.scheduler.stopTime),
|
||||
everyday: persistedBoolean(persisted.scheduler?.everyday, currentState.scheduler.everyday),
|
||||
selectedDays: (() => {
|
||||
const days = Array.isArray(persisted.scheduler?.selectedDays)
|
||||
? persisted.scheduler.selectedDays.filter((day): day is number => typeof day === 'number' && Number.isInteger(day) && day >= 0 && day <= 6)
|
||||
: [];
|
||||
return days.length > 0 ? days : currentState.scheduler.selectedDays;
|
||||
})(),
|
||||
selectedQueueIds: (() => {
|
||||
const queues = Array.isArray(persisted.scheduler?.selectedQueueIds)
|
||||
? persisted.scheduler.selectedQueueIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
: [];
|
||||
return queues.length > 0 ? queues : currentState.scheduler.selectedQueueIds;
|
||||
})(),
|
||||
postQueueAction: isAllowedSetting(POST_QUEUE_ACTION_VALUES, persisted.scheduler?.postQueueAction)
|
||||
? persisted.scheduler.postQueueAction
|
||||
: currentState.scheduler.postQueueAction
|
||||
},
|
||||
schedulerRunning: persistedBoolean(persisted.schedulerRunning, currentState.schedulerRunning),
|
||||
schedulerActiveDownloadIds: Array.isArray(persisted.schedulerActiveDownloadIds)
|
||||
? persisted.schedulerActiveDownloadIds.filter((id): id is string => typeof id === 'string')
|
||||
: currentState.schedulerActiveDownloadIds,
|
||||
schedulerLastStartKey: persistedString(persisted.schedulerLastStartKey, currentState.schedulerLastStartKey),
|
||||
schedulerLastStopKey: persistedString(persisted.schedulerLastStopKey, currentState.schedulerLastStopKey),
|
||||
siteLogins: Array.isArray(persisted.siteLogins)
|
||||
? sanitizeSiteLogins(persisted.siteLogins)
|
||||
: currentState.siteLogins
|
||||
|
||||
@@ -336,6 +336,33 @@ describe('add download metadata workflow', () => {
|
||||
expect(rows.some(item => item.isPlaylist)).toBe(false);
|
||||
});
|
||||
|
||||
it('retains the playlist row when expansion has no valid video entries', () => {
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PL_EMPTY';
|
||||
const rows = reconcileDownloadRows(
|
||||
playlistUrl,
|
||||
[],
|
||||
undefined,
|
||||
new Set(),
|
||||
undefined,
|
||||
{},
|
||||
{},
|
||||
{
|
||||
[playlistUrl]: {
|
||||
title: 'Empty playlist',
|
||||
playlist_id: 'PL_EMPTY',
|
||||
entry_count: 0,
|
||||
skipped_entries: 0,
|
||||
truncated: false,
|
||||
entries: []
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].sourceUrl).toBe(playlistUrl);
|
||||
expect(rows[0].isPlaylist).toBe(true);
|
||||
});
|
||||
|
||||
it('forces explicit extension media fetches through media metadata for any http page', () => {
|
||||
const rows = reconcileDownloadRows(
|
||||
'https://adult.example/watch/123',
|
||||
|
||||
@@ -212,6 +212,7 @@ const parseInputLines = (
|
||||
const expansion = playlistExpansions[sourceUrl];
|
||||
if (expansion) {
|
||||
const playlistSelected = selectedBySourceUrl[sourceUrl] !== false;
|
||||
let validEntryCount = 0;
|
||||
for (const [position, entry] of expansion.entries.entries()) {
|
||||
let entryUrl: string;
|
||||
try {
|
||||
@@ -221,6 +222,7 @@ const parseInputLines = (
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
validEntryCount++;
|
||||
if (seen.has(entryUrl)) continue;
|
||||
seen.add(entryUrl);
|
||||
parsed.push({
|
||||
@@ -239,7 +241,9 @@ const parseInputLines = (
|
||||
// The playlist has been successfully discovered even when every
|
||||
// entry was already represented by another input row. Do not put the
|
||||
// source playlist back into loading state in that case.
|
||||
continue;
|
||||
if (validEntryCount > 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatDownloadBytes,
|
||||
formatDownloadTotal,
|
||||
formatTorrentRatio,
|
||||
resolveDownloadFraction,
|
||||
resolveDownloadSizeDisplay,
|
||||
} from './downloadProgress';
|
||||
@@ -108,3 +109,23 @@ describe('resolveDownloadFraction', () => {
|
||||
})).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTorrentRatio', () => {
|
||||
it('formats positive torrent ratio with two decimal places', () => {
|
||||
expect(formatTorrentRatio(1500, 1000, 'en-US')).toBe('1.50');
|
||||
expect(formatTorrentRatio(2345, 1000, 'en-US')).toBe('2.35');
|
||||
});
|
||||
|
||||
it('formats torrent ratio using the specified locale', () => {
|
||||
const formatted = formatTorrentRatio(1500, 1000, 'fa');
|
||||
expect(formatted).toBe(new Intl.NumberFormat('fa', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1.5));
|
||||
});
|
||||
|
||||
it('returns a dash when ratio cannot be calculated', () => {
|
||||
expect(formatTorrentRatio(0, 0, 'en-US')).toBe('—');
|
||||
expect(formatTorrentRatio(-10, 1000, 'en-US')).toBe('—');
|
||||
expect(formatTorrentRatio(1000, -10, 'en-US')).toBe('—');
|
||||
expect(formatTorrentRatio(1000, 0, 'en-US')).toBe('—');
|
||||
expect(formatTorrentRatio(Number.NaN, 1000, 'en-US')).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,4 +57,64 @@ describe('download table sorting', () => {
|
||||
expect(persisted.speed).toBeUndefined();
|
||||
expect(persisted.eta).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses estimated sizes with ~ or ≈ prefixes and sorts them accurately', () => {
|
||||
expect(parseDownloadSize('~1.2 GB')).toBe(1.2 * 1024 ** 3);
|
||||
expect(parseDownloadSize('~ 500 MB')).toBe(500 * 1024 ** 2);
|
||||
expect(parseDownloadSize('≈250 KB')).toBe(250 * 1024);
|
||||
|
||||
expect(sortedIds([
|
||||
item('missing', { size: '-' }),
|
||||
item('est-1g', { size: '~1 GB' }),
|
||||
item('final-500m', { size: '500 MB' }),
|
||||
item('exact-bytes', { totalBytes: 2 * 1024 ** 3 }),
|
||||
], { column: 'Size', direction: 'asc' })).toEqual([
|
||||
'final-500m',
|
||||
'est-1g',
|
||||
'exact-bytes',
|
||||
'missing'
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps unknown and missing values last in descending sort for Size, Speed, ETA, and Date Added', () => {
|
||||
expect(sortedIds([
|
||||
item('unknown-size', { size: '-' }),
|
||||
item('small-size', { size: '100 MB' }),
|
||||
item('large-size', { size: '1 GB' }),
|
||||
], { column: 'Size', direction: 'desc' })).toEqual([
|
||||
'large-size',
|
||||
'small-size',
|
||||
'unknown-size'
|
||||
]);
|
||||
|
||||
expect(sortedIds([
|
||||
item('unknown-speed', { speed: '-' }),
|
||||
item('fast-speed', { speed: '10 MB/s' }),
|
||||
item('slow-speed', { speed: '1 MB/s' }),
|
||||
], { column: 'Speed', direction: 'desc' })).toEqual([
|
||||
'fast-speed',
|
||||
'slow-speed',
|
||||
'unknown-speed'
|
||||
]);
|
||||
|
||||
expect(sortedIds([
|
||||
item('unknown-eta', { eta: '-' }),
|
||||
item('long-eta', { eta: '2h' }),
|
||||
item('short-eta', { eta: '5m' }),
|
||||
], { column: 'ETA', direction: 'desc' })).toEqual([
|
||||
'long-eta',
|
||||
'short-eta',
|
||||
'unknown-eta'
|
||||
]);
|
||||
|
||||
expect(sortedIds([
|
||||
item('unknown-date', { dateAdded: '' }),
|
||||
item('newer-date', { dateAdded: '2026-08-01T00:00:00.000Z' }),
|
||||
item('older-date', { dateAdded: '2026-01-01T00:00:00.000Z' }),
|
||||
], { column: 'Date Added', direction: 'desc' })).toEqual([
|
||||
'newer-date',
|
||||
'older-date',
|
||||
'unknown-date'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,11 @@ const SIZE_UNITS: Record<string, number> = {
|
||||
};
|
||||
|
||||
const valueOrNull = (value?: string): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
let trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith('~') || trimmed.startsWith('≈')) {
|
||||
trimmed = trimmed.slice(1).trim();
|
||||
}
|
||||
return trimmed && trimmed !== '-' && !/^unknown$/i.test(trimmed) ? trimmed : null;
|
||||
};
|
||||
|
||||
@@ -41,6 +45,16 @@ const parseUnitValue = (value?: string, units = SIZE_UNITS): number | null => {
|
||||
|
||||
export const parseDownloadSize = (value?: string): number | null => parseUnitValue(value);
|
||||
|
||||
export const resolveSortableSize = (download: DownloadItem): number | null => {
|
||||
if (typeof download.totalBytes === 'number' && Number.isFinite(download.totalBytes) && download.totalBytes > 0) {
|
||||
return download.totalBytes;
|
||||
}
|
||||
if (download.status === 'completed' && typeof download.downloadedBytes === 'number' && Number.isFinite(download.downloadedBytes) && download.downloadedBytes > 0) {
|
||||
return download.downloadedBytes;
|
||||
}
|
||||
return parseDownloadSize(download.size);
|
||||
};
|
||||
|
||||
export const parseDownloadSpeed = (value?: string): number | null =>
|
||||
parseUnitValue(value, SIZE_UNITS);
|
||||
|
||||
@@ -65,12 +79,18 @@ export const parseDownloadEta = (value?: string): number | null => {
|
||||
return matched && Number.isFinite(seconds) ? seconds : null;
|
||||
};
|
||||
|
||||
const compareValues = (left: string | number | null, right: string | number | null): number => {
|
||||
const compareValues = (
|
||||
left: string | number | null,
|
||||
right: string | number | null,
|
||||
direction: DownloadSortDirection
|
||||
): number => {
|
||||
if (left === null && right === null) return 0;
|
||||
if (left === null) return 1;
|
||||
if (right === null) return -1;
|
||||
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
||||
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
||||
const comparison = typeof left === 'number' && typeof right === 'number'
|
||||
? left - right
|
||||
: String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
||||
return direction === 'asc' ? comparison : -comparison;
|
||||
};
|
||||
|
||||
const parseDownloadDate = (value?: string): number | null => {
|
||||
@@ -84,27 +104,27 @@ export const sortDownloads = (downloads: DownloadItem[], config: DownloadSortCon
|
||||
let comparison: number;
|
||||
switch (config.column) {
|
||||
case 'File Name':
|
||||
comparison = compareValues(left.fileName || left.url, right.fileName || right.url);
|
||||
comparison = compareValues(left.fileName || left.url, right.fileName || right.url, config.direction);
|
||||
break;
|
||||
case 'Size':
|
||||
comparison = compareValues(parseDownloadSize(left.size), parseDownloadSize(right.size));
|
||||
comparison = compareValues(resolveSortableSize(left), resolveSortableSize(right), config.direction);
|
||||
break;
|
||||
case 'Status':
|
||||
comparison = compareValues(left.status, right.status);
|
||||
comparison = compareValues(left.status, right.status, config.direction);
|
||||
break;
|
||||
case 'Speed':
|
||||
comparison = compareValues(parseDownloadSpeed(left.speed), parseDownloadSpeed(right.speed));
|
||||
comparison = compareValues(parseDownloadSpeed(left.speed), parseDownloadSpeed(right.speed), config.direction);
|
||||
break;
|
||||
case 'ETA':
|
||||
comparison = compareValues(parseDownloadEta(left.eta), parseDownloadEta(right.eta));
|
||||
comparison = compareValues(parseDownloadEta(left.eta), parseDownloadEta(right.eta), config.direction);
|
||||
break;
|
||||
case 'Date Added':
|
||||
comparison = compareValues(parseDownloadDate(left.dateAdded), parseDownloadDate(right.dateAdded));
|
||||
comparison = compareValues(parseDownloadDate(left.dateAdded), parseDownloadDate(right.dateAdded), config.direction);
|
||||
break;
|
||||
}
|
||||
|
||||
if (comparison === 0) comparison = left.id.localeCompare(right.id);
|
||||
return config.direction === 'asc' ? comparison : -comparison;
|
||||
return comparison;
|
||||
});
|
||||
return sorted;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
beginSchedulerControl,
|
||||
cancelPendingPostAction,
|
||||
consumeSchedulerHandoffIds,
|
||||
handoffSupersededSchedulerIds,
|
||||
isSchedulerControlCurrent
|
||||
isSchedulerControlCurrent,
|
||||
registerPostActionCanceller
|
||||
} from './schedulerControl';
|
||||
|
||||
describe('scheduler control generation', () => {
|
||||
@@ -34,4 +36,21 @@ describe('scheduler control generation', () => {
|
||||
expect(handoffSupersededSchedulerIds(['download-a'], () => 'queue-a')).toEqual(new Set());
|
||||
expect(consumeSchedulerHandoffIds(pause)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('cancels pending post actions when a new control generation begins', () => {
|
||||
let cancelled = 0;
|
||||
const unregister = registerPostActionCanceller(() => {
|
||||
cancelled += 1;
|
||||
});
|
||||
|
||||
beginSchedulerControl();
|
||||
expect(cancelled).toBe(1);
|
||||
|
||||
cancelPendingPostAction();
|
||||
expect(cancelled).toBe(2);
|
||||
|
||||
unregister();
|
||||
beginSchedulerControl();
|
||||
expect(cancelled).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,32 @@ let schedulerControlGeneration = 0;
|
||||
let latestRunQueueIds: ReadonlySet<string> | null = null;
|
||||
const schedulerHandoffs = new Map<number, Set<string>>();
|
||||
|
||||
let postActionCanceller: (() => void) | null = null;
|
||||
|
||||
export const registerPostActionCanceller = (canceller: () => void): (() => void) => {
|
||||
postActionCanceller = canceller;
|
||||
return () => {
|
||||
if (postActionCanceller === canceller) {
|
||||
postActionCanceller = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const cancelPendingPostAction = (): void => {
|
||||
postActionCanceller?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a new scheduler control lifecycle. A later manual pause or scheduler
|
||||
* event invalidates earlier asynchronous queue operations before they can
|
||||
* publish stale running state.
|
||||
* publish stale running state, and cancels any pending post-queue action.
|
||||
*/
|
||||
export const beginSchedulerControl = (runQueueIds?: readonly string[]): number => {
|
||||
schedulerControlGeneration += 1;
|
||||
latestRunQueueIds = runQueueIds ? new Set(runQueueIds) : null;
|
||||
schedulerHandoffs.clear();
|
||||
if (runQueueIds) schedulerHandoffs.set(schedulerControlGeneration, new Set());
|
||||
postActionCanceller?.();
|
||||
return schedulerControlGeneration;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
resolveFallbackFilter,
|
||||
shouldRestoreSidebarRevealFocus,
|
||||
shouldRestoreSidebarToggleFocus,
|
||||
} from './sidebarFocus';
|
||||
|
||||
describe('sidebar focus restoration', () => {
|
||||
it('restores focus to reveal button when activeElement was inside the sidebar shell', () => {
|
||||
const sidebarShell = {
|
||||
contains: (el: unknown) => el === buttonInside,
|
||||
};
|
||||
const buttonInside = {
|
||||
closest: (selector: string) => (selector === '.app-sidebar-shell' ? sidebarShell : null),
|
||||
};
|
||||
|
||||
expect(shouldRestoreSidebarRevealFocus(buttonInside, sidebarShell)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not restore focus to reveal button when activeElement was outside the sidebar shell', () => {
|
||||
const sidebarShell = {
|
||||
contains: () => false,
|
||||
};
|
||||
const tableButton = {
|
||||
closest: () => null,
|
||||
};
|
||||
|
||||
expect(shouldRestoreSidebarRevealFocus(tableButton, sidebarShell)).toBe(false);
|
||||
expect(shouldRestoreSidebarRevealFocus(null, sidebarShell)).toBe(false);
|
||||
});
|
||||
|
||||
it('restores focus to sidebar toggle button when reveal button was activated', () => {
|
||||
const revealButton = {
|
||||
closest: (selector: string) => (selector === '.app-sidebar-reveal-button' ? revealButton : null),
|
||||
};
|
||||
|
||||
expect(shouldRestoreSidebarToggleFocus(revealButton, revealButton)).toBe(true);
|
||||
|
||||
const iconInside = {
|
||||
closest: (selector: string) => (selector === '.app-sidebar-reveal-button' ? revealButton : null),
|
||||
};
|
||||
expect(shouldRestoreSidebarToggleFocus(iconInside, revealButton)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not restore focus to sidebar toggle button when reveal was not focused', () => {
|
||||
const revealButton = {
|
||||
closest: () => null,
|
||||
};
|
||||
const otherButton = {
|
||||
closest: () => null,
|
||||
};
|
||||
|
||||
expect(shouldRestoreSidebarToggleFocus(otherButton, revealButton)).toBe(false);
|
||||
expect(shouldRestoreSidebarToggleFocus(null, revealButton)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebar queue filter fallback', () => {
|
||||
it('falls back to all downloads when the active filtered queue is removed', () => {
|
||||
const activeFilter = 'queue:custom-queue-1';
|
||||
const remainingQueues = ['00000000-0000-0000-0000-000000000001', 'custom-queue-2'];
|
||||
|
||||
expect(resolveFallbackFilter(activeFilter, remainingQueues, true)).toBe('all');
|
||||
});
|
||||
|
||||
it('preserves the active filter when the filtered queue remains present', () => {
|
||||
const activeFilter = 'queue:custom-queue-1';
|
||||
const remainingQueues = ['custom-queue-1', '00000000-0000-0000-0000-000000000001'];
|
||||
|
||||
expect(resolveFallbackFilter(activeFilter, remainingQueues, true)).toBe('queue:custom-queue-1');
|
||||
});
|
||||
|
||||
it('preserves queue filter while queues are not yet hydrated', () => {
|
||||
const activeFilter = 'queue:custom-queue-1';
|
||||
|
||||
expect(resolveFallbackFilter(activeFilter, [], false)).toBe('queue:custom-queue-1');
|
||||
});
|
||||
|
||||
it('never modifies non-queue category or status filters', () => {
|
||||
expect(resolveFallbackFilter('all', [], true)).toBe('all');
|
||||
expect(resolveFallbackFilter('active', [], true)).toBe('active');
|
||||
expect(resolveFallbackFilter('completed', [], true)).toBe('completed');
|
||||
expect(resolveFallbackFilter('unfinished', [], true)).toBe('unfinished');
|
||||
expect(resolveFallbackFilter('Musics', [], true)).toBe('Musics');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Helpers for accessible focus preservation during sidebar collapse/reveal
|
||||
* and active filter recovery when queues are deleted.
|
||||
*/
|
||||
|
||||
export type FocusableTarget = {
|
||||
contains?: (other: any) => boolean;
|
||||
closest?: (selector: string) => any;
|
||||
} | null;
|
||||
|
||||
export const shouldRestoreSidebarRevealFocus = (
|
||||
activeElement: FocusableTarget,
|
||||
sidebarShell: FocusableTarget,
|
||||
): boolean => {
|
||||
if (!activeElement || !sidebarShell) return false;
|
||||
if (typeof activeElement.closest === 'function') {
|
||||
return Boolean(activeElement.closest('.app-sidebar-shell'));
|
||||
}
|
||||
return Boolean(sidebarShell.contains?.(activeElement));
|
||||
};
|
||||
|
||||
export const shouldRestoreSidebarToggleFocus = (
|
||||
activeElement: FocusableTarget,
|
||||
revealButton: FocusableTarget,
|
||||
): boolean => {
|
||||
if (!activeElement || !revealButton) return false;
|
||||
if (activeElement === revealButton) return true;
|
||||
if (typeof activeElement.closest === 'function') {
|
||||
return Boolean(activeElement.closest('.app-sidebar-reveal-button'));
|
||||
}
|
||||
return Boolean(revealButton.contains?.(activeElement));
|
||||
};
|
||||
|
||||
export const resolveFallbackFilter = (
|
||||
filter: string,
|
||||
availableQueueIds: Iterable<string>,
|
||||
queuesHydrated: boolean,
|
||||
): string => {
|
||||
if (!filter.startsWith('queue:') || !queuesHydrated) return filter;
|
||||
const queueId = filter.slice(6);
|
||||
const queueSet = availableQueueIds instanceof Set
|
||||
? availableQueueIds
|
||||
: new Set(availableQueueIds);
|
||||
return queueSet.has(queueId) ? filter : 'all';
|
||||
};
|
||||
Reference in New Issue
Block a user