diff --git a/Cargo.lock b/Cargo.lock index 04a30bd..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", @@ -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" 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/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, diff --git a/crates/librqbit/webui/src/api-types.ts b/crates/librqbit/webui/src/api-types.ts index 64e41f0..e7ee454 100644 --- a/crates/librqbit/webui/src/api-types.ts +++ b/crates/librqbit/webui/src/api-types.ts @@ -23,30 +23,10 @@ export interface TorrentFileAttributes { export interface TorrentDetails { name: string | null; info_hash: string; - output_folder: 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; @@ -213,15 +193,16 @@ export interface RqbitAPI { filename?: string | null, ) => string | null; uploadTorrent: ( - data: TorrentInput, + data: string | File, opts?: AddTorrentOptions, ) => Promise; - openTorrentOutput?: (index: number) => Promise; pause: (index: number) => Promise; updateOnlyFiles: (index: number, files: number[]) => Promise; 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/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 307070a..c7815b7 100644 --- a/crates/librqbit/webui/src/components/TorrentRow.tsx +++ b/crates/librqbit/webui/src/components/TorrentRow.tsx @@ -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 ( -
- {contextMenu && ( -
event.stopPropagation()} - > - - -
- )} +
{/* Icon */}
{statusIcon("w-10 h-10")}
@@ -232,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 1cc0c91..749535e 100644 --- a/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx +++ b/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx @@ -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(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 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 (
{canUnpause && ( @@ -153,9 +152,9 @@ export const TorrentActions: React.FC<{ )} - {API.openTorrentOutput && ( - - + {canOpenOutputFolder && ( + + )} diff --git a/crates/librqbit/webui/src/components/buttons/UploadButton.tsx b/crates/librqbit/webui/src/components/buttons/UploadButton.tsx index d78018a..751830b 100644 --- a/crates/librqbit/webui/src/components/buttons/UploadButton.tsx +++ b/crates/librqbit/webui/src/components/buttons/UploadButton.tsx @@ -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; 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/components/modal/FileSelectionModal.tsx b/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx index cba5c72..e1ccd07 100644 --- a/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx +++ b/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx @@ -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, 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 b4012d8..9094c1d 100644 --- a/crates/librqbit/webui/src/http-api.ts +++ b/crates/librqbit/webui/src/http-api.ts @@ -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 } = { }, 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"; @@ -158,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/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/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; diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 5cdb53f..b48609f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -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 diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 62916e9..4380ebc 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -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>>, - pending_torrent_inputs: Mutex>, + pending_launch_inputs: Arc>>, 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)?; @@ -471,11 +184,7 @@ async fn api_from_config( } impl State { - async fn new( - init_logging: InitLoggingResult, - pending_torrent_inputs: Vec, - ) -> 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 { + 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, @@ -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 { - 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, -) -> 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>, @@ -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 { + 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) -> Vec { - let mut inputs = state.pending_torrent_inputs.lock(); - std::mem::take(&mut *inputs) -} +async fn open_output_folder(path: String) -> 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))?; -#[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; - } + 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, diff --git a/desktop/src/StartupTorrentInputs.tsx b/desktop/src/StartupTorrentInputs.tsx deleted file mode 100644 index b67bccb..0000000 --- a/desktop/src/StartupTorrentInputs.tsx +++ /dev/null @@ -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([]); - 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 d59593a..ae2a468 100644 --- a/desktop/src/api.tsx +++ b/desktop/src/api.tsx @@ -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( - "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, @@ -146,6 +133,12 @@ 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 }); + }, getTorrentStreamUrl: () => { return ""; }, diff --git a/desktop/src/rqbit-desktop.tsx b/desktop/src/rqbit-desktop.tsx index f66c7ab..e823cd0 100644 --- a/desktop/src/rqbit-desktop.tsx +++ b/desktop/src/rqbit-desktop.tsx @@ -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(false); - const api = useMemo(() => makeAPI(config), [config]); + + 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 = ( + {configured && ( - <> - - - + )}