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
22 changed files with 678 additions and 707 deletions

80
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",
@ -4641,7 +4660,6 @@ dependencies = [
"base64 0.22.1",
"directories 5.0.1",
"gethostname 0.5.0",
"gtk",
"http",
"librqbit",
"parking_lot",
@ -7410,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,30 +23,10 @@ export interface TorrentFileAttributes {
export interface TorrentDetails {
name: string | null;
info_hash: string;
output_folder: string;
output_folder?: string;
files: Array<TorrentFile>;
}
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;
@ -213,15 +193,16 @@ export interface RqbitAPI {
filename?: string | null,
) => string | null;
uploadTorrent: (
data: TorrentInput,
data: string | File,
opts?: AddTorrentOptions,
) => Promise<AddTorrentResponse>;
openTorrentOutput?: (index: number) => Promise<void>;
pause: (index: number) => Promise<void>;
updateOnlyFiles: (index: number, files: number[]) => Promise<void>;
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

@ -93,82 +93,9 @@ 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 (
<div
className="flex flex-col border p-2 border-gray-200 rounded-xl shadow-xs hover:drop-shadow-sm dark:bg-slate-800 dark:border-slate-900"
onContextMenu={handleContextMenu}
>
{contextMenu && (
<div
className="fixed z-50 min-w-52 overflow-hidden rounded-md border border-gray-200 bg-white py-1 text-sm shadow-lg dark:border-slate-700 dark:bg-slate-800"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(event) => event.stopPropagation()}
>
<button
className="block w-full px-3 py-2 text-left text-gray-800 hover:bg-gray-100 dark:text-slate-100 dark:hover:bg-slate-700"
onClick={openOutput}
>
Open download folder
</button>
<button
className="block w-full px-3 py-2 text-left text-gray-800 hover:bg-gray-100 dark:text-slate-100 dark:hover:bg-slate-700"
onClick={() => {
setExtendedView(true);
setContextMenu(null);
}}
>
Show files
</button>
</div>
)}
<div className="flex flex-col border p-2 border-gray-200 rounded-xl shadow-xs hover:drop-shadow-sm dark:bg-slate-800 dark:border-slate-900">
<section className="flex flex-col lg:flex-row items-center gap-2">
{/* Icon */}
<div className="hidden md:block">{statusIcon("w-10 h-10")}</div>
@ -232,6 +159,7 @@ export const TorrentRow: React.FC<{
<TorrentActions
id={id}
statsResponse={statsResponse}
outputFolder={detailsResponse?.output_folder}
extendedView={extendedView}
setExtendedView={setExtendedView}
/>

View file

@ -5,11 +5,11 @@ import { IconButton } from "./IconButton";
import { DeleteTorrentModal } from "../modal/DeleteTorrentModal";
import {
FaCog,
FaFolderOpen,
FaPause,
FaPlay,
FaTrash,
FaClipboardList,
FaFolderOpen,
} from "react-icons/fa";
import { useErrorStore } from "../../stores/errorStore";
import { ErrorComponent } from "../ErrorComponent";
@ -17,40 +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 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)
@ -133,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 && (
@ -153,9 +152,9 @@ export const TorrentActions: React.FC<{
<FaCog className="hover:text-green-600" />
</IconButton>
)}
{API.openTorrentOutput && (
<IconButton onClick={openOutput} disabled={disabled}>
<FaFolderOpen className="hover:text-blue-500" />
{canOpenOutputFolder && (
<IconButton onClick={openOutputFolder} disabled={disabled}>
<FaFolderOpen className="hover:text-green-600" />
</IconButton>
)}
<IconButton onClick={startDeleting} disabled={disabled}>

View file

@ -2,7 +2,6 @@ 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";
@ -11,7 +10,7 @@ import { Button } from "./Button";
export const UploadButton: React.FC<{
onClick: () => void;
data: TorrentInput | null;
data: string | File | null;
resetData: () => void;
children: ReactNode;
className?: string;

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

@ -1,9 +1,5 @@
import { useContext, useEffect, useState } from "react";
import {
AddTorrentResponse,
AddTorrentOptions,
TorrentInput,
} from "../../api-types";
import { AddTorrentResponse, AddTorrentOptions } from "../../api-types";
import { APIContext } from "../../context";
import { ErrorComponent } from "../ErrorComponent";
import { ErrorWithLabel } from "../../rqbit-web";
@ -23,7 +19,7 @@ export const FileSelectionModal = (props: {
listTorrentResponse: AddTorrentResponse | null;
listTorrentError: ErrorWithLabel | null;
listTorrentLoading: boolean;
data: TorrentInput;
data: string | File;
}) => {
let {
onHide,

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

@ -6,7 +6,6 @@ import {
SessionStats,
TorrentDetails,
TorrentStats,
isLocalTorrentFile,
} from "./api-types";
// Define API URL and base path
@ -100,12 +99,6 @@ export const API: RqbitAPI & { getVersion: () => Promise<string> } = {
},
uploadTorrent: (data, opts): Promise<AddTorrentResponse> => {
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";
@ -158,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

@ -33,7 +33,6 @@ 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

View file

@ -4,17 +4,16 @@
mod config;
use std::{
ffi::OsString,
env,
fs::{File, OpenOptions},
io::{BufReader, BufWriter, Read, Write},
os::unix::net::{UnixListener, UnixStream},
path::{Path, PathBuf},
process::Command,
sync::Arc,
thread,
};
#[cfg(unix)]
use std::os::unix::net::{UnixListener, UnixStream};
use anyhow::Context;
use config::RqbitDesktopConfig;
use http::StatusCode;
@ -29,13 +28,19 @@ use librqbit::{
AddTorrent, AddTorrentOptions, Api, ApiError, PeerConnectionOptions, Session, SessionOptions,
SessionPersistenceConfig,
};
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
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,
@ -45,302 +50,10 @@ struct StateShared {
struct State {
config_filename: String,
shared: Arc<RwLock<Option<StateShared>>>,
pending_torrent_inputs: Mutex<Vec<PendingTorrentInput>>,
pending_launch_inputs: Arc<RwLock<Vec<String>>>,
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<PendingTorrentInput>,
}
fn pending_torrent_input_from_arg(arg: OsString) -> Option<PendingTorrentInput> {
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<PathBuf> {
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<String> {
fn hex_value(value: u8) -> Option<u8> {
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<PendingTorrentInput> {
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<UnixListener> {
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>();
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: &gtk::Widget) -> Option<gtk::HeaderBar> {
use gtk::prelude::*;
if let Ok(header) = widget.clone().downcast::<gtk::HeaderBar>() {
return Some(header);
}
let Ok(container) = widget.clone().downcast::<gtk::Container>() 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: &gtk::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<RqbitDesktopConfig> {
let rdr = BufReader::new(File::open(path)?);
let mut config: RqbitDesktopConfig = serde_json::from_reader(rdr)?;
@ -471,11 +184,7 @@ async fn api_from_config(
}
impl State {
async fn new(
init_logging: InitLoggingResult,
pending_torrent_inputs: Vec<PendingTorrentInput>,
) -> Self {
let pending_torrent_inputs = Mutex::new(pending_torrent_inputs);
async fn new(init_logging: InitLoggingResult) -> Self {
let config_filename = directories::ProjectDirs::from("com", "rqbit", "desktop")
.expect("directories::ProjectDirs::from")
.config_dir()
@ -497,7 +206,7 @@ impl State {
return Self {
config_filename,
shared,
pending_torrent_inputs,
pending_launch_inputs: Arc::new(RwLock::new(Vec::new())),
init_logging,
};
}
@ -506,7 +215,7 @@ impl State {
config_filename,
init_logging,
shared: Arc::new(RwLock::new(None)),
pending_torrent_inputs,
pending_launch_inputs: Arc::new(RwLock::new(Vec::new())),
}
}
@ -549,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>,
@ -574,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]
@ -614,21 +536,6 @@ 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<AddTorrentOptions>,
) -> Result<ApiAddTorrentResponse, ApiError> {
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>,
@ -653,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>,
@ -695,67 +613,34 @@ async fn stats(state: tauri::State<'_, State>) -> Result<SessionStatsSnapshot, A
}
#[tauri::command]
fn take_startup_torrent_inputs(state: tauri::State<'_, State>) -> Vec<PendingTorrentInput> {
let mut inputs = state.pending_torrent_inputs.lock();
std::mem::take(&mut *inputs)
}
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))?;
#[tauri::command]
async fn torrent_open_output(
state: tauri::State<'_, State>,
id: TorrentIdOrHash,
) -> Result<EmptyJsonResponse, ApiError> {
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;
}
if !metadata.is_dir() {
return Err(ApiError::new_from_anyhow(
StatusCode::BAD_REQUEST,
anyhow::anyhow!("output path is not a folder: {}", path.display()),
));
}
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)
}
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))?;
#[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)
}
if !status.success() {
return Err(ApiError::new_from_anyhow(
StatusCode::INTERNAL_SERVER_ERROR,
anyhow::anyhow!("file manager exited with status {status}"),
));
}
#[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(())
Ok(EmptyJsonResponse {})
}
#[tauri::command]
@ -777,28 +662,39 @@ async fn start() {
Err(e) => warn!("failed increasing open file limit: {:#}", e),
};
let pending_torrent_inputs = pending_torrent_inputs_from_args();
let launch_inputs = collect_launch_inputs();
let socket_path = desktop_ipc_socket_path();
#[cfg(unix)]
let single_instance_listener = match acquire_single_instance(&pending_torrent_inputs) {
SingleInstance::Primary(listener) => Some(listener),
SingleInstance::Secondary => return,
SingleInstance::Disabled => None,
};
if !launch_inputs.is_empty() && forward_inputs_to_existing(&socket_path, &launch_inputs).is_ok()
{
return;
}
let state = State::new(init_logging_result, pending_torrent_inputs).await;
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| {
#[cfg(target_os = "linux")]
apply_gtk_window_controls_layout(app);
let app_handle = app.handle().clone();
spawn_ipc_listener(
socket_path.clone(),
shared.clone(),
pending.clone(),
app_handle.clone(),
);
#[cfg(unix)]
if let Some(listener) = single_instance_listener {
start_single_instance_listener(listener, 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![
@ -807,14 +703,13 @@ async fn start() {
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,
torrent_create_from_file_path,
take_startup_torrent_inputs,
torrent_open_output,
open_output_folder,
stats,
get_version,
config_default,

View file

@ -1,144 +0,0 @@
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<TorrentInput[]>([]);
const [activeInput, setActiveInput] = useState<TorrentInput | null>(null);
const [listTorrentResponse, setListTorrentResponse] =
useState<AddTorrentResponse | null>(null);
const [listTorrentError, setListTorrentError] =
useState<ErrorWithLabel | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!enabled) {
return;
}
let unlisten: (() => void) | null = null;
let cancelled = false;
const drainPendingInputs = () => {
invoke<PendingTorrentInput[]>("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 (
<FileSelectionModal
onHide={clearActive}
listTorrentError={listTorrentError}
listTorrentResponse={listTorrentResponse}
data={activeInput}
listTorrentLoading={loading}
/>
);
};

View file

@ -7,7 +7,6 @@ import {
TorrentStats,
ErrorDetails,
SessionStats,
isLocalTorrentFile,
} from "rqbit-webui/src/api-types";
import { InvokeArgs, invoke } from "@tauri-apps/api/core";
@ -111,23 +110,11 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => {
}
);
}
if (isLocalTorrentFile(data)) {
return await invokeAPI<AddTorrentResponse>(
"torrent_create_from_file_path",
{
path: data.path,
opts: opts ?? {},
}
);
}
return await invokeAPI<AddTorrentResponse>("torrent_create_from_url", {
url: data,
opts: opts ?? {},
});
},
openTorrentOutput: function (id: number): Promise<void> {
return invokeAPI<void>("torrent_open_output", { id });
},
updateOnlyFiles: function (id, files): Promise<void> {
return invokeAPI<void>("torrent_action_configure", {
id: id,
@ -146,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 { useMemo, 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,7 +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 { StartupTorrentInputs } from "./StartupTorrentInputs";
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,7 +25,31 @@ export const RqbitDesktop: React.FC<{
currentState.config ?? defaultConfig,
);
let [configurationOpened, setConfigurationOpened] = useState<boolean>(false);
const api = useMemo(() => makeAPI(config), [config]);
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
@ -31,16 +62,13 @@ export const RqbitDesktop: React.FC<{
);
return (
<APIContext.Provider value={api}>
<APIContext.Provider value={makeAPI(config)}>
{configured && (
<>
<RqbitWebUI
title={`Rqbit Desktop`}
version={version}
menuButtons={[configButton]}
></RqbitWebUI>
<StartupTorrentInputs enabled={configured} />
</>
<RqbitWebUI
title={`Rqbit Desktop`}
version={version}
menuButtons={[configButton]}
></RqbitWebUI>
)}
<ConfigModal
show={!configured || configurationOpened}