feat(torrent): add standalone properties windows

- add caller-bound native properties bridge and lifecycle guards

- split Network settings into accessible secondary tabs

- add tabbed Torrent and generic Properties surfaces

- harden runtime validation and ignore the implementation plan
This commit is contained in:
NimBold
2026-08-04 09:26:38 +03:30
parent 579a8f7f80
commit c342bcd347
22 changed files with 1778 additions and 88 deletions
+1
View File
@@ -13,6 +13,7 @@ AGENT.md
AGENTS.md
TORRENT_FEATURES.md
torrent_features.md
TORRENT_UI_IMPLEMENTATION_PLAN.md
CLAUDE.md
GEMINI.md
implementation_plan.md
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "properties-window",
"description": "Minimal capability for Firelink Properties windows",
"windows": ["properties-*"],
"permissions": [
"core:window:allow-close",
"core:window:allow-set-title",
"core:event:allow-listen",
"core:event:allow-unlisten",
"dialog:default",
"clipboard-manager:allow-write-text",
"log:default"
]
}
+4
View File
@@ -4,9 +4,11 @@ use tauri_plugin_opener::OpenerExt;
#[tauri::command]
pub async fn reveal_in_file_manager(
caller: tauri::WebviewWindow,
app_handle: tauri::AppHandle,
path: String,
) -> Result<(), String> {
crate::properties_window::ensure_main_window(&caller)?;
let primary = authorize_reveal_path(&app_handle, &path)?;
let path = existing_download_asset(&primary).ok_or_else(|| {
format!(
@@ -29,9 +31,11 @@ pub async fn reveal_in_file_manager(
#[tauri::command]
pub async fn open_downloaded_file(
caller: tauri::WebviewWindow,
app_handle: tauri::AppHandle,
path: String,
) -> Result<(), String> {
crate::properties_window::ensure_main_window(&caller)?;
let path = authorize_download_path(&app_handle, &path)?;
if !path.exists() {
return Err(format!("Downloaded file is missing: {}", path.display()));
+288 -29
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -6,7 +6,8 @@ use ts_rs::TS;
use crate::ipc::DownloadCategory;
#[tauri::command]
pub async fn get_system_proxy() -> Result<Option<String>, String> {
pub async fn get_system_proxy(caller: tauri::WebviewWindow) -> Result<Option<String>, String> {
crate::properties_window::ensure_main_window(&caller)?;
match native_system_proxy() {
Ok(Some(proxy)) => Ok(Some(proxy)),
Ok(None) => Ok(proxy_from_environment()),
@@ -539,8 +540,10 @@ struct GitHubRelease {
#[tauri::command]
pub async fn check_for_updates(
caller: tauri::WebviewWindow,
app_handle: tauri::AppHandle,
) -> Result<ReleaseCheckOutcome, String> {
crate::properties_window::ensure_main_window(&caller)?;
let current_version = app_handle.package_info().version.to_string();
crate::ensure_reqwest_crypto_provider();
@@ -606,10 +609,12 @@ fn cmp_versions(a: &str, b: &str) -> std::cmp::Ordering {
#[tauri::command]
pub async fn create_category_directories(
caller: tauri::WebviewWindow,
app_handle: tauri::AppHandle,
base_folder: String,
subfolders: std::collections::HashMap<String, String>,
) -> Result<(), String> {
crate::properties_window::ensure_main_window(&caller)?;
let base = crate::resolve_path(&base_folder, &app_handle);
let mut errors = Vec::new();
+392
View File
@@ -0,0 +1,392 @@
use std::collections::HashMap;
use std::sync::Mutex;
use serde::Serialize;
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use uuid::Uuid;
const MAIN_WINDOW_LABEL: &str = "main";
const PROPERTIES_LABEL_PREFIX: &str = "properties-";
const PROPERTIES_WINDOW_TITLE: &str = "Properties - Firelink";
const PROPERTIES_WINDOW_READY_EVENT: &str = "properties-window-ready";
const PROPERTIES_WINDOW_ACTION_REQUEST_EVENT: &str = "properties-window-action-request";
const MAX_PROPERTIES_ACTION_PAYLOAD_BYTES: usize = 64 * 1024;
#[derive(Default)]
pub struct PropertiesWindowRegistry {
state: Mutex<RegistryState>,
}
#[derive(Default)]
struct RegistryState {
by_download: HashMap<String, String>,
by_window: HashMap<String, String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PropertiesWindowReadyEvent {
window_label: String,
download_id: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PropertiesWindowActionEvent {
window_label: String,
download_id: String,
request_id: u64,
action: String,
payload: Option<serde_json::Value>,
}
impl PropertiesWindowRegistry {
pub fn allocate(&self, download_id: &str) -> Result<String, String> {
let mut state = self
.state
.lock()
.map_err(|_| "Properties window registry is unavailable".to_string())?;
if let Some(label) = state.by_download.get(download_id) {
return Ok(label.clone());
}
let label = format!("{PROPERTIES_LABEL_PREFIX}{}", Uuid::new_v4().simple());
state.by_download.insert(download_id.to_string(), label.clone());
state.by_window.insert(label.clone(), download_id.to_string());
Ok(label)
}
pub fn download_for_window(&self, label: &str) -> Result<Option<String>, String> {
Ok(self
.state
.lock()
.map_err(|_| "Properties window registry is unavailable".to_string())?
.by_window
.get(label)
.cloned())
}
pub fn remove_window(&self, label: &str) -> Result<Option<String>, String> {
let mut state = self
.state
.lock()
.map_err(|_| "Properties window registry is unavailable".to_string())?;
let download_id = state.by_window.remove(label);
if let Some(download_id) = &download_id {
state.by_download.remove(download_id);
}
Ok(download_id)
}
pub fn remove_download(&self, download_id: &str) -> Result<Option<String>, String> {
let mut state = self
.state
.lock()
.map_err(|_| "Properties window registry is unavailable".to_string())?;
let label = state.by_download.remove(download_id);
if let Some(label) = &label {
state.by_window.remove(label);
}
Ok(label)
}
pub fn window_for_download(&self, download_id: &str) -> Result<Option<String>, String> {
Ok(self
.state
.lock()
.map_err(|_| "Properties window registry is unavailable".to_string())?
.by_download
.get(download_id)
.cloned())
}
}
pub fn is_properties_window_label(label: &str) -> bool {
label.starts_with(PROPERTIES_LABEL_PREFIX)
&& label.len() > PROPERTIES_LABEL_PREFIX.len()
&& label[PROPERTIES_LABEL_PREFIX.len()..]
.chars()
.all(|character| character.is_ascii_hexdigit())
}
/// Custom Tauri commands are not automatically narrowed by a capability's
/// window list. Commands that a Properties child may call must therefore
/// validate the invoking webview and its registered download explicitly.
pub fn ensure_properties_or_main(
caller: &tauri::WebviewWindow,
registry: &PropertiesWindowRegistry,
download_id: &str,
) -> Result<(), String> {
if caller.label() == MAIN_WINDOW_LABEL {
return Ok(());
}
if !is_properties_window_label(caller.label())
|| registry.download_for_window(caller.label())?.as_deref() != Some(download_id)
{
return Err("This window is not authorized for the requested download".to_string());
}
Ok(())
}
pub fn ensure_main_window(caller: &tauri::WebviewWindow) -> Result<(), String> {
(caller.label() == MAIN_WINDOW_LABEL)
.then_some(())
.ok_or_else(|| "This command is available only to the main window".to_string())
}
fn registered_download_for_caller(
caller: &tauri::WebviewWindow,
registry: &PropertiesWindowRegistry,
) -> Result<String, String> {
let label = caller.label();
if !is_properties_window_label(label) {
return Err("This window is not a Properties window".to_string());
}
registry
.download_for_window(label)?
.ok_or_else(|| "Properties window is no longer registered".to_string())
}
fn is_properties_action(action: &str) -> bool {
matches!(
action,
"apply-properties"
| "pause-resume"
| "set-download-limit"
| "set-torrent-upload-limit"
| "set-torrent-peer-options"
)
}
fn download_exists(db: &crate::db::DbState, download_id: &str) -> Result<bool, String> {
let connection = db.lock()?;
Ok(crate::db::load_downloads(&connection)?.into_iter().any(|record| {
serde_json::from_str::<serde_json::Value>(&record)
.ok()
.and_then(|value| value.get("id").and_then(serde_json::Value::as_str).map(str::to_owned))
.is_some_and(|id| id == download_id)
}))
}
fn validate_download_id(download_id: &str) -> Result<(), String> {
let trimmed = download_id.trim();
if trimmed.is_empty() || trimmed.len() > 256 || trimmed.chars().any(char::is_control) {
return Err("Invalid download ID".to_string());
}
Ok(())
}
#[tauri::command]
pub fn open_download_properties_window(
app: tauri::AppHandle,
caller: tauri::WebviewWindow,
db: tauri::State<'_, crate::db::DbState>,
registry: tauri::State<'_, PropertiesWindowRegistry>,
id: String,
) -> Result<String, String> {
if caller.label() != MAIN_WINDOW_LABEL {
return Err("Only the main window can open Properties windows".to_string());
}
validate_download_id(&id)?;
if !download_exists(&db, &id)? {
return Err("Download no longer exists".to_string());
}
let label = registry.allocate(&id)?;
if let Some(window) = app.get_webview_window(&label) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
return Ok(label);
}
let build_result = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("index.html".into()))
.title(PROPERTIES_WINDOW_TITLE)
.inner_size(1000.0, 720.0)
.min_inner_size(760.0, 560.0)
.resizable(true)
.always_on_top(false)
.build();
if let Err(error) = build_result {
// Two rapid main-window requests can race between the native lookup
// above and builder creation. If the first request won, retain the
// registry entry and focus its window instead of treating the second
// request as a failed open.
if let Some(window) = app.get_webview_window(&label) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
return Ok(label);
}
let _ = registry.remove_window(&label);
return Err(format!("Could not open Properties window: {error}"));
}
Ok(label)
}
#[tauri::command]
pub fn get_properties_window_download_id(
caller: tauri::WebviewWindow,
registry: tauri::State<'_, PropertiesWindowRegistry>,
) -> Result<String, String> {
registered_download_for_caller(&caller, &registry)
}
#[tauri::command]
pub fn properties_window_send_ready(
caller: tauri::WebviewWindow,
app: tauri::AppHandle,
registry: tauri::State<'_, PropertiesWindowRegistry>,
) -> Result<(), String> {
let download_id = registered_download_for_caller(&caller, &registry)?;
app.emit_to(
MAIN_WINDOW_LABEL,
PROPERTIES_WINDOW_READY_EVENT,
PropertiesWindowReadyEvent {
window_label: caller.label().to_string(),
download_id,
},
)
.map_err(|error| error.to_string())
}
#[tauri::command]
pub fn properties_window_send_action(
caller: tauri::WebviewWindow,
app: tauri::AppHandle,
registry: tauri::State<'_, PropertiesWindowRegistry>,
request_id: u64,
action: String,
payload: Option<serde_json::Value>,
) -> Result<(), String> {
if !is_properties_action(&action)
|| action.len() > 64
|| action.chars().any(char::is_control)
{
return Err("Invalid Properties action".to_string());
}
if let Some(payload) = payload.as_ref() {
let payload_size = serde_json::to_vec(payload)
.map_err(|_| "Invalid Properties action payload".to_string())?
.len();
if payload_size > MAX_PROPERTIES_ACTION_PAYLOAD_BYTES {
return Err("Properties action payload is too large".to_string());
}
}
let download_id = registered_download_for_caller(&caller, &registry)?;
app.emit_to(
MAIN_WINDOW_LABEL,
PROPERTIES_WINDOW_ACTION_REQUEST_EVENT,
PropertiesWindowActionEvent {
window_label: caller.label().to_string(),
download_id,
request_id,
action,
payload,
},
)
.map_err(|error| error.to_string())
}
#[tauri::command]
pub fn validate_properties_window_request(
caller: tauri::WebviewWindow,
registry: tauri::State<'_, PropertiesWindowRegistry>,
window_label: String,
download_id: String,
) -> Result<(), String> {
if caller.label() != MAIN_WINDOW_LABEL {
return Err("Only the main window can validate Properties requests".to_string());
}
validate_download_id(&download_id)?;
if !is_properties_window_label(&window_label) {
return Err("Invalid Properties window label".to_string());
}
if registry.download_for_window(&window_label)?.as_deref() != Some(download_id.as_str()) {
return Err("Properties window request does not match its registered download".to_string());
}
Ok(())
}
#[tauri::command]
pub fn close_download_properties_window(
caller: tauri::WebviewWindow,
app: tauri::AppHandle,
registry: tauri::State<'_, PropertiesWindowRegistry>,
id: String,
) -> Result<(), String> {
let label = caller.label();
let registered_id = if label == MAIN_WINDOW_LABEL {
registry.window_for_download(&id)?.map(|_| id.clone())
} else {
registry.download_for_window(label)?
};
if registered_id.as_deref() != Some(id.as_str()) {
return Err("Properties window close request is not registered".to_string());
}
if let Some(label) = registry.window_for_download(&id)? {
if let Some(window) = app.get_webview_window(&label) {
window.close().map_err(|error| error.to_string())?;
}
}
let _ = registry.remove_download(&id);
Ok(())
}
#[tauri::command]
pub fn properties_window_registry_remove_for_download(
caller: tauri::WebviewWindow,
app: tauri::AppHandle,
registry: tauri::State<'_, PropertiesWindowRegistry>,
id: String,
) -> Result<(), String> {
if caller.label() != MAIN_WINDOW_LABEL {
return Err("Only the main window can remove a Properties window".to_string());
}
if let Some(label) = registry.remove_download(&id)? {
if let Some(window) = app.get_webview_window(&label) {
let _ = window.close();
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn labels_are_opaque_and_strictly_scoped() {
assert!(is_properties_window_label("properties-0123456789abcdef"));
assert!(!is_properties_window_label("properties-download-id"));
assert!(!is_properties_window_label("main"));
assert!(!is_properties_window_label("properties-"));
}
#[test]
fn registry_reuses_one_label_per_download_and_cleans_both_indexes() {
let registry = PropertiesWindowRegistry::default();
let first = registry.allocate("download-a").unwrap();
assert_eq!(registry.allocate("download-a").unwrap(), first);
assert_eq!(registry.download_for_window(&first).unwrap(), Some("download-a".to_string()));
assert_eq!(registry.remove_window(&first).unwrap(), Some("download-a".to_string()));
assert_eq!(registry.download_for_window(&first).unwrap(), None);
assert_ne!(registry.allocate("download-a").unwrap(), first);
}
#[test]
fn invalid_ids_are_rejected() {
assert!(validate_download_id("").is_err());
assert!(validate_download_id("\n").is_err());
assert!(validate_download_id("valid-id").is_ok());
}
#[test]
fn child_actions_are_allowlisted() {
assert!(is_properties_action("apply-properties"));
assert!(is_properties_action("set-torrent-peer-options"));
assert!(!is_properties_action("get_keychain_password"));
assert!(!is_properties_action(""));
}
}
+6 -10
View File
@@ -10,11 +10,13 @@ import { KeychainPermissionModal } from './components/KeychainPermissionModal';
import { extractValidDownloadUrls } from './utils/url';
import { readClipboardDownloadUrls } from './utils/clipboard';
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
import { initializeDownloadPersistence, useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { initDownloadListener } from './store/downloadStore';
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore";
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
import { WindowControls } from "./components/WindowControls";
import { PropertiesWindowBridgeHost } from "./components/PropertiesWindowBridgeHost";
import { useToast } from "./contexts/ToastContext";
import { setLogStreamActive } from './utils/logger';
import { updateDockBadge } from './utils/dockBadge';
@@ -49,9 +51,6 @@ const SettingsView = lazy(loadSettingsView);
const SchedulerView = lazy(loadSchedulerView);
const SpeedLimiterView = lazy(loadSpeedLimiterView);
const LogsView = lazy(loadLogsView);
const PropertiesModal = lazy(() => import('./components/PropertiesModal').then(module => ({
default: module.PropertiesModal,
})));
const DeleteConfirmationModal = lazy(() => import('./components/DeleteConfirmationModal').then(module => ({
default: module.DeleteConfirmationModal,
})));
@@ -226,7 +225,6 @@ function App() {
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen);
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen);
const downloads = useDownloadStore(state => state.downloads);
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
@@ -409,6 +407,7 @@ function App() {
}, [sidebarWidth]);
useEffect(() => {
const disposePersistence = initializeDownloadPersistence(getCurrentWindow().label);
let active = true;
let cleanupListeners: (() => void) | null = null;
const initialize = async () => {
@@ -624,6 +623,7 @@ function App() {
pendingStartupInputs.current = [];
cleanupListeners?.();
cleanupListeners = null;
disposePersistence();
};
}, [addToast, queueFrontendReadyUpdate]);
@@ -1166,11 +1166,7 @@ function App() {
{isAddModalOpen && <AddDownloadsModal />}
{selectedPropertiesDownloadId !== null && (
<Suspense fallback={null}>
<PropertiesModal />
</Suspense>
)}
<PropertiesWindowBridgeHost />
{isDeleteModalOpen && (
<Suspense fallback={null}>
<DeleteConfirmationModal />
+8 -2
View File
@@ -56,6 +56,7 @@ import {
import { updateDownloadSelection } from '../utils/downloadSelection';
import { clampFloatingPosition } from '../utils/floatingPosition';
import { FloatingQueueSubmenu } from './FloatingQueueSubmenu';
import { openPropertiesWindow } from '../propertiesBridge';
export interface DownloadTableStatusSummary {
summary: DownloadSummary;
@@ -1383,8 +1384,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, []);
const openProperties = useCallback((id: string) => {
useDownloadStore.getState().setSelectedPropertiesDownloadId(id);
}, []);
void openPropertiesWindow(id).catch(error => {
showInteractionError(t($ => $.downloadTable.interactionError, {
message: t($ => $.downloadTable.properties),
detail: error instanceof Error ? error.message : String(error)
}), error);
});
}, [showInteractionError, t]);
const revealDownloadFile = useCallback(async (item: DownloadItem) => {
const pathToReveal = await getDownloadPath(item);
+472
View File
@@ -0,0 +1,472 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
import { open, save } from '@tauri-apps/plugin-dialog';
import { Copy, FileDown, FolderOpen, Pause, Play, RefreshCw, Save, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot';
import type { TorrentDetails } from '../bindings/TorrentDetails';
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
import { invokeCommand as invoke } from '../ipc';
import {
PROPERTIES_WINDOW_ACTION_RESULT,
PROPERTIES_WINDOW_REMOVED,
PROPERTIES_WINDOW_SNAPSHOT,
sendPropertiesActionRequest,
sendPropertiesReady,
type PropertiesAction,
type PropertiesActionRequest,
type PropertiesActionResult,
type PropertiesPatch,
type PropertiesSnapshot,
type PropertiesSnapshotEvent,
} from '../propertiesBridge';
import { formatDownloadBytes, formatTorrentRatio } from '../utils/downloadProgress';
type PropertiesTab = 'overview' | 'files' | 'trackers' | 'peers' | 'options' | 'transfer' | 'advanced';
const isTorrentStatus = (status: string) =>
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status);
const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'retrying', 'moving'].includes(status);
const safeTitle = (name: string) => {
const bounded = name.replace(/[\r\n\u0000]/g, ' ').trim().slice(0, 160);
return `${bounded || 'Download'} - Properties - Firelink`;
};
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
export const PropertiesWindowApp = () => {
const { t } = useTranslation();
const currentWindow = useMemo(() => getCurrentWindow(), []);
const windowLabel = currentWindow.label;
const [downloadId, setDownloadId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<PropertiesSnapshot | null>(null);
const [activeTab, setActiveTab] = useState<PropertiesTab>('overview');
const [pendingTab, setPendingTab] = useState<PropertiesTab | null>(null);
const [closePrompt, setClosePrompt] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [notice, setNotice] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [fileProgress, setFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
const [peers, setPeers] = useState<TorrentPeerDiagnostics | null>(null);
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
const [details, setDetails] = useState<TorrentDetails | null>(null);
const [diagnosticError, setDiagnosticError] = useState('');
// null means the Files tab has no local selection draft yet; [] is an
// explicit user choice to clear every file and must remain visually empty.
const [selectedFiles, setSelectedFiles] = useState<number[] | null>(null);
const [fileName, setFileName] = useState('');
const [destination, setDestination] = useState('');
const [connections, setConnections] = useState('');
const [trackers, setTrackers] = useState('');
const [excludedTrackers, setExcludedTrackers] = useState('');
const [downloadLimit, setDownloadLimit] = useState('');
const [uploadLimit, setUploadLimit] = useState('');
const [maxPeers, setMaxPeers] = useState('');
const [peerSpeedLimit, setPeerSpeedLimit] = useState('');
const [draftTab, setDraftTab] = useState<PropertiesTab | null>(null);
const draftTabRef = useRef<PropertiesTab | null>(null);
const closeAfterSaveRef = useRef(false);
const switchAfterSaveRef = useRef<PropertiesTab | null>(null);
const requestIdRef = useRef(0);
const latestSnapshotRevisionRef = useRef(0);
const diagnosticsInFlightRef = useRef(new Set<string>());
const snapshotRef = useRef(snapshot);
const activeTabRef = useRef(activeTab);
const downloadIdRef = useRef(downloadId);
snapshotRef.current = snapshot;
activeTabRef.current = activeTab;
downloadIdRef.current = downloadId;
const isTorrent = snapshot?.isTorrent === true;
const tabs = useMemo<PropertiesTab[]>(() => isTorrent
? ['overview', 'files', 'trackers', 'peers', 'options']
: ['overview', 'transfer', 'advanced'], [isTorrent]);
const isDirty = draftTab !== null;
useEffect(() => {
draftTabRef.current = draftTab;
}, [draftTab]);
const hydrateDraft = useCallback((next: PropertiesSnapshot) => {
setFileName(next.fileName);
setDestination(next.destination ?? '');
setConnections(next.connections === undefined ? '' : String(next.connections));
setTrackers(next.torrentTrackers ?? '');
setExcludedTrackers(next.torrentExcludeTrackers ?? '');
setSelectedFiles(next.torrentFileIndices ? [...next.torrentFileIndices] : null);
setDownloadLimit(next.speedLimit ?? '');
setUploadLimit(next.torrentUploadLimit ?? '');
setMaxPeers(next.torrentMaxPeers === undefined ? '' : String(next.torrentMaxPeers));
setPeerSpeedLimit(next.torrentPeerSpeedLimit ?? '');
}, []);
const refreshDiagnostics = useCallback(async (tab: PropertiesTab, id: string) => {
if (!isTorrentStatus(snapshotRef.current?.status ?? '')) return;
const requestKey = `${id}:${tab}`;
if (diagnosticsInFlightRef.current.has(requestKey)) return;
diagnosticsInFlightRef.current.add(requestKey);
const isCurrent = () => downloadIdRef.current === id
&& activeTabRef.current === tab
&& isTorrentStatus(snapshotRef.current?.status ?? '');
if (isCurrent()) setDiagnosticError('');
try {
if (tab === 'overview') {
const nextDetails = await invoke('get_torrent_details', { id });
if (isCurrent()) setDetails(nextDetails);
} else if (tab === 'files') {
const nextProgress = await invoke('get_torrent_file_progress', { id });
if (isCurrent()) setFileProgress(nextProgress);
} else if (tab === 'peers') {
const [nextPeers, nextAvailability] = await Promise.all([
invoke('get_torrent_peers', { id }),
invoke('get_torrent_availability', { id }),
]);
if (isCurrent()) {
setPeers(nextPeers);
setAvailability(nextAvailability);
}
}
} catch (error) {
if (isCurrent()) setDiagnosticError(errorText(error));
} finally {
diagnosticsInFlightRef.current.delete(requestKey);
}
}, []);
useEffect(() => {
let cancelled = false;
let unlistenSnapshot: UnlistenFn | undefined;
let unlistenResult: UnlistenFn | undefined;
let unlistenRemoved: UnlistenFn | undefined;
const start = async () => {
try {
const id = await invoke('get_properties_window_download_id');
if (cancelled) return;
setDownloadId(id);
unlistenSnapshot = await listen<PropertiesSnapshotEvent>(PROPERTIES_WINDOW_SNAPSHOT, event => {
if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id) return;
if (event.payload.revision <= latestSnapshotRevisionRef.current) return;
latestSnapshotRevisionRef.current = event.payload.revision;
setSnapshot(event.payload.snapshot);
if (draftTabRef.current === null) hydrateDraft(event.payload.snapshot);
void currentWindow.setTitle(safeTitle(event.payload.snapshot.fileName)).catch(() => undefined);
});
unlistenResult = await listen<PropertiesActionResult>(PROPERTIES_WINDOW_ACTION_RESULT, event => {
if (event.payload.windowLabel !== windowLabel || event.payload.downloadId !== id) return;
if (event.payload.requestId !== requestIdRef.current) return;
setIsSaving(false);
if (!event.payload.ok) setErrorMessage(event.payload.error ?? 'The action failed');
else {
const nextTab = switchAfterSaveRef.current;
const shouldClose = closeAfterSaveRef.current;
switchAfterSaveRef.current = null;
closeAfterSaveRef.current = false;
setErrorMessage('');
setNotice(t($ => $.properties.saved));
draftTabRef.current = null;
setDraftTab(null);
if (nextTab) {
setActiveTab(nextTab);
setPendingTab(null);
}
if (shouldClose) {
setClosePrompt(false);
void currentWindow.close().catch(error => setErrorMessage(errorText(error)));
}
}
});
unlistenRemoved = await listen<{ windowLabel: string; downloadId: string }>(PROPERTIES_WINDOW_REMOVED, event => {
if (event.payload.windowLabel === windowLabel && event.payload.downloadId === id) {
setSnapshot(null);
setNotice(t($ => $.downloadTable.noDownloads));
}
});
await sendPropertiesReady();
} catch (error) {
if (!cancelled) setErrorMessage(errorText(error));
}
};
void start();
return () => {
cancelled = true;
unlistenSnapshot?.();
unlistenResult?.();
unlistenRemoved?.();
};
}, [currentWindow, hydrateDraft, t, windowLabel]);
useEffect(() => {
if (!snapshot || draftTab !== null) return;
hydrateDraft(snapshot);
}, [draftTab, hydrateDraft, snapshot]);
useEffect(() => {
if (!downloadId || !snapshot || !isTorrent) return;
void refreshDiagnostics(activeTab, downloadId);
if (!['files', 'peers'].includes(activeTab)) return;
const interval = window.setInterval(() => void refreshDiagnostics(activeTab, downloadId), activeTab === 'peers' ? 3000 : 2000);
return () => window.clearInterval(interval);
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot]);
useEffect(() => {
if (!isDirty) return;
let unlisten: UnlistenFn | undefined;
void currentWindow.onCloseRequested(event => {
event.preventDefault();
setClosePrompt(true);
}).then(value => { unlisten = value; });
return () => unlisten?.();
}, [currentWindow, isDirty]);
const requestAction = useCallback(async (
action: PropertiesAction,
payload?: PropertiesActionRequest['payload'],
) => {
if (!downloadId) return;
const requestId = ++requestIdRef.current;
setIsSaving(action === 'apply-properties');
try {
await sendPropertiesActionRequest({
windowLabel,
downloadId,
requestId,
action,
payload,
});
} catch (error) {
setIsSaving(false);
closeAfterSaveRef.current = false;
switchAfterSaveRef.current = null;
setErrorMessage(errorText(error));
}
}, [downloadId, windowLabel]);
const applyActiveTab = useCallback(async () => {
if (!snapshot || !isEditableStatus(snapshot.status)) {
closeAfterSaveRef.current = false;
switchAfterSaveRef.current = null;
setErrorMessage(t($ => $.properties.editingUnavailable));
return;
}
const patch: PropertiesPatch = {};
if (activeTab === 'overview') {
patch.fileName = fileName;
patch.destination = destination || undefined;
if (connections.trim()) patch.connections = Number(connections);
} else if (activeTab === 'files' && isTorrent) {
const nextSelectedFiles = selectedFiles
?? fileProgress?.files.filter(file => file.selected).map(file => file.index)
?? [];
if (nextSelectedFiles.length === 0) {
setErrorMessage(t($ => $.properties.torrentFileSelectionRequired));
closeAfterSaveRef.current = false;
switchAfterSaveRef.current = null;
return;
}
patch.torrentFileIndices = nextSelectedFiles;
} else if (activeTab === 'trackers') {
patch.torrentTrackers = trackers;
patch.torrentExcludeTrackers = excludedTrackers;
} else if (activeTab === 'options' || activeTab === 'transfer') {
if (downloadLimit !== snapshot.speedLimit) patch.speedLimit = downloadLimit;
if (isTorrent) {
patch.torrentUploadLimit = uploadLimit;
patch.torrentMaxPeers = maxPeers.trim() ? Number(maxPeers) : undefined;
patch.torrentPeerSpeedLimit = peerSpeedLimit;
}
}
await requestAction('apply-properties', patch);
}, [activeTab, connections, destination, downloadLimit, excludedTrackers, fileName, fileProgress, isTorrent, maxPeers, peerSpeedLimit, requestAction, selectedFiles, snapshot, t, trackers, uploadLimit]);
const chooseTab = (tab: PropertiesTab) => {
if (tab === activeTab) return;
if (isDirty) setPendingTab(tab);
else setActiveTab(tab);
};
const discardDraft = () => {
const shouldClose = closePrompt;
if (snapshot) hydrateDraft(snapshot);
draftTabRef.current = null;
setDraftTab(null);
if (pendingTab) setActiveTab(pendingTab);
setPendingTab(null);
setClosePrompt(false);
if (shouldClose) void currentWindow.close().catch(error => setErrorMessage(errorText(error)));
};
const closeWindow = async () => {
if (!downloadId) return;
try {
await invoke('close_download_properties_window', { id: downloadId });
} catch (error) {
setErrorMessage(errorText(error));
}
};
const performTorrentAction = async (action: 'magnet' | 'export' | 'move' | 'verify') => {
if (!downloadId) return;
try {
if (action === 'magnet') {
await writeClipboardText(await invoke('get_torrent_magnet_link', { id: downloadId }));
setNotice(t($ => $.properties.torrentMagnetCopied));
} else if (action === 'export') {
const destinationPath = await save({ defaultPath: `${snapshot?.fileName || 'download'}.torrent` });
if (destinationPath) {
await invoke('export_torrent_metadata', { id: downloadId, destination: destinationPath });
setNotice(t($ => $.properties.torrentMetadataExported));
}
} else if (action === 'move') {
const selected = await open({ directory: true, multiple: false });
if (selected && typeof selected === 'string') {
await invoke('move_torrent_data', { id: downloadId, destination: selected });
setNotice(t($ => $.properties.torrentMoveCompleted));
}
} else {
await invoke('verify_torrent_data', { id: downloadId });
setNotice(t($ => $.properties.torrentVerifyIntegrity));
}
} catch (error) {
setErrorMessage(errorText(error));
}
};
if (!downloadId) {
return <main className="properties-window-shell p-6" role="status">{errorMessage || t($ => $.app.loading)}</main>;
}
if (!snapshot) {
return <main className="properties-window-shell p-6" role="status">{errorMessage || t($ => $.app.loading)}</main>;
}
const progress = Math.max(0, Math.min(1, snapshot.fraction ?? 0));
const total = snapshot.size || (snapshot.totalBytes === undefined
? t($ => $.addDownloads.unknownSize)
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
const statusLabel = t($ => $.downloads.status[snapshot.status]);
const tabLabel = (tab: PropertiesTab) => {
switch (tab) {
case 'overview': return t($ => $.properties.torrentDetails);
case 'files': return t($ => $.properties.torrentFileProgress);
case 'trackers': return t($ => $.properties.torrentTrackers);
case 'peers': return t($ => $.properties.torrentPeerDiagnostics);
case 'options': return t($ => $.properties.advancedTransfer);
case 'transfer': return t($ => $.properties.connections);
case 'advanced': return t($ => $.properties.advancedTransfer);
}
};
return (
<main className="properties-window-shell flex h-screen min-h-0 flex-col bg-main-bg text-text-primary" aria-labelledby="properties-window-title">
<header className="shrink-0 border-b border-border-modal bg-sidebar-bg px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 id="properties-window-title" className="truncate text-base font-semibold" title={snapshot.fileName}>{snapshot.fileName}</h1>
<p className="mt-1 text-xs text-text-muted" role="status">{statusLabel} · {Math.round(progress * 100)}% · {total}</p>
</div>
<div className="flex flex-wrap items-center gap-2" aria-label={t($ => $.actions.continue)}>
<button type="button" className="app-button px-3 text-xs" onClick={() => void requestAction('pause-resume')}>
{['paused', 'ready', 'staged', 'completed', 'failed'].includes(snapshot.status) ? <Play size={14} /> : <Pause size={14} />}
{['paused', 'ready', 'staged', 'completed', 'failed'].includes(snapshot.status) ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.pause)}
</button>
{isTorrent && <>
<button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('magnet')}><Copy size={14} />{t($ => $.properties.torrentCopyMagnet)}</button>
<button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('export')}><FileDown size={14} />{t($ => $.properties.torrentExportMetadata)}</button>
<button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('move')}><FolderOpen size={14} />{t($ => $.properties.torrentMove)}</button>
</>}
</div>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-item-hover" aria-label={t($ => $.properties.progress)}>
<div className="h-full rounded-full bg-accent transition-[width] motion-reduce:transition-none" style={{ width: `${progress * 100}%` }} />
</div>
<div className="mt-3 grid min-w-0 grid-cols-2 gap-2 text-[11px] text-text-muted sm:grid-cols-4" dir="ltr">
<span>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</span>
<span>{snapshot.speed || '—'}</span>
<span>{snapshot.eta || '—'}</span>
{isTorrent && <span>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</span>}
</div>
</header>
<nav className="properties-window-tabs flex shrink-0 gap-1 overflow-x-auto border-b border-border-modal px-4" role="tablist" aria-label={t($ => $.downloadTable.properties)}>
{tabs.map(tab => (
<button
key={tab}
type="button"
role="tab"
aria-selected={activeTab === tab}
aria-controls={`properties-panel-${tab}`}
tabIndex={activeTab === tab ? 0 : -1}
className={`whitespace-nowrap border-b-2 px-3 py-2 text-xs font-medium ${activeTab === tab ? 'border-accent text-text-primary' : 'border-transparent text-text-muted hover:text-text-primary'}`}
onClick={() => chooseTab(tab)}
onKeyDown={event => {
const index = tabs.indexOf(tab);
const nextIndex = event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index - 1 + tabs.length) % tabs.length : event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : -1;
if (nextIndex >= 0) {
event.preventDefault();
const next = tabs[nextIndex];
chooseTab(next);
window.setTimeout(() => document.getElementById(`properties-tab-${next}`)?.focus(), 0);
}
}}
id={`properties-tab-${tab}`}
>{tabLabel(tab)}</button>
))}
</nav>
<section id={`properties-panel-${activeTab}`} role="tabpanel" aria-labelledby={`properties-tab-${activeTab}`} className="min-h-0 flex-1 overflow-auto p-5" tabIndex={0}>
{activeTab === 'overview' && <div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs text-text-muted">{t($ => $.properties.fileName)}<input className="app-control mt-1 w-full" value={fileName} onChange={event => { setFileName(event.target.value); setDraftTab('overview'); }} disabled={!isEditableStatus(snapshot.status)} /></label>
<label className="text-xs text-text-muted">{t($ => $.properties.destination)}<input className="app-control mt-1 w-full" value={destination} onChange={event => { setDestination(event.target.value); setDraftTab('overview'); }} disabled={!isEditableStatus(snapshot.status)} /></label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.url)}</span><p className="mt-1 break-all" dir="ltr">{snapshot.url}</p></div>
<div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.category)}</span><p className="mt-1">{snapshot.category}</p></div>
</div>
{isTorrent && details && <div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
<span className="text-text-muted">{t($ => $.properties.torrentDetailsInfoHash)}</span><span className="font-mono break-all">{details.infoHash}</span>
<span className="text-text-muted">{t($ => $.properties.torrentDetailsPieces)}</span><span>{details.pieceCount} × {formatDownloadBytes(details.pieceLength)}</span>
<span className="text-text-muted">{t($ => $.properties.torrentDetailsPrivate)}</span><span>{details.private ? t($ => $.properties.torrentDetailsPrivateYes) : t($ => $.properties.torrentDetailsPrivateNo)}</span>
</div>}
{isTorrent && <div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" onClick={() => void performTorrentAction('verify')}><RefreshCw size={14} />{t($ => $.properties.torrentVerifyNow)}</button></div>}
</div>}
{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" 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" onClick={() => { setSelectedFiles([]); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionClear)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => downloadId && void refreshDiagnostics('files', downloadId)}><RefreshCw size={14} />{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} 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 + 1} ${file.relativePath}`} /></td><td className="p-2">{file.index + 1}</td><td className="max-w-[420px] truncate p-2" dir="auto">{file.relativePath}</td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
</div>}
{activeTab === 'trackers' && isTorrent && <div className="space-y-4">
<label className="block text-xs text-text-muted">{t($ => $.properties.torrentTrackers)}<textarea className="app-control mt-1 min-h-28 w-full font-mono" value={trackers} onChange={event => { setTrackers(event.target.value); setDraftTab('trackers'); }} /></label>
<label className="block text-xs text-text-muted">{t($ => $.properties.torrentExcludeTrackers)}<textarea className="app-control mt-1 min-h-28 w-full font-mono" value={excludedTrackers} onChange={event => { setExcludedTrackers(event.target.value); setDraftTab('trackers'); }} /></label>
<p className="text-xs text-text-muted">{t($ => $.properties.torrentTrackersHint)}</p>
{details && <div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><strong>{t($ => $.properties.torrentDetailsTrackers)}</strong><p className="mt-1 break-words" dir="auto">{details.trackers.join(', ') || '—'}</p></div>}
</div>}
{activeTab === 'peers' && isTorrent && <div className="space-y-4">
<div className="flex items-center justify-between"><p className="text-sm">{peers ? t($ => $.properties.torrentPeerCount, { total: peers.totalPeers, seeders: peers.totalSeeders }) : t($ => $.properties.torrentPeerDiagnosticsUnavailable)}</p><button type="button" className="app-button px-3 text-xs" onClick={() => downloadId && void refreshDiagnostics('peers', downloadId)}><RefreshCw size={14} />{t($ => $.properties.torrentPeerDiagnosticsRefresh)}</button></div>
<div className="grid gap-3 sm:grid-cols-2"><div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.torrentAvailability)}</span><p className="mt-1">{availability ? `${availability.availability} · ${availability.pieceCount} ${t($ => $.properties.torrentDetailsPieces)}` : '—'}</p></div><div className="rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs"><span className="text-text-muted">{t($ => $.properties.torrentPeerDiagnosticsHint)}</span><p className="mt-1">{peers?.truncated ? t($ => $.properties.torrentPeerShowing, { shown: peers.peers.length, total: peers.totalPeers }) : peers?.peers.length ?? 0}</p></div></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[520px] text-xs" dir="ltr"><thead className="bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentPeerDownload)}</th><th className="p-2">{t($ => $.properties.torrentPeerUpload)}</th><th className="p-2">{t($ => $.properties.torrentPeerSeeder)}</th><th className="p-2">{t($ => $.properties.torrentPeerChoking)}</th></tr></thead><tbody>{peers?.peers.map((peer, index) => <tr key={index} className="border-t border-border-modal/60"><td className="p-2">{formatDownloadBytes(peer.downloadSpeed)}/s</td><td className="p-2">{formatDownloadBytes(peer.uploadSpeed)}/s</td><td className="p-2">{peer.seeder ? '✓' : '—'}</td><td className="p-2">{peer.peerChoking ? '✓' : '—'}</td></tr>)}</tbody></table></div>
</div>}
{(activeTab === 'transfer' || activeTab === 'options') && <div className="grid max-w-2xl gap-4 sm:grid-cols-2">
<label className="text-xs text-text-muted">{t($ => $.properties.speedCap)}<input className="app-control mt-1 w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab(activeTab); }} placeholder="1024K" disabled={!isEditableStatus(snapshot.status)} /></label>
{isTorrent && <label className="text-xs text-text-muted">{t($ => $.properties.liveTorrentUploadLimit)}<input className="app-control mt-1 w-full" value={uploadLimit} onChange={event => { setUploadLimit(event.target.value); setDraftTab(activeTab); }} placeholder="1024K" disabled={!isEditableStatus(snapshot.status)} /></label>}
{isTorrent && <label className="text-xs text-text-muted">{t($ => $.properties.torrentMaxPeers)}<input className="app-control mt-1 w-full" value={maxPeers} onChange={event => { setMaxPeers(event.target.value); setDraftTab(activeTab); }} inputMode="numeric" disabled={!isEditableStatus(snapshot.status)} /></label>}
{isTorrent && <label className="text-xs text-text-muted">{t($ => $.properties.torrentPeerSpeedLimit)}<input className="app-control mt-1 w-full" value={peerSpeedLimit} onChange={event => { setPeerSpeedLimit(event.target.value); setDraftTab(activeTab); }} placeholder="50K" disabled={!isEditableStatus(snapshot.status)} /></label>}
</div>}
{activeTab === 'advanced' && <div className="space-y-4"><p className="text-xs text-text-muted">{t($ => $.properties.advancedTransfer)}</p><p className="text-xs">{snapshot.hasCookies ? t($ => $.properties.cookies) : '—'} · {snapshot.hasHeaders ? t($ => $.properties.headers) : '—'}</p><p className="text-xs text-text-muted">{t($ => $.properties.liveSpeedLimitHint)}</p></div>}
</section>
{(isDirty || errorMessage || notice || pendingTab || closePrompt) && <div className="shrink-0 border-t border-border-modal bg-sidebar-bg px-4 py-2" aria-live="polite">
{pendingTab || closePrompt ? <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span>{t($ => $.scheduler.unsavedChanges)}</span><div className="flex gap-2"><button type="button" className="app-button px-3 text-xs" onClick={discardDraft}>{t($ => $.actions.cancel)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={isSaving} onClick={() => { closeAfterSaveRef.current = closePrompt; switchAfterSaveRef.current = pendingTab; void applyActiveTab(); }}>{t($ => $.properties.save)}</button><button type="button" className="app-button px-3 text-xs" onClick={() => { switchAfterSaveRef.current = null; closeAfterSaveRef.current = false; setPendingTab(null); setClosePrompt(false); }}>{t($ => $.properties.cancel)}</button></div></div> : <div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span className={errorMessage ? 'text-red-400' : 'text-text-muted'}>{errorMessage || notice}</span><div className="flex gap-2">{isDirty && <><button type="button" className="app-button px-3 text-xs" onClick={discardDraft}>{t($ => $.actions.cancel)}</button><button type="button" className="app-button app-button-primary px-3 text-xs" disabled={isSaving} onClick={() => void applyActiveTab()}><Save size={14} />{t($ => $.properties.save)}</button></>}<button type="button" className="app-button px-3 text-xs" onClick={() => void closeWindow()}><X size={14} />{t($ => $.properties.cancel)}</button></div></div>}
</div>}
</main>
);
};
@@ -0,0 +1,250 @@
import { useEffect } from 'react';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useDownloadStore } from '../store/useDownloadStore';
import type { DownloadItem } from '../store/useDownloadStore';
import { getPauseResumeAction } from '../utils/downloadActions';
import {
isValidTorrentExcludeTrackerList,
isValidTorrentTrackerList,
normalizeSpeedLimitForBackend,
} from '../utils/downloads';
import {
PROPERTIES_WINDOW_ACTION_REQUEST,
PROPERTIES_WINDOW_CLOSED,
PROPERTIES_WINDOW_READY,
applySecretPatch,
sanitizePropertiesSnapshot,
sendPropertiesActionResult,
sendPropertiesRemoved,
sendPropertiesSnapshot,
type PropertiesActionRequest,
type PropertiesPatch,
type PropertiesWindowReady,
} from '../propertiesBridge';
import { invokeCommand as invoke } from '../ipc';
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
const normalizeOptionalSpeed = (value: unknown, label: string): string | undefined => {
if (typeof value !== 'string') throw new Error(`Invalid ${label}`);
const trimmed = value.trim();
if (!trimmed) return undefined;
const normalized = normalizeSpeedLimitForBackend(trimmed);
if (!normalized) throw new Error(`Invalid ${label}`);
return normalized;
};
const copyEditablePatch = (rawPatch: PropertiesPatch): Partial<DownloadItem> => {
const safePatch: Partial<DownloadItem> = {};
const copy = (key: keyof PropertiesPatch) => {
if (Object.prototype.hasOwnProperty.call(rawPatch, key)) {
(safePatch as Record<string, unknown>)[key] = rawPatch[key];
}
};
for (const key of [
'fileName',
'destination',
'connections',
'speedLimit',
'torrentFileIndices',
'torrentTrackers',
'torrentExcludeTrackers',
'torrentUploadLimit',
'torrentMaxPeers',
'torrentPeerSpeedLimit',
] as const) copy(key);
if (safePatch.fileName !== undefined && typeof safePatch.fileName !== 'string') {
throw new Error('Invalid file name');
}
if (safePatch.destination !== undefined && typeof safePatch.destination !== 'string') {
throw new Error('Invalid destination');
}
if (safePatch.connections !== undefined
&& (!Number.isInteger(safePatch.connections) || safePatch.connections < 1 || safePatch.connections > 16)) {
throw new Error('Connections must be a whole number from 1 to 16');
}
if (safePatch.speedLimit !== undefined) {
safePatch.speedLimit = normalizeOptionalSpeed(safePatch.speedLimit, 'download speed limit');
}
if (safePatch.torrentUploadLimit !== undefined) {
safePatch.torrentUploadLimit = normalizeOptionalSpeed(safePatch.torrentUploadLimit, 'Torrent upload limit');
}
if (safePatch.torrentPeerSpeedLimit !== undefined) {
safePatch.torrentPeerSpeedLimit = normalizeOptionalSpeed(safePatch.torrentPeerSpeedLimit, 'Torrent peer speed limit');
}
if (safePatch.torrentMaxPeers !== undefined
&& (!Number.isInteger(safePatch.torrentMaxPeers) || safePatch.torrentMaxPeers < 0 || safePatch.torrentMaxPeers > 1000)) {
throw new Error('Torrent maximum peers must be a whole number from 0 to 1000');
}
if (safePatch.torrentTrackers !== undefined
&& (typeof safePatch.torrentTrackers !== 'string' || !isValidTorrentTrackerList(safePatch.torrentTrackers))) {
throw new Error('Invalid Torrent tracker list');
}
if (typeof safePatch.torrentTrackers === 'string' && !safePatch.torrentTrackers.trim()) {
safePatch.torrentTrackers = undefined;
}
if (safePatch.torrentExcludeTrackers !== undefined
&& (typeof safePatch.torrentExcludeTrackers !== 'string' || !isValidTorrentExcludeTrackerList(safePatch.torrentExcludeTrackers))) {
throw new Error('Invalid excluded Torrent tracker list');
}
if (typeof safePatch.torrentExcludeTrackers === 'string' && !safePatch.torrentExcludeTrackers.trim()) {
safePatch.torrentExcludeTrackers = undefined;
}
if (safePatch.torrentFileIndices !== undefined
&& (!Array.isArray(safePatch.torrentFileIndices)
|| safePatch.torrentFileIndices.length === 0
|| safePatch.torrentFileIndices.some(index => !Number.isInteger(index) || index < 0))) {
throw new Error('Torrent file selection must contain at least one valid file');
}
return safePatch;
};
export const PropertiesWindowBridgeHost = () => {
useEffect(() => {
const windows = new Map<string, string>();
const snapshotRevisions = new Map<string, number>();
let disposed = false;
let unlistenReady: UnlistenFn | undefined;
let unlistenAction: UnlistenFn | undefined;
let unlistenClosed: UnlistenFn | undefined;
const sendFor = async (windowLabel: string, downloadId: string) => {
const item = useDownloadStore.getState().downloads.find(download => download.id === downloadId);
if (!item || disposed) return false;
const revision = (snapshotRevisions.get(windowLabel) ?? 0) + 1;
snapshotRevisions.set(windowLabel, revision);
await sendPropertiesSnapshot(windowLabel, {
windowLabel,
downloadId,
revision,
snapshot: sanitizePropertiesSnapshot(item),
});
return true;
};
const handleReady = async (payload: PropertiesWindowReady) => {
try {
await invoke('validate_properties_window_request', payload);
const item = useDownloadStore.getState().downloads.find(download => download.id === payload.downloadId);
if (!item) {
await sendPropertiesRemoved(payload.windowLabel, payload.downloadId);
return;
}
windows.set(payload.windowLabel, payload.downloadId);
if (!snapshotRevisions.has(payload.windowLabel)) snapshotRevisions.set(payload.windowLabel, 0);
await sendFor(payload.windowLabel, payload.downloadId);
} catch {
// The child will show its own unavailable state. Do not log bridge
// payloads because they may contain URLs or other user data.
}
};
const handleAction = async (request: PropertiesActionRequest) => {
let ok = false;
let error: string | undefined;
try {
await invoke('validate_properties_window_request', request);
if (windows.get(request.windowLabel) !== request.downloadId) {
throw new Error('Properties window is no longer registered');
}
const store = useDownloadStore.getState();
const item = store.downloads.find(download => download.id === request.downloadId);
if (!item) throw new Error('Download no longer exists');
switch (request.action) {
case 'apply-properties': {
const rawPatch = (request.payload ?? {}) as PropertiesPatch;
const safePatch = copyEditablePatch(rawPatch);
if ('password' in rawPatch) {
safePatch.password = applySecretPatch(rawPatch.password, item.password);
}
if ('cookies' in rawPatch) {
safePatch.cookies = applySecretPatch(rawPatch.cookies, item.cookies);
}
if ('headers' in rawPatch) {
safePatch.headers = applySecretPatch(rawPatch.headers, item.headers);
}
if ('username' in rawPatch) {
safePatch.username = applySecretPatch(rawPatch.username, item.username);
}
await store.applyProperties(request.downloadId, safePatch);
break;
}
case 'pause-resume':
if (getPauseResumeAction(item.status) === 'pause') await store.pauseDownload(request.downloadId);
else await store.resumeDownload(request.downloadId);
break;
case 'set-download-limit':
await store.setDownloadSpeedLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null);
break;
case 'set-torrent-upload-limit':
await store.setTorrentUploadLimit(request.downloadId, request.payload && 'limit' in request.payload ? request.payload.limit : null);
break;
case 'set-torrent-peer-options': {
if (!request.payload || !('maxPeers' in request.payload)) throw new Error('Invalid Torrent peer options');
await store.setTorrentPeerOptions(request.downloadId, request.payload.maxPeers, request.payload.peerSpeedLimit);
break;
}
default:
throw new Error('Invalid Properties action');
}
ok = true;
} catch (caught) {
error = errorText(caught);
}
try {
await sendPropertiesActionResult(request.windowLabel, {
windowLabel: request.windowLabel,
downloadId: request.downloadId,
requestId: request.requestId,
ok,
...(error ? { error } : {}),
});
} catch {
// The window may have closed between the request and its result.
return;
}
if (ok) {
try {
await sendFor(request.windowLabel, request.downloadId);
} catch {
// Snapshot delivery is best effort across a close/reopen race.
}
}
};
void listen<PropertiesWindowReady>(PROPERTIES_WINDOW_READY, event => void handleReady(event.payload)).then(value => { unlistenReady = value; });
void listen<PropertiesActionRequest>(PROPERTIES_WINDOW_ACTION_REQUEST, event => void handleAction(event.payload)).then(value => { unlistenAction = value; });
void listen<string>(PROPERTIES_WINDOW_CLOSED, event => {
windows.delete(event.payload);
snapshotRevisions.delete(event.payload);
}).then(value => { unlistenClosed = value; });
const unsubscribeStore = useDownloadStore.subscribe((state, previous) => {
for (const [windowLabel, downloadId] of windows) {
const next = state.downloads.find(download => download.id === downloadId);
const before = previous.downloads.find(download => download.id === downloadId);
if (!next) {
void sendPropertiesRemoved(windowLabel, downloadId).catch(() => undefined);
windows.delete(windowLabel);
snapshotRevisions.delete(windowLabel);
void invoke('properties_window_registry_remove_for_download', { id: downloadId }).catch(() => undefined);
} else if (next !== before) {
void sendFor(windowLabel, downloadId).catch(() => undefined);
}
}
});
return () => {
disposed = true;
unsubscribeStore();
unlistenReady?.();
unlistenAction?.();
unlistenClosed?.();
};
}, []);
return null;
};
+107 -23
View File
@@ -99,6 +99,27 @@ type ManualUpdateStatus =
type SystemProxyStatus = 'idle' | 'checking' | 'detected' | 'none' | 'error';
export type NetworkSettingsSection = 'general' | 'discovery' | 'connection' | 'limits' | 'advanced';
const networkSettingsSections: NetworkSettingsSection[] = [
'general',
'discovery',
'connection',
'limits',
'advanced',
];
const networkSettingsSectionFromStorage = (): NetworkSettingsSection => {
try {
const stored = window.localStorage.getItem('firelink-network-settings-section');
return networkSettingsSections.includes(stored as NetworkSettingsSection)
? stored as NetworkSettingsSection
: 'general';
} catch {
return 'general';
}
};
const engineStatusCache = new Map<string, EngineStatusItem>();
const engineStatusInFlight = new Map<string, Promise<EngineStatusItem>>();
@@ -288,6 +309,7 @@ export default function SettingsView() {
const { i18n, t } = useTranslation();
const settings = useSettingsStore();
const activeTab = settings.activeSettingsTab;
const [networkSection, setNetworkSection] = useState<NetworkSettingsSection>(networkSettingsSectionFromStorage);
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
const isSidebarOnRight = settings.sidebarPosition === 'right'
|| (settings.sidebarPosition === 'auto' && isRtl);
@@ -317,6 +339,14 @@ export default function SettingsView() {
const userAgentMenuRef = useRef<HTMLDivElement>(null);
const [isUserAgentMenuOpen, setIsUserAgentMenuOpen] = useState(false);
useEffect(() => {
try {
window.localStorage.setItem('firelink-network-settings-section', networkSection);
} catch {
// Restricted WebViews may not expose local storage.
}
}, [networkSection]);
// Local state for engine status
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
@@ -1170,6 +1200,49 @@ runEngineChecks(false);
{/* Network Pane */}
{activeTab === 'network' && (
<div className="settings-pane max-w-[720px]">
<nav className="network-settings-tabs mb-4 flex gap-1 overflow-x-auto border-b border-border-color/60" role="tablist" aria-label={t($ => $.settings.tabs.network)}>
{networkSettingsSections.map(section => {
const label = section === 'general'
? t($ => $.settings.network.proxy)
: section === 'discovery'
? t($ => $.settings.network.torrentPeerDiscovery)
: section === 'connection'
? t($ => $.settings.network.torrentNetwork)
: section === 'limits'
? t($ => $.settings.network.torrentResourceLimits)
: t($ => $.settings.network.torrentAdvanced);
return (
<button
key={section}
type="button"
role="tab"
aria-selected={networkSection === section}
aria-controls={`network-settings-panel-${section}`}
tabIndex={networkSection === section ? 0 : -1}
className={`whitespace-nowrap border-b-2 px-3 py-2 text-xs font-medium ${networkSection === section ? 'border-accent text-text-primary' : 'border-transparent text-text-muted hover:text-text-primary'}`}
onClick={() => setNetworkSection(section)}
onKeyDown={event => {
const index = networkSettingsSections.indexOf(section);
const nextIndex = (event.key === (isRtl ? 'ArrowLeft' : 'ArrowRight'))
? (index + 1) % networkSettingsSections.length
: event.key === (isRtl ? 'ArrowRight' : 'ArrowLeft')
? (index - 1 + networkSettingsSections.length) % networkSettingsSections.length
: event.key === 'Home' ? 0 : event.key === 'End' ? networkSettingsSections.length - 1 : -1;
if (nextIndex < 0) return;
event.preventDefault();
const next = networkSettingsSections[nextIndex];
setNetworkSection(next);
window.setTimeout(() => document.getElementById(`network-settings-tab-${next}`)?.focus(), 0);
}}
id={`network-settings-tab-${section}`}
>
{label}
</button>
);
})}
</nav>
<div id="network-settings-panel-general" role="tabpanel" aria-labelledby="network-settings-tab-general" hidden={networkSection !== 'general'} tabIndex={0}>
<h2 className="settings-section-title">{t($ => $.settings.network.proxy)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-network-row settings-choice-row">
@@ -1256,7 +1329,9 @@ runEngineChecks(false);
</p>
)}
</div>
</div>
<div id="network-settings-panel-discovery" role="tabpanel" aria-labelledby="network-settings-tab-discovery" hidden={networkSection !== 'discovery'} tabIndex={0}>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentPeerDiscovery)}</h2>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
@@ -1324,7 +1399,9 @@ runEngineChecks(false);
{t($ => $.settings.network.torrentPeerDiscoveryRestartNote)}
</p>
</div>
</div>
<div id="network-settings-panel-connection" role="tabpanel" aria-labelledby="network-settings-tab-connection" hidden={networkSection !== 'connection'} tabIndex={0}>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentNetwork)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-network-row">
@@ -1502,31 +1579,9 @@ runEngineChecks(false);
{t($ => $.settings.network.torrentNetworkRestartNote)}
</p>
</div>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentAdvanced)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDhtMessageTimeout)}</span>
<small>{t($ => $.settings.network.torrentDhtMessageTimeoutDescription)}</small>
</div>
<input
type="number"
min={MIN_TORRENT_DHT_MESSAGE_TIMEOUT}
max={MAX_TORRENT_DHT_MESSAGE_TIMEOUT}
step={1}
value={torrentDhtMessageTimeoutInput}
onChange={(event) => setTorrentDhtMessageTimeoutInput(event.target.value)}
onBlur={(event) => commitTorrentDhtMessageTimeout(event.target.value)}
className="app-control settings-port-input text-center"
aria-label={t($ => $.settings.network.torrentDhtMessageTimeout)}
/>
</div>
<p className="settings-group-footer">
{t($ => $.settings.network.torrentNetworkRestartNote)}
</p>
</div>
<div id="network-settings-panel-limits" role="tabpanel" aria-labelledby="network-settings-tab-limits" hidden={networkSection !== 'limits'} tabIndex={0}>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentResourceLimits)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-network-row">
@@ -1576,7 +1631,35 @@ runEngineChecks(false);
/>
</div>
</div>
</div>
<div id="network-settings-panel-advanced" role="tabpanel" aria-labelledby="network-settings-tab-advanced" hidden={networkSection !== 'advanced'} tabIndex={0}>
<h2 className="settings-section-title">{t($ => $.settings.network.torrentAdvanced)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDhtMessageTimeout)}</span>
<small>{t($ => $.settings.network.torrentDhtMessageTimeoutDescription)}</small>
</div>
<input
type="number"
min={MIN_TORRENT_DHT_MESSAGE_TIMEOUT}
max={MAX_TORRENT_DHT_MESSAGE_TIMEOUT}
step={1}
value={torrentDhtMessageTimeoutInput}
onChange={(event) => setTorrentDhtMessageTimeoutInput(event.target.value)}
onBlur={(event) => commitTorrentDhtMessageTimeout(event.target.value)}
className="app-control settings-port-input text-center"
aria-label={t($ => $.settings.network.torrentDhtMessageTimeout)}
/>
</div>
<p className="settings-group-footer">
{t($ => $.settings.network.torrentNetworkRestartNote)}
</p>
</div>
</div>
<section id="network-settings-group-general-identity" role="region" aria-label={t($ => $.settings.network.identity)} hidden={networkSection !== 'general'}>
<h2 className="settings-section-title">{t($ => $.settings.network.identity)}</h2>
<div className="mac-settings-group settings-popup-group">
<div className="mac-settings-row settings-network-row">
@@ -1640,6 +1723,7 @@ runEngineChecks(false);
</div>
<p className="settings-group-footer">{t($ => $.settings.network.userAgentOverrides)}</p>
</div>
</section>
</div>
)}
+1
View File
@@ -231,6 +231,7 @@ const common = {
liveSpeedLimitClear: 'Clear',
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
editingUnavailable: 'These properties cannot be edited while the download is active.',
liveTorrentUploadLimit: 'Live Torrent upload limit',
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
+1
View File
@@ -231,6 +231,7 @@ const fa = {
liveSpeedLimitClear: 'پاک کردن',
liveSpeedLimitFailed: 'به‌روزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانه‌ای هنگام اجرا در دسترس نیست.',
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگی‌ها ممکن نیست.',
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
liveTorrentUploadLimitHint: 'برای تورنت‌های فعال و در حال سید اعمال می‌شود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
+1
View File
@@ -231,6 +231,7 @@ const he = {
liveSpeedLimitClear: 'נקה',
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
+1
View File
@@ -231,6 +231,7 @@ const ru = {
liveSpeedLimitClear: 'Очистить',
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
+1
View File
@@ -231,6 +231,7 @@ const uk = {
liveSpeedLimitClear: 'Очистити',
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
+1
View File
@@ -231,6 +231,7 @@ const zhCN = {
liveSpeedLimitClear: '清除',
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
editingUnavailable: '下载进行时无法编辑这些属性。',
liveTorrentUploadLimit: '实时种子上传限速',
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
liveTorrentUploadLimitPlaceholder: '例如 1024K',
+7
View File
@@ -155,6 +155,13 @@ type CommandMap = {
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
move_many_in_queue: { args: { ids: string[]; queueId: string; direction: 'up' | 'down'; targetIndex?: number }; result: string[] };
remove_from_queue: { args: { id: string }; result: boolean };
open_download_properties_window: { args: { id: string }; result: string };
get_properties_window_download_id: { args: undefined; result: string };
properties_window_send_ready: { args: undefined; result: void };
properties_window_send_action: { args: { requestId: number; action: string; payload?: unknown }; result: void };
validate_properties_window_request: { args: { windowLabel: string; downloadId: string }; result: void };
close_download_properties_window: { args: { id: string }; result: void };
properties_window_registry_remove_for_download: { args: { id: string }; result: void };
};
type CommandName = keyof CommandMap;
+5 -1
View File
@@ -12,6 +12,10 @@ import { i18nReady } from "./i18n";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { ToastProvider } from "./contexts/ToastContext";
import { error as logError, warn as logWarn, initLogger } from "./utils/logger";
import { getCurrentWindow } from '@tauri-apps/api/window';
import { PropertiesWindowApp } from './components/PropertiesWindowApp';
const isPropertiesWindow = getCurrentWindow().label.startsWith('properties-');
void initLogger();
@@ -48,7 +52,7 @@ const renderApp = () => {
<StrictMode>
<ErrorBoundary>
<ToastProvider>
<App />
{isPropertiesWindow ? <PropertiesWindowApp /> : <App />}
</ToastProvider>
</ErrorBoundary>
</StrictMode>,
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import type { DownloadItem } from './store/useDownloadStore';
vi.mock('./ipc', () => ({
invokeCommand: vi.fn(),
}));
vi.mock('@tauri-apps/api/event', () => ({
emit: vi.fn(),
emitTo: vi.fn(),
}));
import { applySecretPatch, sanitizePropertiesSnapshot } from './propertiesBridge';
describe('Properties window bridge', () => {
it('sanitizes transfer secrets while preserving presence flags', () => {
const item = {
id: 'download-1',
fileName: 'example.iso',
url: 'https://example.test/file',
password: 'password',
cookies: 'sid=secret',
headers: 'Authorization: Bearer secret',
username: 'user',
} as DownloadItem;
const snapshot = sanitizePropertiesSnapshot(item);
expect(snapshot).not.toHaveProperty('password');
expect(snapshot).not.toHaveProperty('cookies');
expect(snapshot).not.toHaveProperty('headers');
expect(snapshot).not.toHaveProperty('username');
expect(snapshot.hasPassword).toBe(true);
expect(snapshot.hasCookies).toBe(true);
expect(snapshot.hasHeaders).toBe(true);
expect(snapshot.hasUsername).toBe(true);
});
it('applies explicit secret changes without conflating unchanged fields', () => {
expect(applySecretPatch(undefined, 'existing')).toBe('existing');
expect(applySecretPatch({ kind: 'unchanged' }, 'existing')).toBe('existing');
expect(applySecretPatch({ kind: 'replace', value: 'new' }, 'existing')).toBe('new');
expect(applySecretPatch({ kind: 'clear' }, 'existing')).toBeUndefined();
expect(() => applySecretPatch({ kind: 'replace', value: 42 }, 'existing')).toThrow('Invalid secret value');
expect(() => applySecretPatch({ kind: 'unexpected' }, 'existing')).toThrow('Invalid secret patch');
});
});
+127
View File
@@ -0,0 +1,127 @@
import { emitTo } from '@tauri-apps/api/event';
import type { DownloadItem } from './store/useDownloadStore';
import { invokeCommand as invoke } from './ipc';
export const PROPERTIES_WINDOW_READY = 'properties-window-ready' as const;
export const PROPERTIES_WINDOW_SNAPSHOT = 'properties-window-snapshot' as const;
export const PROPERTIES_WINDOW_ACTION_REQUEST = 'properties-window-action-request' as const;
export const PROPERTIES_WINDOW_ACTION_RESULT = 'properties-window-action-result' as const;
export const PROPERTIES_WINDOW_REMOVED = 'properties-window-removed' as const;
export const PROPERTIES_WINDOW_CLOSED = 'properties-window-closed' as const;
export type PropertiesSnapshot = Omit<DownloadItem, 'password' | 'cookies' | 'headers' | 'username'> & {
hasPassword: boolean;
hasCookies: boolean;
hasHeaders: boolean;
hasUsername: boolean;
};
export type SecretPatch =
| { kind: 'unchanged' }
| { kind: 'replace'; value: string }
| { kind: 'clear' };
export type PropertiesPatch = Partial<Omit<DownloadItem, 'password' | 'cookies' | 'headers' | 'username'>> & {
username?: SecretPatch;
password?: SecretPatch;
cookies?: SecretPatch;
headers?: SecretPatch;
};
export type PropertiesAction =
| 'apply-properties'
| 'pause-resume'
| 'set-download-limit'
| 'set-torrent-upload-limit'
| 'set-torrent-peer-options';
export type PropertiesWindowReady = {
windowLabel: string;
downloadId: string;
};
export type PropertiesActionRequest = {
windowLabel: string;
downloadId: string;
requestId: number;
action: PropertiesAction;
payload?: PropertiesPatch | { limit: string | null } | { maxPeers: string | null; peerSpeedLimit: string | null };
};
export type PropertiesActionResult = {
windowLabel: string;
downloadId: string;
requestId: number;
ok: boolean;
error?: string;
};
export type PropertiesSnapshotEvent = {
windowLabel: string;
downloadId: string;
revision: number;
snapshot: PropertiesSnapshot;
};
const copyWithoutSecrets = (item: DownloadItem): PropertiesSnapshot => {
const {
password,
cookies,
headers,
username,
...safeItem
} = item;
return {
...safeItem,
hasPassword: Boolean(password),
hasCookies: Boolean(cookies),
hasHeaders: Boolean(headers),
hasUsername: Boolean(username),
};
};
export const sanitizePropertiesSnapshot = copyWithoutSecrets;
export const openPropertiesWindow = (downloadId: string): Promise<string> =>
invoke('open_download_properties_window', { id: downloadId });
export const sendPropertiesReady = (): Promise<void> =>
invoke('properties_window_send_ready');
export const sendPropertiesActionRequest = (payload: PropertiesActionRequest): Promise<void> =>
invoke('properties_window_send_action', {
requestId: payload.requestId,
action: payload.action,
payload: payload.payload,
});
export const sendPropertiesSnapshot = (windowLabel: string, payload: PropertiesSnapshotEvent): Promise<void> =>
emitTo(windowLabel, PROPERTIES_WINDOW_SNAPSHOT, payload);
export const sendPropertiesActionResult = (windowLabel: string, payload: PropertiesActionResult): Promise<void> =>
emitTo(windowLabel, PROPERTIES_WINDOW_ACTION_RESULT, payload);
export const sendPropertiesRemoved = (windowLabel: string, downloadId: string): Promise<void> =>
emitTo(windowLabel, PROPERTIES_WINDOW_REMOVED, { windowLabel, downloadId });
export const applySecretPatch = (
patch: unknown,
existing: string | undefined,
): string | undefined => {
if (patch === undefined) return existing;
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
throw new Error('Invalid secret patch');
}
const candidate = patch as Record<string, unknown>;
switch (candidate.kind) {
case 'unchanged':
return existing;
case 'clear':
return undefined;
case 'replace':
if (typeof candidate.value !== 'string') throw new Error('Invalid secret value');
return candidate.value;
default:
throw new Error('Invalid secret patch');
}
};
+37 -22
View File
@@ -2589,27 +2589,42 @@ async function processQueuesSave() {
isSavingQueues = false;
}
useDownloadStore.subscribe((state, prevState) => {
if (state.queues !== prevState.queues) {
const data = JSON.stringify(state.queues);
if (data !== lastSavedQueues) {
lastSavedQueues = data;
nextQueuesData = data;
processQueuesSave();
}
}
let downloadPersistenceUnsubscribe: (() => void) | null = null;
if (state.downloads !== prevState.downloads) {
// Strip secret fields (password/cookies/headers) and volatile progress
// before writing to disk. Secrets remain on the in-memory item for the
// active session only.
const staticDownloads = state.downloads.map(redactDownloadForPersistence);
const currentSerialized = JSON.stringify(staticDownloads);
if (currentSerialized !== lastSavedDownloads) {
lastSavedDownloads = currentSerialized;
nextDownloadsData = currentSerialized;
processDownloadsSave();
/**
* Persistence is a main-webview service. Properties windows import the
* download types and bridge helpers but must never install this subscription
* or write whole-store snapshots from a child webview.
*/
export const initializeDownloadPersistence = (windowLabel: string): (() => void) => {
if (windowLabel !== 'main' || downloadPersistenceUnsubscribe) return () => undefined;
downloadPersistenceUnsubscribe = useDownloadStore.subscribe((state, prevState) => {
if (state.queues !== prevState.queues) {
const data = JSON.stringify(state.queues);
if (data !== lastSavedQueues) {
lastSavedQueues = data;
nextQueuesData = data;
void processQueuesSave();
}
}
}
});
if (state.downloads !== prevState.downloads) {
// Strip secret fields (password/cookies/headers) and volatile progress
// before writing to disk. Secrets remain on the in-memory item for the
// active session only.
const staticDownloads = state.downloads.map(redactDownloadForPersistence);
const currentSerialized = JSON.stringify(staticDownloads);
if (currentSerialized !== lastSavedDownloads) {
lastSavedDownloads = currentSerialized;
nextDownloadsData = currentSerialized;
void processDownloadsSave();
}
}
});
return () => {
downloadPersistenceUnsubscribe?.();
downloadPersistenceUnsubscribe = null;
};
};