Compare commits

...

5 commits

Author SHA1 Message Date
f703bb15b7 chore: fige Cargo.lock (mimalloc) 2026-09-04 10:48:53 +02:00
6ea975935f perf(rqbit): mimalloc comme allocateur global
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) <noreply@anthropic.com>
2026-08-07 19:32:49 +02:00
fbd052bc04 feat(http-api): cabler la route POST /torrents/delete_all
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) <noreply@anthropic.com>
2026-08-07 19:25:48 +02:00
5d68aaf352 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) <noreply@anthropic.com>
2026-08-07 17:29:33 +02:00
99219f90bc 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
2026-07-09 14:18:06 +02:00
18 changed files with 683 additions and 13 deletions

79
Cargo.lock generated
View file

@ -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",
@ -7409,3 +7428,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"

View file

@ -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<EmptyJsonResponse> {
let ids: Vec<usize> = {
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<String> = 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,

View file

@ -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),

View file

@ -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<ApiState>,
Query(params): Query<DeleteAllQueryParams>,
) -> Result<impl IntoResponse> {
state
.api
.api_torrents_delete_all(params.remove_files)
.await
.map(axum::Json)
}
#[derive(Deserialize)]
pub struct UpdateOnlyFilesRequest {
only_files: Vec<usize>,

View file

@ -23,6 +23,7 @@ export interface TorrentFileAttributes {
export interface TorrentDetails {
name: string | null;
info_hash: string;
output_folder?: string;
files: Array<TorrentFile>;
}
@ -201,5 +202,7 @@ export interface RqbitAPI {
start: (index: number) => Promise<void>;
forget: (index: number) => Promise<void>;
delete: (index: number) => Promise<void>;
deleteAll: (removeFiles?: boolean) => Promise<void>;
openOutputFolder?: (path: string) => Promise<void>;
stats: () => Promise<SessionStats>;
}

View file

@ -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 (
<div className="container mx-auto">
@ -19,7 +24,18 @@ export const RootContent = (props: {}) => {
remove={() => setCloseableError(null)}
/>
<ErrorComponent error={otherError} />
{torrents && torrents.length > 0 && (
<div className="flex justify-end px-4 py-2">
<IconButton onClick={() => setDeleteAllOpened(true)}>
<FiTrash2 className="hover:text-red-500" />
</IconButton>
</div>
)}
<TorrentsList torrents={torrents} loading={torrentsInitiallyLoading} />
<DeleteAllTorrentsModal
show={deleteAllOpened}
onHide={() => setDeleteAllOpened(false)}
/>
</div>
);
};

View file

@ -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<HTMLDivElement>(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 (
<RefreshTorrentStatsContext.Provider
value={{ refresh: forceStatsRefreshCallback }}
>
<TorrentRow
id={id}
detailsResponse={detailsResponse}
statsResponse={statsResponse}
/>
<div
ref={rowRef}
className={
isFocusedTorrent
? "rounded-xl ring-2 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-offset-gray-900"
: undefined
}
>
<TorrentRow
id={id}
detailsResponse={detailsResponse}
statsResponse={statsResponse}
/>
</div>
</RefreshTorrentStatsContext.Provider>
);
};

View file

@ -159,6 +159,7 @@ export const TorrentRow: React.FC<{
<TorrentActions
id={id}
statsResponse={statsResponse}
outputFolder={detailsResponse?.output_folder}
extendedView={extendedView}
setExtendedView={setExtendedView}
/>

View file

@ -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<boolean>(false);
let [deleting, setDeleting] = useState<boolean>(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 (
<div className="flex w-full justify-center gap-2 dark:text-slate-300">
{canUnpause && (
@ -136,6 +152,11 @@ export const TorrentActions: React.FC<{
<FaCog className="hover:text-green-600" />
</IconButton>
)}
{canOpenOutputFolder && (
<IconButton onClick={openOutputFolder} disabled={disabled}>
<FaFolderOpen className="hover:text-green-600" />
</IconButton>
)}
<IconButton onClick={startDeleting} disabled={disabled}>
<FaTrash className="hover:text-red-500" />
</IconButton>

View file

@ -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<ErrorWithLabel | null>(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 (
<Modal isOpen={show} onClose={onHide} title="Delete all torrents">
<ModalBody>
<p className="text-gray-700 dark:text-slate-300">
Are you sure you want to delete <strong>all</strong> torrents? This
action cannot be undone.
</p>
<div className="mt-4 flex items-center">
<input
type="checkbox"
id="deleteFilesAll"
className="form-checkbox h-4 w-4 text-blue-500"
onChange={() => setDeleteFiles(!deleteFiles)}
checked={deleteFiles}
placeholder="Also delete files"
/>
<label
htmlFor="deleteFilesAll"
className="ml-2 text-gray-700 dark:text-slate-300"
>
Also delete downloaded files
</label>
</div>
{error && <ErrorComponent error={error} />}
</ModalBody>
<ModalFooter>
{deleting && <Spinner />}
<Button variant="cancel" onClick={close}>
Cancel
</Button>
<Button variant="danger" onClick={deleteAllTorrents} disabled={deleting}>
Delete All
</Button>
</ModalFooter>
</Modal>
);
};

View file

@ -29,6 +29,9 @@ export const APIContext = createContext<RqbitAPI>({
delete: () => {
throw new Error("Function not implemented.");
},
deleteAll: () => {
throw new Error("Function not implemented.");
},
getTorrentStreamUrl: () => {
throw new Error("Function not implemented.");
},

View file

@ -151,6 +151,13 @@ export const API: RqbitAPI & { getVersion: () => Promise<string> } = {
delete: (index: number): Promise<void> => {
return makeRequest("POST", `/torrents/${index}/delete`);
},
deleteAll: (removeFiles: boolean = false): Promise<void> => {
let url = "/torrents/delete_all";
if (removeFiles) {
url += "?remove_files=true";
}
return makeRequest("POST", url);
},
getVersion: async (): Promise<string> => {
const r = await makeRequest("GET", "/");
return r.version;

View file

@ -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<TorrentStore>((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 };
}),
}));

View file

@ -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",

View file

@ -922,3 +922,6 @@ mod tests {
}
}
}
#[global_allocator]
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;

View file

@ -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<RwLock<Option<StateShared>>>,
pending_launch_inputs: Arc<RwLock<Vec<String>>>,
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<String> {
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<UnixListener> {
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<RwLock<Option<StateShared>>>,
pending: Arc<RwLock<Vec<String>>>,
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::<Vec<String>>(&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<RwLock<Option<StateShared>>>,
pending: Arc<RwLock<Vec<String>>>,
inputs: Vec<String>,
app_handle: Option<AppHandle>,
) {
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<RwLock<Option<StateShared>>>,
pending: Arc<RwLock<Vec<String>>>,
input: &str,
) -> anyhow::Result<Option<usize>> {
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<RwLock<Option<StateShared>>>,
pending: Arc<RwLock<Vec<String>>>,
app_handle: Option<AppHandle>,
) {
let inputs = {
let mut pending = pending.write();
if pending.is_empty() {
return;
}
pending.drain(..).collect::<Vec<_>>()
};
add_launch_inputs(shared, pending, inputs, app_handle).await;
}
fn path_from_launch_input(input: &str) -> anyhow::Result<PathBuf> {
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<String> {
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<RqbitDesktopConfig>,
@ -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<EmptyJsonResponse, ApiError> {
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]
@ -333,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<EmptyJsonResponse, ApiError> {
state
.api()?
.api_torrents_delete_all(remove_files)
.await
}
#[tauri::command]
async fn torrent_action_pause(
state: tauri::State<'_, State>,
@ -374,6 +612,37 @@ async fn stats(state: tauri::State<'_, State>) -> Result<SessionStatsSnapshot, A
Ok(state.api()?.api_session_stats())
}
#[tauri::command]
async fn open_output_folder(path: String) -> Result<EmptyJsonResponse, ApiError> {
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,22 +662,54 @@ 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,
torrent_stats,
torrent_create_from_url,
torrent_action_delete,
torrents_delete_all,
torrent_action_pause,
torrent_action_forget,
torrent_action_start,
torrent_action_configure,
torrent_create_from_base64_file,
open_output_folder,
stats,
get_version,
config_default,

View file

@ -133,6 +133,12 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => {
delete: function (id: number): Promise<void> {
return invokeAPI<void>("torrent_action_delete", { id });
},
deleteAll: function (removeFiles: boolean = false): Promise<void> {
return invokeAPI<void>("torrents_delete_all", { removeFiles });
},
openOutputFolder: function (path: string): Promise<void> {
return invokeAPI<void>("open_output_folder", { path });
},
getTorrentStreamUrl: () => {
return "";
},

View file

@ -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<boolean>(false);
useEffect(() => {
if (!configured) {
return;
}
let cancelled = false;
const unlisten = listen<TorrentsChangedPayload>(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 = (
<IconButton
onClick={() => {