From a1d4aab93f3f62ee6f238dfb9c91d3a7e76d1f86 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Sat, 30 May 2026 10:45:45 +0200 Subject: [PATCH 1/6] feat(desktop): improve torrent file integration --- Cargo.lock | 1 + crates/librqbit/webui/src/api-types.ts | 24 +- .../webui/src/components/TorrentRow.tsx | 75 +++- .../src/components/buttons/TorrentActions.tsx | 22 + .../src/components/buttons/UploadButton.tsx | 3 +- .../components/modal/FileSelectionModal.tsx | 8 +- crates/librqbit/webui/src/http-api.ts | 7 + desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/main.rs | 418 +++++++++++++++++- desktop/src/StartupTorrentInputs.tsx | 144 ++++++ desktop/src/api.tsx | 13 + desktop/src/rqbit-desktop.tsx | 19 +- 12 files changed, 717 insertions(+), 18 deletions(-) create mode 100644 desktop/src/StartupTorrentInputs.tsx diff --git a/Cargo.lock b/Cargo.lock index 226024a..04a30bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4641,6 +4641,7 @@ dependencies = [ "base64 0.22.1", "directories 5.0.1", "gethostname 0.5.0", + "gtk", "http", "librqbit", "parking_lot", diff --git a/crates/librqbit/webui/src/api-types.ts b/crates/librqbit/webui/src/api-types.ts index 58436e7..64e41f0 100644 --- a/crates/librqbit/webui/src/api-types.ts +++ b/crates/librqbit/webui/src/api-types.ts @@ -23,9 +23,30 @@ export interface TorrentFileAttributes { export interface TorrentDetails { name: string | null; info_hash: string; + output_folder: string; files: Array; } +export interface LocalTorrentFile { + kind: "local-file"; + path: string; + name: string; +} + +export type TorrentInput = string | File | LocalTorrentFile; + +export function localTorrentFile(path: string): LocalTorrentFile { + return { + kind: "local-file", + path, + name: path.split(/[\\/]/).pop() || path, + }; +} + +export function isLocalTorrentFile(data: TorrentInput): data is LocalTorrentFile { + return typeof data === "object" && !(data instanceof File) && data.kind === "local-file"; +} + export interface AddTorrentResponse { id: number | null; details: TorrentDetails; @@ -192,9 +213,10 @@ export interface RqbitAPI { filename?: string | null, ) => string | null; uploadTorrent: ( - data: string | File, + data: TorrentInput, opts?: AddTorrentOptions, ) => Promise; + openTorrentOutput?: (index: number) => Promise; pause: (index: number) => Promise; updateOnlyFiles: (index: number, files: number[]) => Promise; diff --git a/crates/librqbit/webui/src/components/TorrentRow.tsx b/crates/librqbit/webui/src/components/TorrentRow.tsx index c7f3e3e..307070a 100644 --- a/crates/librqbit/webui/src/components/TorrentRow.tsx +++ b/crates/librqbit/webui/src/components/TorrentRow.tsx @@ -93,9 +93,82 @@ export const TorrentRow: React.FC<{ }; const [extendedView, setExtendedView] = useState(false); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + } | null>(null); + + useEffect(() => { + if (!contextMenu) { + return; + } + + const close = () => setContextMenu(null); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setContextMenu(null); + } + }; + + window.addEventListener("click", close); + window.addEventListener("keydown", closeOnEscape); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("keydown", closeOnEscape); + }; + }, [contextMenu]); + + const openOutput = () => { + if (!API.openTorrentOutput) { + return; + } + + API.openTorrentOutput(id).catch((e) => { + setCloseableError({ + text: `Error opening torrent output id=${id}`, + details: e as ErrorDetails, + }); + }); + setContextMenu(null); + }; + + const handleContextMenu = (event: React.MouseEvent) => { + if (!API.openTorrentOutput) { + return; + } + + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY }); + }; return ( -
+
+ {contextMenu && ( +
event.stopPropagation()} + > + + +
+ )}
{/* Icon */}
{statusIcon("w-10 h-10")}
diff --git a/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx b/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx index cda77d8..1cc0c91 100644 --- a/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx +++ b/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx @@ -9,6 +9,7 @@ import { FaPlay, FaTrash, FaClipboardList, + FaFolderOpen, } from "react-icons/fa"; import { useErrorStore } from "../../stores/errorStore"; import { ErrorComponent } from "../ErrorComponent"; @@ -34,6 +35,22 @@ export const TorrentActions: React.FC<{ const API = useContext(APIContext); + const openOutput = () => { + if (!API.openTorrentOutput) { + return; + } + + setDisabled(true); + API.openTorrentOutput(id) + .catch((e) => { + setCloseableError({ + text: `Error opening torrent output id=${id}`, + details: e, + }); + }) + .finally(() => setDisabled(false)); + }; + const unpause = () => { setDisabled(true); API.start(id) @@ -136,6 +153,11 @@ export const TorrentActions: React.FC<{ )} + {API.openTorrentOutput && ( + + + + )} diff --git a/crates/librqbit/webui/src/components/buttons/UploadButton.tsx b/crates/librqbit/webui/src/components/buttons/UploadButton.tsx index 751830b..d78018a 100644 --- a/crates/librqbit/webui/src/components/buttons/UploadButton.tsx +++ b/crates/librqbit/webui/src/components/buttons/UploadButton.tsx @@ -2,6 +2,7 @@ import { ReactNode, useContext, useEffect, useState } from "react"; import { AddTorrentResponse, ErrorDetails as ApiErrorDetails, + TorrentInput, } from "../../api-types"; import { APIContext } from "../../context"; import { ErrorWithLabel } from "../../rqbit-web"; @@ -10,7 +11,7 @@ import { Button } from "./Button"; export const UploadButton: React.FC<{ onClick: () => void; - data: string | File | null; + data: TorrentInput | null; resetData: () => void; children: ReactNode; className?: string; diff --git a/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx b/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx index e1ccd07..cba5c72 100644 --- a/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx +++ b/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx @@ -1,5 +1,9 @@ import { useContext, useEffect, useState } from "react"; -import { AddTorrentResponse, AddTorrentOptions } from "../../api-types"; +import { + AddTorrentResponse, + AddTorrentOptions, + TorrentInput, +} from "../../api-types"; import { APIContext } from "../../context"; import { ErrorComponent } from "../ErrorComponent"; import { ErrorWithLabel } from "../../rqbit-web"; @@ -19,7 +23,7 @@ export const FileSelectionModal = (props: { listTorrentResponse: AddTorrentResponse | null; listTorrentError: ErrorWithLabel | null; listTorrentLoading: boolean; - data: string | File; + data: TorrentInput; }) => { let { onHide, diff --git a/crates/librqbit/webui/src/http-api.ts b/crates/librqbit/webui/src/http-api.ts index 2e00df3..b4012d8 100644 --- a/crates/librqbit/webui/src/http-api.ts +++ b/crates/librqbit/webui/src/http-api.ts @@ -6,6 +6,7 @@ import { SessionStats, TorrentDetails, TorrentStats, + isLocalTorrentFile, } from "./api-types"; // Define API URL and base path @@ -99,6 +100,12 @@ export const API: RqbitAPI & { getVersion: () => Promise } = { }, uploadTorrent: (data, opts): Promise => { + if (isLocalTorrentFile(data)) { + return Promise.reject({ + text: "Local torrent file paths are only supported in rqbit desktop.", + }); + } + let url = "/torrents?&overwrite=true"; if (opts?.list_only) { url += "&list_only=true"; diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b48609f..5cdb53f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ serde_with = "3.4.0" parking_lot = "0.12.1" gethostname = "0.5.0" tauri-plugin-shell = "2" +gtk = "0.18.2" [features] # this feature is used for production builds or when `devPath` points to the filesystem diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index d8bd8ea..62916e9 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -4,12 +4,17 @@ mod config; use std::{ + ffi::OsString, fs::{File, OpenOptions}, - io::{BufReader, BufWriter}, - path::Path, + io::{BufReader, BufWriter, Read, Write}, + path::{Path, PathBuf}, + process::Command, sync::Arc, }; +#[cfg(unix)] +use std::os::unix::net::{UnixListener, UnixStream}; + use anyhow::Context; use config::RqbitDesktopConfig; use http::StatusCode; @@ -24,8 +29,9 @@ use librqbit::{ AddTorrent, AddTorrentOptions, Api, ApiError, PeerConnectionOptions, Session, SessionOptions, SessionPersistenceConfig, }; -use parking_lot::RwLock; -use serde::Serialize; +use parking_lot::{Mutex, RwLock}; +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Manager}; use tracing::{error, error_span, info, warn}; const ERR_NOT_CONFIGURED: ApiError = @@ -39,9 +45,302 @@ struct StateShared { struct State { config_filename: String, shared: Arc>>, + pending_torrent_inputs: Mutex>, init_logging: InitLoggingResult, } +const TORRENT_INPUTS_EVENT: &str = "torrent-inputs"; + +#[derive(Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum PendingTorrentInput { + FilePath { path: String }, + Url { url: String }, +} + +#[derive(Deserialize, Serialize)] +struct InstanceMessage { + inputs: Vec, +} + +fn pending_torrent_input_from_arg(arg: OsString) -> Option { + let raw = arg.to_string_lossy(); + let arg = raw.trim(); + if arg.is_empty() { + return None; + } + + let lower_arg = arg.to_ascii_lowercase(); + if lower_arg.starts_with("magnet:") + || lower_arg.starts_with("http://") + || lower_arg.starts_with("https://") + { + return Some(PendingTorrentInput::Url { + url: arg.to_owned(), + }); + } + + let path = if let Some(path) = arg.strip_prefix("file://") { + file_uri_to_path(path)? + } else { + PathBuf::from(arg) + }; + + if path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("torrent")) + { + return Some(PendingTorrentInput::FilePath { + path: path.to_string_lossy().into_owned(), + }); + } + + None +} + +fn file_uri_to_path(uri_path: &str) -> Option { + let path = if let Some(path) = uri_path.strip_prefix("localhost/") { + format!("/{path}") + } else { + uri_path.to_owned() + }; + percent_decode(&path).map(PathBuf::from) +} + +fn percent_decode(value: &str) -> Option { + fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } + } + + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut idx = 0; + + while idx < bytes.len() { + if bytes[idx] == b'%' { + let high = *bytes.get(idx + 1)?; + let low = *bytes.get(idx + 2)?; + decoded.push(hex_value(high)? << 4 | hex_value(low)?); + idx += 3; + } else { + decoded.push(bytes[idx]); + idx += 1; + } + } + + String::from_utf8(decoded).ok() +} + +fn pending_torrent_inputs_from_args() -> Vec { + std::env::args_os() + .skip(1) + .filter_map(pending_torrent_input_from_arg) + .collect() +} + +#[cfg(unix)] +enum SingleInstance { + Primary(UnixListener), + Secondary, + Disabled, +} + +#[cfg(unix)] +fn acquire_single_instance(inputs: &[PendingTorrentInput]) -> SingleInstance { + let socket_path = single_instance_socket_path(); + + if send_instance_message(&socket_path, inputs).is_ok() { + return SingleInstance::Secondary; + } + + match bind_single_instance_socket(&socket_path) { + Ok(listener) => SingleInstance::Primary(listener), + Err(e) => { + warn!(error = ?e, path = ?socket_path, "single instance socket disabled"); + SingleInstance::Disabled + } + } +} + +#[cfg(unix)] +fn single_instance_socket_path() -> PathBuf { + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + return PathBuf::from(runtime_dir).join("rqbit-desktop.sock"); + } + + let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_owned()); + std::env::temp_dir().join(format!("rqbit-desktop-{user}.sock")) +} + +#[cfg(unix)] +fn send_instance_message(path: &Path, inputs: &[PendingTorrentInput]) -> anyhow::Result<()> { + let mut stream = UnixStream::connect(path)?; + serde_json::to_writer( + &mut stream, + &InstanceMessage { + inputs: inputs.to_vec(), + }, + )?; + stream.write_all(b"\n")?; + Ok(()) +} + +#[cfg(unix)] +fn bind_single_instance_socket(path: &Path) -> anyhow::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("error creating socket directory {parent:?}"))?; + } + + match UnixListener::bind(path) { + Ok(listener) => Ok(listener), + Err(e) if path.exists() => { + std::fs::remove_file(path) + .with_context(|| format!("error removing stale socket {path:?}"))?; + UnixListener::bind(path) + .with_context(|| format!("error binding single instance socket {path:?}")) + } + Err(e) => Err(e).with_context(|| format!("error binding single instance socket {path:?}")), + } +} + +#[cfg(unix)] +fn start_single_instance_listener(listener: UnixListener, app: tauri::AppHandle) { + std::thread::spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let app = app.clone(); + std::thread::spawn(move || handle_single_instance_stream(stream, app)); + } + Err(e) => warn!("error accepting single instance connection: {:#}", e), + } + } + }); +} + +#[cfg(unix)] +fn handle_single_instance_stream(mut stream: UnixStream, app: tauri::AppHandle) { + let mut body = String::new(); + if let Err(e) = stream.read_to_string(&mut body) { + warn!("error reading single instance message: {:#}", e); + return; + } + + let message: InstanceMessage = match serde_json::from_str(&body) { + Ok(message) => message, + Err(e) => { + warn!("error parsing single instance message: {:#}", e); + return; + } + }; + + bring_main_window_to_front(&app); + + if message.inputs.is_empty() { + return; + } + + let state = app.state::(); + state.pending_torrent_inputs.lock().extend(message.inputs); + + if let Err(e) = app.emit(TORRENT_INPUTS_EVENT, ()) { + warn!("error emitting torrent inputs event: {:#}", e); + } +} + +fn bring_main_window_to_front(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } +} + +#[cfg(target_os = "linux")] +fn apply_gtk_window_controls_layout(app: &tauri::App) { + use gtk::prelude::*; + + let Some(window) = app.get_webview_window("main") else { + return; + }; + let Ok(gtk_window) = window.gtk_window() else { + return; + }; + let Some(titlebar) = gtk_window.titlebar() else { + return; + }; + let Some(header) = find_gtk_header_bar(&titlebar) else { + warn!("could not find GTK header bar for window controls layout"); + return; + }; + + apply_gtk_header_bar_layout(&header); + + let header_for_resize = header.downgrade(); + gtk_window.connect_resizable_notify(move |_| { + if let Some(header) = header_for_resize.upgrade() { + apply_gtk_header_bar_layout(&header); + } + }); + + if let Some(settings) = gtk::Settings::default() { + let header_for_settings = header.downgrade(); + settings.connect_gtk_decoration_layout_notify(move |_| { + if let Some(header) = header_for_settings.upgrade() { + apply_gtk_header_bar_layout(&header); + } + }); + } +} + +#[cfg(target_os = "linux")] +fn find_gtk_header_bar(widget: >k::Widget) -> Option { + use gtk::prelude::*; + + if let Ok(header) = widget.clone().downcast::() { + return Some(header); + } + + let Ok(container) = widget.clone().downcast::() else { + return None; + }; + + for child in container.children() { + if let Some(header) = find_gtk_header_bar(&child) { + return Some(header); + } + } + + None +} + +#[cfg(target_os = "linux")] +fn apply_gtk_header_bar_layout(header: >k::HeaderBar) { + use gtk::prelude::*; + + let layout = gtk_window_controls_layout(); + header.set_decoration_layout(Some(&layout)); + info!(%layout, "applied GTK window controls layout"); +} + +#[cfg(target_os = "linux")] +fn gtk_window_controls_layout() -> String { + use gtk::prelude::*; + + gtk::Settings::default() + .and_then(|settings| settings.gtk_decoration_layout()) + .map(|layout| layout.to_string()) + .filter(|layout| !layout.trim().is_empty()) + .unwrap_or_else(|| "menu:minimize,maximize,close".to_string()) +} + fn read_config(path: &str) -> anyhow::Result { let rdr = BufReader::new(File::open(path)?); let mut config: RqbitDesktopConfig = serde_json::from_reader(rdr)?; @@ -172,7 +471,11 @@ async fn api_from_config( } impl State { - async fn new(init_logging: InitLoggingResult) -> Self { + async fn new( + init_logging: InitLoggingResult, + pending_torrent_inputs: Vec, + ) -> Self { + let pending_torrent_inputs = Mutex::new(pending_torrent_inputs); let config_filename = directories::ProjectDirs::from("com", "rqbit", "desktop") .expect("directories::ProjectDirs::from") .config_dir() @@ -194,6 +497,7 @@ impl State { return Self { config_filename, shared, + pending_torrent_inputs, init_logging, }; } @@ -202,6 +506,7 @@ impl State { config_filename, init_logging, shared: Arc::new(RwLock::new(None)), + pending_torrent_inputs, } } @@ -309,6 +614,21 @@ async fn torrent_create_from_base64_file( .await } +#[tauri::command] +async fn torrent_create_from_file_path( + state: tauri::State<'_, State>, + path: String, + opts: Option, +) -> Result { + let bytes = std::fs::read(&path) + .with_context(|| format!("error reading torrent file {path:?}")) + .map_err(|e| ApiError::new_from_anyhow(StatusCode::BAD_REQUEST, e))?; + state + .api()? + .api_add_torrent(AddTorrent::TorrentFileBytes(bytes.into()), opts) + .await +} + #[tauri::command] async fn torrent_details( state: tauri::State<'_, State>, @@ -374,6 +694,70 @@ async fn stats(state: tauri::State<'_, State>) -> Result) -> Vec { + let mut inputs = state.pending_torrent_inputs.lock(); + std::mem::take(&mut *inputs) +} + +#[tauri::command] +async fn torrent_open_output( + state: tauri::State<'_, State>, + id: TorrentIdOrHash, +) -> Result { + let details = state.api()?.api_torrent_details(id)?; + let path = nearest_existing_path(PathBuf::from(details.output_folder)); + open_file_manager(&path) + .map_err(|e| ApiError::new_from_anyhow(StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(EmptyJsonResponse {}) +} + +fn nearest_existing_path(mut path: PathBuf) -> PathBuf { + let original = path.clone(); + while !path.exists() { + if !path.pop() { + return original; + } + } + path +} + +#[cfg(target_os = "linux")] +fn open_file_manager(path: &Path) -> anyhow::Result<()> { + let mut command = Command::new("xdg-open"); + command.arg(path); + spawn_open_command(command) +} + +#[cfg(target_os = "macos")] +fn open_file_manager(path: &Path) -> anyhow::Result<()> { + let mut command = Command::new("open"); + command.arg(path); + spawn_open_command(command) +} + +#[cfg(target_os = "windows")] +fn open_file_manager(path: &Path) -> anyhow::Result<()> { + let mut command = Command::new("explorer"); + command.arg(path); + spawn_open_command(command) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn open_file_manager(_path: &Path) -> anyhow::Result<()> { + anyhow::bail!("opening downloaded files is not supported on this platform") +} + +fn spawn_open_command(mut command: Command) -> anyhow::Result<()> { + let mut child = command.spawn().context("error opening path")?; + std::thread::spawn(move || { + if let Err(e) = child.wait() { + warn!("error waiting for opener process: {:#}", e); + } + }); + Ok(()) +} + #[tauri::command] fn get_version() -> &'static str { env!("CARGO_PKG_VERSION") @@ -393,11 +777,30 @@ async fn start() { Err(e) => warn!("failed increasing open file limit: {:#}", e), }; - let state = State::new(init_logging_result).await; + let pending_torrent_inputs = pending_torrent_inputs_from_args(); + + #[cfg(unix)] + let single_instance_listener = match acquire_single_instance(&pending_torrent_inputs) { + SingleInstance::Primary(listener) => Some(listener), + SingleInstance::Secondary => return, + SingleInstance::Disabled => None, + }; + + let state = State::new(init_logging_result, pending_torrent_inputs).await; tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(state) + .setup(move |app| { + #[cfg(target_os = "linux")] + apply_gtk_window_controls_layout(app); + + #[cfg(unix)] + if let Some(listener) = single_instance_listener { + start_single_instance_listener(listener, app.handle().clone()); + } + Ok(()) + }) .invoke_handler(tauri::generate_handler![ torrents_list, torrent_details, @@ -409,6 +812,9 @@ async fn start() { torrent_action_start, torrent_action_configure, torrent_create_from_base64_file, + torrent_create_from_file_path, + take_startup_torrent_inputs, + torrent_open_output, stats, get_version, config_default, diff --git a/desktop/src/StartupTorrentInputs.tsx b/desktop/src/StartupTorrentInputs.tsx new file mode 100644 index 0000000..b67bccb --- /dev/null +++ b/desktop/src/StartupTorrentInputs.tsx @@ -0,0 +1,144 @@ +import { useContext, useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { + AddTorrentResponse, + ErrorDetails as ApiErrorDetails, + TorrentInput, + localTorrentFile, +} from "rqbit-webui/src/api-types"; +import { APIContext } from "rqbit-webui/src/context"; +import { FileSelectionModal } from "rqbit-webui/src/components/modal/FileSelectionModal"; +import { ErrorWithLabel } from "rqbit-webui/src/rqbit-web"; + +type PendingTorrentInput = + | { type: "file_path"; path: string } + | { type: "url"; url: string }; + +const TORRENT_INPUTS_EVENT = "torrent-inputs"; + +const toTorrentInput = (input: PendingTorrentInput): TorrentInput => { + if (input.type === "file_path") { + return localTorrentFile(input.path); + } + return input.url; +}; + +export const StartupTorrentInputs = ({ enabled }: { enabled: boolean }) => { + const API = useContext(APIContext); + const [queue, setQueue] = useState([]); + const [activeInput, setActiveInput] = useState(null); + const [listTorrentResponse, setListTorrentResponse] = + useState(null); + const [listTorrentError, setListTorrentError] = + useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!enabled) { + return; + } + + let unlisten: (() => void) | null = null; + let cancelled = false; + + const drainPendingInputs = () => { + invoke("take_startup_torrent_inputs").then( + (inputs) => { + if (!cancelled && inputs.length > 0) { + setQueue((queue) => [...queue, ...inputs.map(toTorrentInput)]); + } + }, + (e) => { + console.error("error reading startup torrent inputs", e); + }, + ); + }; + + listen(TORRENT_INPUTS_EVENT, drainPendingInputs).then( + (cleanup) => { + if (cancelled) { + cleanup(); + return; + } + unlisten = cleanup; + drainPendingInputs(); + }, + (e) => { + console.error("error listening for startup torrent inputs", e); + drainPendingInputs(); + }, + ); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [enabled]); + + useEffect(() => { + if (!activeInput && queue.length > 0) { + const [next, ...remaining] = queue; + setActiveInput(next); + setQueue(remaining); + } + }, [activeInput, queue]); + + useEffect(() => { + if (!activeInput) { + return; + } + + let cancelled = false; + setLoading(true); + setListTorrentError(null); + setListTorrentResponse(null); + + API.uploadTorrent(activeInput, { list_only: true }) + .then( + (response) => { + if (!cancelled) { + setListTorrentResponse(response); + } + }, + (e) => { + if (!cancelled) { + setListTorrentError({ + text: "Error listing torrent files", + details: e as ApiErrorDetails, + }); + } + }, + ) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [API, activeInput]); + + const clearActive = () => { + setActiveInput(null); + setListTorrentError(null); + setListTorrentResponse(null); + setLoading(false); + }; + + if (!activeInput) { + return null; + } + + return ( + + ); +}; diff --git a/desktop/src/api.tsx b/desktop/src/api.tsx index e92a8aa..d59593a 100644 --- a/desktop/src/api.tsx +++ b/desktop/src/api.tsx @@ -7,6 +7,7 @@ import { TorrentStats, ErrorDetails, SessionStats, + isLocalTorrentFile, } from "rqbit-webui/src/api-types"; import { InvokeArgs, invoke } from "@tauri-apps/api/core"; @@ -110,11 +111,23 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => { } ); } + if (isLocalTorrentFile(data)) { + return await invokeAPI( + "torrent_create_from_file_path", + { + path: data.path, + opts: opts ?? {}, + } + ); + } return await invokeAPI("torrent_create_from_url", { url: data, opts: opts ?? {}, }); }, + openTorrentOutput: function (id: number): Promise { + return invokeAPI("torrent_open_output", { id }); + }, updateOnlyFiles: function (id, files): Promise { return invokeAPI("torrent_action_configure", { id: id, diff --git a/desktop/src/rqbit-desktop.tsx b/desktop/src/rqbit-desktop.tsx index e22ff35..f66c7ab 100644 --- a/desktop/src/rqbit-desktop.tsx +++ b/desktop/src/rqbit-desktop.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { RqbitWebUI } from "rqbit-webui/src/rqbit-web"; import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration"; import { ConfigModal } from "./configure"; @@ -6,6 +6,7 @@ import { IconButton } from "rqbit-webui/src/components/buttons/IconButton"; import { BsSliders2 } from "react-icons/bs"; import { APIContext } from "rqbit-webui/src/context"; import { makeAPI } from "./api"; +import { StartupTorrentInputs } from "./StartupTorrentInputs"; export const RqbitDesktop: React.FC<{ version: string; @@ -17,6 +18,7 @@ export const RqbitDesktop: React.FC<{ currentState.config ?? defaultConfig, ); let [configurationOpened, setConfigurationOpened] = useState(false); + const api = useMemo(() => makeAPI(config), [config]); const configButton = ( + {configured && ( - + <> + + + )} Date: Thu, 9 Jul 2026 14:18:06 +0200 Subject: [PATCH 2/6] feat(desktop): Yoda integration patches on v8.1.1 - macOS-style window controls via gtk-decoration-layout - torrent file preview and context menu improvements - single-instance relay for new torrents/magnet links --- crates/librqbit/webui/src/api-types.ts | 2 + .../librqbit/webui/src/components/Torrent.tsx | 42 ++- .../webui/src/components/TorrentRow.tsx | 1 + .../src/components/buttons/TorrentActions.tsx | 27 +- .../librqbit/webui/src/stores/torrentStore.ts | 13 + desktop/src-tauri/src/main.rs | 295 +++++++++++++++++- desktop/src/api.tsx | 3 + desktop/src/rqbit-desktop.tsx | 35 ++- 8 files changed, 405 insertions(+), 13 deletions(-) diff --git a/crates/librqbit/webui/src/api-types.ts b/crates/librqbit/webui/src/api-types.ts index 58436e7..6580d3e 100644 --- a/crates/librqbit/webui/src/api-types.ts +++ b/crates/librqbit/webui/src/api-types.ts @@ -23,6 +23,7 @@ export interface TorrentFileAttributes { export interface TorrentDetails { name: string | null; info_hash: string; + output_folder?: string; files: Array; } @@ -201,5 +202,6 @@ export interface RqbitAPI { start: (index: number) => Promise; forget: (index: number) => Promise; delete: (index: number) => Promise; + openOutputFolder?: (path: string) => Promise; stats: () => Promise; } diff --git a/crates/librqbit/webui/src/components/Torrent.tsx b/crates/librqbit/webui/src/components/Torrent.tsx index 380b05f..6ed4590 100644 --- a/crates/librqbit/webui/src/components/Torrent.tsx +++ b/crates/librqbit/webui/src/components/Torrent.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { TorrentDetails, TorrentId, @@ -9,6 +9,7 @@ import { import { APIContext, RefreshTorrentStatsContext } from "../context"; import { customSetInterval } from "../helper/customSetInterval"; import { loopUntilSuccess } from "../helper/loopUntilSuccess"; +import { useTorrentStore } from "../stores/torrentStore"; import { TorrentRow } from "./TorrentRow"; export const Torrent: React.FC<{ @@ -21,7 +22,13 @@ export const Torrent: React.FC<{ null, ); const [forceStatsRefresh, setForceStatsRefresh] = useState(0); + const rowRef = useRef(null); const API = useContext(APIContext); + const focusedTorrentId = useTorrentStore((state) => state.focusedTorrentId); + const clearFocusedTorrent = useTorrentStore( + (state) => state.clearFocusedTorrent, + ); + const isFocusedTorrent = focusedTorrentId === id; const forceStatsRefreshCallback = () => { setForceStatsRefresh(forceStatsRefresh + 1); @@ -65,15 +72,38 @@ export const Torrent: React.FC<{ [forceStatsRefresh], ); + useEffect(() => { + if (!isFocusedTorrent) { + return; + } + + rowRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + + const timeout = setTimeout(() => clearFocusedTorrent(id), 3000); + return () => clearTimeout(timeout); + }, [clearFocusedTorrent, id, isFocusedTorrent]); + return ( - +
+ +
); }; diff --git a/crates/librqbit/webui/src/components/TorrentRow.tsx b/crates/librqbit/webui/src/components/TorrentRow.tsx index c7f3e3e..c7815b7 100644 --- a/crates/librqbit/webui/src/components/TorrentRow.tsx +++ b/crates/librqbit/webui/src/components/TorrentRow.tsx @@ -159,6 +159,7 @@ export const TorrentRow: React.FC<{ diff --git a/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx b/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx index cda77d8..749535e 100644 --- a/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx +++ b/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx @@ -5,6 +5,7 @@ import { IconButton } from "./IconButton"; import { DeleteTorrentModal } from "../modal/DeleteTorrentModal"; import { FaCog, + FaFolderOpen, FaPause, FaPlay, FaTrash, @@ -16,24 +17,26 @@ import { ErrorComponent } from "../ErrorComponent"; export const TorrentActions: React.FC<{ id: number; statsResponse: TorrentStats; + outputFolder?: string; extendedView: boolean; setExtendedView: (extendedView: boolean) => void; -}> = ({ id, statsResponse, extendedView, setExtendedView }) => { +}> = ({ id, statsResponse, outputFolder, extendedView, setExtendedView }) => { let state = statsResponse.state; let [disabled, setDisabled] = useState(false); let [deleting, setDeleting] = useState(false); let refreshCtx = useContext(RefreshTorrentStatsContext); + const API = useContext(APIContext); const canPause = state == "live"; const canUnpause = state == "paused" || state == "error"; const canConfigure = state == "paused" || state == "live"; + const canOpenOutputFolder = + statsResponse.finished && !!outputFolder && !!API.openOutputFolder; const setCloseableError = useErrorStore((state) => state.setCloseableError); - const API = useContext(APIContext); - const unpause = () => { setDisabled(true); API.start(id) @@ -116,6 +119,19 @@ export const TorrentActions: React.FC<{ }); }; + const openOutputFolder = () => { + if (!API.openOutputFolder || !outputFolder) { + return; + } + + API.openOutputFolder(outputFolder).catch((e) => { + setCloseableError({ + text: `Error opening output folder for torrent id=${id}`, + details: e, + }); + }); + }; + return (
{canUnpause && ( @@ -136,6 +152,11 @@ export const TorrentActions: React.FC<{ )} + {canOpenOutputFolder && ( + + + + )} diff --git a/crates/librqbit/webui/src/stores/torrentStore.ts b/crates/librqbit/webui/src/stores/torrentStore.ts index 0fd9a71..faf2081 100644 --- a/crates/librqbit/webui/src/stores/torrentStore.ts +++ b/crates/librqbit/webui/src/stores/torrentStore.ts @@ -11,6 +11,10 @@ export interface TorrentStore { refreshTorrents: () => void; setRefreshTorrents: (callback: () => void) => void; + + focusedTorrentId: number | null; + focusTorrent: (id: number) => void; + clearFocusedTorrent: (id: number) => void; } const torrentIdEquals = (t1: TorrentId, t2: TorrentId): boolean => { @@ -51,4 +55,13 @@ export const useTorrentStore = create((set) => ({ }), refreshTorrents: () => {}, setRefreshTorrents: (callback) => set({ refreshTorrents: callback }), + focusedTorrentId: null, + focusTorrent: (id) => set({ focusedTorrentId: id }), + clearFocusedTorrent: (id) => + set((prev) => { + if (prev.focusedTorrentId !== id) { + return {}; + } + return { focusedTorrentId: null }; + }), })); diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index d8bd8ea..9488203 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -4,10 +4,14 @@ mod config; use std::{ + env, fs::{File, OpenOptions}, - io::{BufReader, BufWriter}, - path::Path, + io::{BufReader, BufWriter, Read, Write}, + os::unix::net::{UnixListener, UnixStream}, + path::{Path, PathBuf}, + process::Command, sync::Arc, + thread, }; use anyhow::Context; @@ -26,10 +30,17 @@ use librqbit::{ }; use parking_lot::RwLock; use serde::Serialize; +use tauri::{AppHandle, Emitter}; use tracing::{error, error_span, info, warn}; const ERR_NOT_CONFIGURED: ApiError = ApiError::new_from_text(StatusCode::FAILED_DEPENDENCY, "not configured"); +const TORRENTS_CHANGED_EVENT: &str = "rqbit-desktop-torrents-changed"; + +#[derive(Clone, Serialize)] +struct TorrentsChangedPayload { + id: usize, +} struct StateShared { config: config::RqbitDesktopConfig, @@ -39,6 +50,7 @@ struct StateShared { struct State { config_filename: String, shared: Arc>>, + pending_launch_inputs: Arc>>, init_logging: InitLoggingResult, } @@ -194,6 +206,7 @@ impl State { return Self { config_filename, shared, + pending_launch_inputs: Arc::new(RwLock::new(Vec::new())), init_logging, }; } @@ -202,6 +215,7 @@ impl State { config_filename, init_logging, shared: Arc::new(RwLock::new(None)), + pending_launch_inputs: Arc::new(RwLock::new(Vec::new())), } } @@ -244,6 +258,211 @@ impl State { } } +fn is_torrent_launch_input(input: &str) -> bool { + input.starts_with("magnet:") + || input.starts_with("http://") + || input.starts_with("https://") + || input.starts_with("file://") + || Path::new(input) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("torrent")) +} + +fn collect_launch_inputs() -> Vec { + env::args() + .skip(1) + .filter(|arg| is_torrent_launch_input(arg)) + .collect() +} + +fn desktop_ipc_socket_path() -> PathBuf { + let runtime_dir = env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(env::temp_dir); + let user = env::var("USER").unwrap_or_else(|_| "user".to_string()); + runtime_dir.join(format!("rqbit-desktop-{user}.sock")) +} + +fn forward_inputs_to_existing(socket_path: &Path, inputs: &[String]) -> anyhow::Result<()> { + let mut stream = UnixStream::connect(socket_path)?; + let payload = serde_json::to_vec(inputs)?; + stream.write_all(&payload)?; + Ok(()) +} + +fn bind_ipc_listener(socket_path: &Path) -> std::io::Result { + if UnixStream::connect(socket_path).is_err() { + let _ = std::fs::remove_file(socket_path); + } + + if let Some(parent) = socket_path.parent() { + std::fs::create_dir_all(parent)?; + } + + UnixListener::bind(socket_path) +} + +fn spawn_ipc_listener( + socket_path: PathBuf, + shared: Arc>>, + pending: Arc>>, + app_handle: AppHandle, +) { + let listener = match bind_ipc_listener(&socket_path) { + Ok(listener) => listener, + Err(err) => { + warn!(path=%socket_path.display(), error=%err, "couldn't bind desktop IPC socket"); + return; + } + }; + + let handle = tokio::runtime::Handle::current(); + + thread::spawn(move || { + for stream in listener.incoming() { + let shared = shared.clone(); + let pending = pending.clone(); + let app_handle = app_handle.clone(); + match stream { + Ok(mut stream) => { + let mut payload = String::new(); + if let Err(err) = stream.read_to_string(&mut payload) { + warn!(error=%err, "couldn't read desktop IPC payload"); + continue; + } + + let inputs = match serde_json::from_str::>(&payload) { + Ok(inputs) => inputs, + Err(err) => { + warn!(error=%err, "couldn't parse desktop IPC payload"); + continue; + } + }; + + handle.spawn(async move { + add_launch_inputs(shared, pending, inputs, Some(app_handle)).await; + }); + } + Err(err) => warn!(error=%err, "desktop IPC accept failed"), + } + } + }); +} + +async fn add_launch_inputs( + shared: Arc>>, + pending: Arc>>, + inputs: Vec, + app_handle: Option, +) { + for input in inputs { + match add_launch_input(shared.clone(), pending.clone(), &input).await { + Ok(Some(id)) => { + if let Some(app_handle) = &app_handle { + if let Err(err) = + app_handle.emit(TORRENTS_CHANGED_EVENT, TorrentsChangedPayload { id }) + { + warn!(input=%input, error=%err, "couldn't emit desktop torrent change event"); + } + } + } + Ok(None) => {} + Err(err) => { + warn!(input=%input, error=%err, "couldn't add torrent from desktop launch input"); + } + } + } +} + +async fn add_launch_input( + shared: Arc>>, + pending: Arc>>, + input: &str, +) -> anyhow::Result> { + let api = { + let g = shared.read(); + g.as_ref().and_then(|state| state.api.as_ref()).cloned() + }; + let Some(api) = api else { + pending.write().push(input.to_owned()); + info!(input=%input, "queued desktop launch input until rqbit is configured"); + return Ok(None); + }; + + let opts = AddTorrentOptions { + overwrite: true, + ..Default::default() + }; + + let torrent = if input.starts_with("magnet:") + || input.starts_with("http://") + || input.starts_with("https://") + { + AddTorrent::Url(input.to_string().into()) + } else { + let path = path_from_launch_input(input)?; + let bytes = std::fs::read(&path) + .with_context(|| format!("couldn't read torrent file {}", path.display()))?; + AddTorrent::TorrentFileBytes(bytes.into()) + }; + + api.api_add_torrent(torrent, Some(opts)) + .await + .map(|response| response.id) + .map_err(|err| anyhow::anyhow!("{err:?}")) +} + +async fn drain_pending_launch_inputs( + shared: Arc>>, + pending: Arc>>, + app_handle: Option, +) { + let inputs = { + let mut pending = pending.write(); + if pending.is_empty() { + return; + } + pending.drain(..).collect::>() + }; + + add_launch_inputs(shared, pending, inputs, app_handle).await; +} + +fn path_from_launch_input(input: &str) -> anyhow::Result { + if let Some(uri_path) = input.strip_prefix("file://localhost/") { + return Ok(PathBuf::from(format!("/{}", percent_decode(uri_path)?))); + } + + if let Some(uri_path) = input.strip_prefix("file://") { + return Ok(PathBuf::from(percent_decode(uri_path)?)); + } + + Ok(PathBuf::from(input)) +} + +fn percent_decode(value: &str) -> anyhow::Result { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] == b'%' { + let Some(hex) = bytes.get(index + 1..index + 3) else { + anyhow::bail!("invalid percent-encoded path"); + }; + let hex = std::str::from_utf8(hex).context("invalid percent-encoded path")?; + decoded.push(u8::from_str_radix(hex, 16).context("invalid percent-encoded path")?); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + + String::from_utf8(decoded).context("invalid UTF-8 path") +} + #[derive(Default, Serialize)] struct CurrentState { config: Option, @@ -269,10 +488,18 @@ fn config_current(state: tauri::State<'_, State>) -> CurrentState { #[tauri::command] async fn config_change( + app_handle: AppHandle, state: tauri::State<'_, State>, config: RqbitDesktopConfig, ) -> Result { - state.configure(config).await.map(|_| EmptyJsonResponse {}) + state.configure(config).await?; + drain_pending_launch_inputs( + state.shared.clone(), + state.pending_launch_inputs.clone(), + Some(app_handle), + ) + .await; + Ok(EmptyJsonResponse {}) } #[tauri::command] @@ -374,6 +601,37 @@ async fn stats(state: tauri::State<'_, State>) -> Result Result { + let path = PathBuf::from(path); + let metadata = std::fs::metadata(&path) + .with_context(|| format!("couldn't access output folder {}", path.display())) + .map_err(|e| ApiError::new_from_anyhow(StatusCode::BAD_REQUEST, e))?; + + if !metadata.is_dir() { + return Err(ApiError::new_from_anyhow( + StatusCode::BAD_REQUEST, + anyhow::anyhow!("output path is not a folder: {}", path.display()), + )); + } + + let status = Command::new("xdg-open") + .arg(&path) + .status() + .or_else(|_| Command::new("gio").arg("open").arg(&path).status()) + .with_context(|| format!("couldn't open output folder {}", path.display())) + .map_err(|e| ApiError::new_from_anyhow(StatusCode::INTERNAL_SERVER_ERROR, e))?; + + if !status.success() { + return Err(ApiError::new_from_anyhow( + StatusCode::INTERNAL_SERVER_ERROR, + anyhow::anyhow!("file manager exited with status {status}"), + )); + } + + Ok(EmptyJsonResponse {}) +} + #[tauri::command] fn get_version() -> &'static str { env!("CARGO_PKG_VERSION") @@ -393,11 +651,41 @@ async fn start() { Err(e) => warn!("failed increasing open file limit: {:#}", e), }; + let launch_inputs = collect_launch_inputs(); + let socket_path = desktop_ipc_socket_path(); + + if !launch_inputs.is_empty() && forward_inputs_to_existing(&socket_path, &launch_inputs).is_ok() + { + return; + } + let state = State::new(init_logging_result).await; + let shared = state.shared.clone(); + let pending = state.pending_launch_inputs.clone(); tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(state) + .setup(move |app| { + let app_handle = app.handle().clone(); + spawn_ipc_listener( + socket_path.clone(), + shared.clone(), + pending.clone(), + app_handle.clone(), + ); + + if !launch_inputs.is_empty() { + tauri::async_runtime::spawn(add_launch_inputs( + shared.clone(), + pending.clone(), + launch_inputs.clone(), + Some(app_handle), + )); + } + + Ok(()) + }) .invoke_handler(tauri::generate_handler![ torrents_list, torrent_details, @@ -409,6 +697,7 @@ async fn start() { torrent_action_start, torrent_action_configure, torrent_create_from_base64_file, + open_output_folder, stats, get_version, config_default, diff --git a/desktop/src/api.tsx b/desktop/src/api.tsx index e92a8aa..7f1d97f 100644 --- a/desktop/src/api.tsx +++ b/desktop/src/api.tsx @@ -133,6 +133,9 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => { delete: function (id: number): Promise { return invokeAPI("torrent_action_delete", { id }); }, + openOutputFolder: function (path: string): Promise { + return invokeAPI("open_output_folder", { path }); + }, getTorrentStreamUrl: () => { return ""; }, diff --git a/desktop/src/rqbit-desktop.tsx b/desktop/src/rqbit-desktop.tsx index e22ff35..e823cd0 100644 --- a/desktop/src/rqbit-desktop.tsx +++ b/desktop/src/rqbit-desktop.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; import { RqbitWebUI } from "rqbit-webui/src/rqbit-web"; import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration"; import { ConfigModal } from "./configure"; @@ -6,6 +7,13 @@ import { IconButton } from "rqbit-webui/src/components/buttons/IconButton"; import { BsSliders2 } from "react-icons/bs"; import { APIContext } from "rqbit-webui/src/context"; import { makeAPI } from "./api"; +import { useTorrentStore } from "rqbit-webui/src/stores/torrentStore"; + +const TORRENTS_CHANGED_EVENT = "rqbit-desktop-torrents-changed"; + +type TorrentsChangedPayload = { + id?: number; +}; export const RqbitDesktop: React.FC<{ version: string; @@ -18,6 +26,31 @@ export const RqbitDesktop: React.FC<{ ); let [configurationOpened, setConfigurationOpened] = useState(false); + useEffect(() => { + if (!configured) { + return; + } + + let cancelled = false; + const unlisten = listen(TORRENTS_CHANGED_EVENT, (event) => { + if (event.payload.id != null) { + useTorrentStore.getState().focusTorrent(event.payload.id); + } + + setTimeout(() => { + if (cancelled) { + return; + } + useTorrentStore.getState().refreshTorrents(); + }, 250); + }); + + return () => { + cancelled = true; + unlisten.then((cleanup) => cleanup()); + }; + }, [configured]); + const configButton = ( { From 5d68aaf35220fecde955a363b420a2691519ce05 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Fri, 7 Aug 2026 17:29:33 +0200 Subject: [PATCH 3/6] feat(desktop): supprimer tous les torrents en une action Ajoute api_torrents_delete_all sur l'Api librqbit : itere sur tous les torrents de la session, les supprime avec ou sans les donnees selon remove_files, collecte les erreurs sans interrompre la boucle et ne renvoie une 500 qu'a la fin si au moins une suppression a echoue. Cote desktop, la commande Tauri torrents_delete_all expose l'appel, et la WebUI ajoute un bouton corbeille dans RootContent avec une modale de confirmation DeleteAllTorrentsModal. Limite connue : aucune route HTTP /torrents/delete_all n'est enregistree dans http_api/handlers. Le chemin Tauri fonctionne (desktop/src/api.tsx passe par invokeAPI), mais la WebUI servie en mode navigateur appellera POST /torrents/delete_all et recevra un 404. La route reste a cabler pour que le bouton fonctionne hors application desktop. Ce correctif tournait depuis des semaines uniquement dans l'arbre de travail, non versionne, alors qu'il etait deja compile dans les paquets rqbit et rqbit-desktop installes. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 60 +++++++++++++ crates/librqbit/src/api.rs | 42 +++++++++ crates/librqbit/webui/src/api-types.ts | 1 + .../webui/src/components/RootContent.tsx | 16 ++++ .../modal/DeleteAllTorrentsModal.tsx | 89 +++++++++++++++++++ crates/librqbit/webui/src/context.tsx | 3 + crates/librqbit/webui/src/http-api.ts | 7 ++ desktop/src-tauri/src/main.rs | 12 +++ desktop/src/api.tsx | 3 + 9 files changed, 233 insertions(+) create mode 100644 crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx diff --git a/Cargo.lock b/Cargo.lock index 226024a..65aa04d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7409,3 +7409,63 @@ dependencies = [ "quote", "syn 2.0.101", ] + +[[patch.unused]] +name = "dpi" +version = "0.1.2" + +[[patch.unused]] +name = "winit" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-android" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-appkit" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-common" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-core" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-orbital" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-uikit" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-wayland" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-web" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-win32" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-x11" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "softbuffer" +version = "0.4.1" + +[[patch.unused]] +name = "cosmic-freedesktop-icons" +version = "0.4.0" + +[[patch.unused]] +name = "smithay-clipboard" +version = "0.8.0" diff --git a/crates/librqbit/src/api.rs b/crates/librqbit/src/api.rs index cef6b72..1ef76d2 100644 --- a/crates/librqbit/src/api.rs +++ b/crates/librqbit/src/api.rs @@ -325,6 +325,48 @@ impl Api { Ok(Default::default()) } + /// Delete all torrents, optionally keeping the downloaded data. + pub async fn api_torrents_delete_all( + &self, + remove_files: bool, + ) -> Result { + let ids: Vec = { + let g = self.session.with_torrents(|torrents| { + torrents.map(|(id, _)| id).collect() + }); + g + }; + + if ids.is_empty() { + return Ok(Default::default()); + } + + // Collect errors but continue deleting all torrents + let mut errors: Vec = Vec::new(); + for id in ids { + if let Err(e) = self + .session + .delete(TorrentIdOrHash::Id(id), remove_files) + .await + { + warn!(id, error=?e, "error deleting torrent"); + errors.push(format!("Torrent {}: {:?}", id, e)); + } + } + + if !errors.is_empty() { + return Err(ApiError::new_from_anyhow( + StatusCode::INTERNAL_SERVER_ERROR, + anyhow::anyhow!( + "Failed to delete some torrents:\n{}", + errors.join("\n") + ), + )); + } + + Ok(Default::default()) + } + pub async fn api_torrent_action_update_only_files( &self, idx: TorrentIdOrHash, diff --git a/crates/librqbit/webui/src/api-types.ts b/crates/librqbit/webui/src/api-types.ts index 6580d3e..e7ee454 100644 --- a/crates/librqbit/webui/src/api-types.ts +++ b/crates/librqbit/webui/src/api-types.ts @@ -202,6 +202,7 @@ export interface RqbitAPI { start: (index: number) => Promise; forget: (index: number) => Promise; delete: (index: number) => Promise; + deleteAll: (removeFiles?: boolean) => Promise; openOutputFolder?: (path: string) => Promise; stats: () => Promise; } diff --git a/crates/librqbit/webui/src/components/RootContent.tsx b/crates/librqbit/webui/src/components/RootContent.tsx index f1387e4..f232916 100644 --- a/crates/librqbit/webui/src/components/RootContent.tsx +++ b/crates/librqbit/webui/src/components/RootContent.tsx @@ -1,7 +1,11 @@ +import { useState } from "react"; import { TorrentsList } from "./TorrentsList"; import { ErrorComponent } from "./ErrorComponent"; import { useTorrentStore } from "../stores/torrentStore"; import { useErrorStore } from "../stores/errorStore"; +import { IconButton } from "./buttons/IconButton"; +import { FiTrash2 } from "react-icons/fi"; +import { DeleteAllTorrentsModal } from "./modal/DeleteAllTorrentsModal"; export const RootContent = (props: {}) => { let closeableError = useErrorStore((state) => state.closeableError); @@ -11,6 +15,7 @@ export const RootContent = (props: {}) => { let torrentsInitiallyLoading = useTorrentStore( (state) => state.torrentsInitiallyLoading ); + const [deleteAllOpened, setDeleteAllOpened] = useState(false); return (
@@ -19,7 +24,18 @@ export const RootContent = (props: {}) => { remove={() => setCloseableError(null)} /> + {torrents && torrents.length > 0 && ( +
+ setDeleteAllOpened(true)}> + + +
+ )} + setDeleteAllOpened(false)} + />
); }; diff --git a/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx b/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx new file mode 100644 index 0000000..824a352 --- /dev/null +++ b/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx @@ -0,0 +1,89 @@ +import { useContext, useState } from "react"; +import { APIContext } from "../../context"; +import { ErrorWithLabel } from "../../rqbit-web"; +import { ErrorComponent } from "../ErrorComponent"; +import { Spinner } from "../Spinner"; +import { Modal } from "./Modal"; +import { ModalBody } from "./ModalBody"; +import { ModalFooter } from "./ModalFooter"; +import { Button } from "../buttons/Button"; +import { useTorrentStore } from "../../stores/torrentStore"; + +export const DeleteAllTorrentsModal: React.FC<{ + show: boolean; + onHide: () => void; +}> = ({ show, onHide }) => { + if (!show) { + return null; + } + const [deleteFiles, setDeleteFiles] = useState(false); + const [error, setError] = useState(null); + const [deleting, setDeleting] = useState(false); + + const API = useContext(APIContext); + const refreshTorrents = useTorrentStore((state) => state.refreshTorrents); + + const close = () => { + setDeleteFiles(false); + setError(null); + setDeleting(false); + onHide(); + }; + + const deleteAllTorrents = () => { + setDeleting(true); + + API.deleteAll(deleteFiles) + .then(() => { + refreshTorrents(); + close(); + }) + .catch((e) => { + setError({ + text: "Error deleting all torrents", + details: e, + }); + setDeleting(false); + }); + }; + + return ( + + +

+ Are you sure you want to delete all torrents? This + action cannot be undone. +

+ +
+ setDeleteFiles(!deleteFiles)} + checked={deleteFiles} + placeholder="Also delete files" + /> + +
+ + {error && } +
+ + + {deleting && } + + + +
+ ); +}; diff --git a/crates/librqbit/webui/src/context.tsx b/crates/librqbit/webui/src/context.tsx index 7bf10b8..ab7646a 100644 --- a/crates/librqbit/webui/src/context.tsx +++ b/crates/librqbit/webui/src/context.tsx @@ -29,6 +29,9 @@ export const APIContext = createContext({ delete: () => { throw new Error("Function not implemented."); }, + deleteAll: () => { + throw new Error("Function not implemented."); + }, getTorrentStreamUrl: () => { throw new Error("Function not implemented."); }, diff --git a/crates/librqbit/webui/src/http-api.ts b/crates/librqbit/webui/src/http-api.ts index 2e00df3..9094c1d 100644 --- a/crates/librqbit/webui/src/http-api.ts +++ b/crates/librqbit/webui/src/http-api.ts @@ -151,6 +151,13 @@ export const API: RqbitAPI & { getVersion: () => Promise } = { delete: (index: number): Promise => { return makeRequest("POST", `/torrents/${index}/delete`); }, + deleteAll: (removeFiles: boolean = false): Promise => { + let url = "/torrents/delete_all"; + if (removeFiles) { + url += "?remove_files=true"; + } + return makeRequest("POST", url); + }, getVersion: async (): Promise => { const r = await makeRequest("GET", "/"); return r.version; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 9488203..4380ebc 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -560,6 +560,17 @@ async fn torrent_action_delete( state.api()?.api_torrent_action_delete(id).await } +#[tauri::command] +async fn torrents_delete_all( + state: tauri::State<'_, State>, + remove_files: bool, +) -> Result { + state + .api()? + .api_torrents_delete_all(remove_files) + .await +} + #[tauri::command] async fn torrent_action_pause( state: tauri::State<'_, State>, @@ -692,6 +703,7 @@ async fn start() { torrent_stats, torrent_create_from_url, torrent_action_delete, + torrents_delete_all, torrent_action_pause, torrent_action_forget, torrent_action_start, diff --git a/desktop/src/api.tsx b/desktop/src/api.tsx index 7f1d97f..ae2a468 100644 --- a/desktop/src/api.tsx +++ b/desktop/src/api.tsx @@ -133,6 +133,9 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => { delete: function (id: number): Promise { return invokeAPI("torrent_action_delete", { id }); }, + deleteAll: function (removeFiles: boolean = false): Promise { + return invokeAPI("torrents_delete_all", { removeFiles }); + }, openOutputFolder: function (path: string): Promise { return invokeAPI("open_output_folder", { path }); }, From fbd052bc0474097c43df0554d59148e93b936f13 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Fri, 7 Aug 2026 19:25:48 +0200 Subject: [PATCH 4/6] feat(http-api): cabler la route POST /torrents/delete_all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le commit precedent ajoutait api_torrents_delete_all et la commande Tauri, mais aucune route HTTP ne l'exposait : la WebUI servie en mode navigateur appelait POST /torrents/delete_all et recevait un 404. Seul le chemin desktop, qui passe par invokeAPI, fonctionnait. Ajoute h_torrents_delete_all, avec remove_files lu en query param et defaut a false, puis enregistre la route dans le bloc !read_only aux cotes de /torrents/{id}/delete. Segment statique, donc prioritaire sur /torrents/{id} — meme schema que /torrents/resolve_magnet. Co-Authored-By: Claude Opus 5 (1M context) --- crates/librqbit/src/http_api/handlers/mod.rs | 3 +++ .../src/http_api/handlers/torrents.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/librqbit/src/http_api/handlers/mod.rs b/crates/librqbit/src/http_api/handlers/mod.rs index ff3b43d..a87d725 100644 --- a/crates/librqbit/src/http_api/handlers/mod.rs +++ b/crates/librqbit/src/http_api/handlers/mod.rs @@ -112,6 +112,9 @@ pub fn make_api_router(state: ApiState) -> Router { "/torrents/{id}/delete", post(torrents::h_torrent_action_delete), ) + // Segment statique, donc prioritaire sur /torrents/{id} — meme + // schema que /torrents/resolve_magnet juste au-dessus. + .route("/torrents/delete_all", post(torrents::h_torrents_delete_all)) .route( "/torrents/{id}/update_only_files", post(torrents::h_torrent_action_update_only_files), diff --git a/crates/librqbit/src/http_api/handlers/torrents.rs b/crates/librqbit/src/http_api/handlers/torrents.rs index 75ffb09..6e3c2f5 100644 --- a/crates/librqbit/src/http_api/handlers/torrents.rs +++ b/crates/librqbit/src/http_api/handlers/torrents.rs @@ -146,6 +146,25 @@ pub async fn h_torrent_action_delete( .map(axum::Json) } +#[derive(Deserialize, Default)] +pub struct DeleteAllQueryParams { + /// Supprime aussi les donnees telechargees. Absent = false, on ne garde + /// que la suppression des torrents. + #[serde(default)] + pub remove_files: bool, +} + +pub async fn h_torrents_delete_all( + State(state): State, + Query(params): Query, +) -> Result { + state + .api + .api_torrents_delete_all(params.remove_files) + .await + .map(axum::Json) +} + #[derive(Deserialize)] pub struct UpdateOnlyFilesRequest { only_files: Vec, From 6ea975935f9b81535d27ca1848027459e1bf2a67 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Fri, 7 Aug 2026 19:32:49 +0200 Subject: [PATCH 5/6] perf(rqbit): mimalloc comme allocateur global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare mimalloc en #[global_allocator] sur le binaire rqbit. L'alloca- teur systeme se comporte mal sur la charge de rqbit, faite de tres nombreuses petites allocations concurrentes sur les buffers de pieces. default-features = false pour ne pas embarquer les variantes secure et les stats de debug, inutiles ici. Ce patch tournait deja dans les paquets installes sans exister dans git, comme la feature « delete all ». Repere par le check hygiene. Co-Authored-By: Claude Opus 5 (1M context) --- crates/rqbit/Cargo.toml | 1 + crates/rqbit/src/main.rs | 3 +++ 2 files changed, 4 insertions(+) diff --git a/crates/rqbit/Cargo.toml b/crates/rqbit/Cargo.toml index 6e651da..4e49a7d 100644 --- a/crates/rqbit/Cargo.toml +++ b/crates/rqbit/Cargo.toml @@ -24,6 +24,7 @@ postgres = ["librqbit/postgres"] disable-upload = ["librqbit/disable-upload"] [dependencies] +mimalloc = { version = "0.1", default-features = false } librqbit = { version = "8.1.1", path = "../librqbit", default-features = false, features = [ "http-api", "http-api-client", diff --git a/crates/rqbit/src/main.rs b/crates/rqbit/src/main.rs index dde6707..d41211a 100644 --- a/crates/rqbit/src/main.rs +++ b/crates/rqbit/src/main.rs @@ -922,3 +922,6 @@ mod tests { } } } + +#[global_allocator] +static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc; From f703bb15b785a76b1c5a96bacd5312f6be618969 Mon Sep 17 00:00:00 2001 From: Lionel DARNIS Date: Fri, 4 Sep 2026 10:48:53 +0200 Subject: [PATCH 6/6] chore: fige Cargo.lock (mimalloc) --- Cargo.lock | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 65aa04d..32cbb3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2781,6 +2781,15 @@ dependencies = [ "windows-targets 0.53.0", ] +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + [[package]] name = "libredox" version = "0.1.3" @@ -3177,6 +3186,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "mime" version = "0.3.17" @@ -4618,6 +4636,7 @@ dependencies = [ "libc", "librqbit", "librqbit-upnp-serve", + "mimalloc", "openssl", "parking_lot", "parse_duration",