diff --git a/Cargo.lock b/Cargo.lock index 32cbb3b..04a30bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2781,15 +2781,6 @@ 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" @@ -3186,15 +3177,6 @@ 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" @@ -4636,7 +4618,6 @@ dependencies = [ "libc", "librqbit", "librqbit-upnp-serve", - "mimalloc", "openssl", "parking_lot", "parse_duration", @@ -4660,6 +4641,7 @@ dependencies = [ "base64 0.22.1", "directories 5.0.1", "gethostname 0.5.0", + "gtk", "http", "librqbit", "parking_lot", @@ -7428,63 +7410,3 @@ 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 1ef76d2..cef6b72 100644 --- a/crates/librqbit/src/api.rs +++ b/crates/librqbit/src/api.rs @@ -325,48 +325,6 @@ 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 a87d725..ff3b43d 100644 --- a/crates/librqbit/src/http_api/handlers/mod.rs +++ b/crates/librqbit/src/http_api/handlers/mod.rs @@ -112,9 +112,6 @@ 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 6e3c2f5..75ffb09 100644 --- a/crates/librqbit/src/http_api/handlers/torrents.rs +++ b/crates/librqbit/src/http_api/handlers/torrents.rs @@ -146,25 +146,6 @@ 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 e7ee454..64e41f0 100644 --- a/crates/librqbit/webui/src/api-types.ts +++ b/crates/librqbit/webui/src/api-types.ts @@ -23,10 +23,30 @@ 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; @@ -193,16 +213,15 @@ export interface RqbitAPI { filename?: string | null, ) => string | null; uploadTorrent: ( - data: string | File, + data: TorrentInput, opts?: AddTorrentOptions, ) => Promise; + openTorrentOutput?: (index: number) => Promise; pause: (index: number) => Promise; updateOnlyFiles: (index: number, files: number[]) => Promise; 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 f232916..f1387e4 100644 --- a/crates/librqbit/webui/src/components/RootContent.tsx +++ b/crates/librqbit/webui/src/components/RootContent.tsx @@ -1,11 +1,7 @@ -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); @@ -15,7 +11,6 @@ export const RootContent = (props: {}) => { let torrentsInitiallyLoading = useTorrentStore( (state) => state.torrentsInitiallyLoading ); - const [deleteAllOpened, setDeleteAllOpened] = useState(false); return (
@@ -24,18 +19,7 @@ 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 6ed4590..380b05f 100644 --- a/crates/librqbit/webui/src/components/Torrent.tsx +++ b/crates/librqbit/webui/src/components/Torrent.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { TorrentDetails, TorrentId, @@ -9,7 +9,6 @@ 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<{ @@ -22,13 +21,7 @@ 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); @@ -72,38 +65,15 @@ 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 c7815b7..307070a 100644 --- a/crates/librqbit/webui/src/components/TorrentRow.tsx +++ b/crates/librqbit/webui/src/components/TorrentRow.tsx @@ -93,9 +93,82 @@ export const TorrentRow: React.FC<{ }; const [extendedView, setExtendedView] = useState(false); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + } | null>(null); + + useEffect(() => { + if (!contextMenu) { + return; + } + + const close = () => setContextMenu(null); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setContextMenu(null); + } + }; + + window.addEventListener("click", close); + window.addEventListener("keydown", closeOnEscape); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("keydown", closeOnEscape); + }; + }, [contextMenu]); + + const openOutput = () => { + if (!API.openTorrentOutput) { + return; + } + + API.openTorrentOutput(id).catch((e) => { + setCloseableError({ + text: `Error opening torrent output id=${id}`, + details: e as ErrorDetails, + }); + }); + setContextMenu(null); + }; + + const handleContextMenu = (event: React.MouseEvent) => { + if (!API.openTorrentOutput) { + return; + } + + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY }); + }; return ( -
+
+ {contextMenu && ( +
event.stopPropagation()} + > + + +
+ )}
{/* Icon */}
{statusIcon("w-10 h-10")}
@@ -159,7 +232,6 @@ 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 749535e..1cc0c91 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,26 +17,40 @@ import { ErrorComponent } from "../ErrorComponent"; export const TorrentActions: React.FC<{ id: number; statsResponse: TorrentStats; - outputFolder?: string; extendedView: boolean; setExtendedView: (extendedView: boolean) => void; -}> = ({ id, statsResponse, outputFolder, extendedView, setExtendedView }) => { +}> = ({ id, statsResponse, 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) @@ -119,19 +133,6 @@ 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 && ( @@ -152,9 +153,9 @@ export const TorrentActions: React.FC<{ )} - {canOpenOutputFolder && ( - - + {API.openTorrentOutput && ( + + )} diff --git a/crates/librqbit/webui/src/components/buttons/UploadButton.tsx b/crates/librqbit/webui/src/components/buttons/UploadButton.tsx index 751830b..d78018a 100644 --- a/crates/librqbit/webui/src/components/buttons/UploadButton.tsx +++ b/crates/librqbit/webui/src/components/buttons/UploadButton.tsx @@ -2,6 +2,7 @@ import { ReactNode, useContext, useEffect, useState } from "react"; import { AddTorrentResponse, ErrorDetails as ApiErrorDetails, + TorrentInput, } from "../../api-types"; import { APIContext } from "../../context"; import { ErrorWithLabel } from "../../rqbit-web"; @@ -10,7 +11,7 @@ import { Button } from "./Button"; export const UploadButton: React.FC<{ onClick: () => void; - data: string | File | null; + data: TorrentInput | null; resetData: () => void; children: ReactNode; className?: string; diff --git a/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx b/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx deleted file mode 100644 index 824a352..0000000 --- a/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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 e1ccd07..cba5c72 100644 --- a/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx +++ b/crates/librqbit/webui/src/components/modal/FileSelectionModal.tsx @@ -1,5 +1,9 @@ import { useContext, useEffect, useState } from "react"; -import { AddTorrentResponse, AddTorrentOptions } from "../../api-types"; +import { + AddTorrentResponse, + AddTorrentOptions, + TorrentInput, +} from "../../api-types"; import { APIContext } from "../../context"; import { ErrorComponent } from "../ErrorComponent"; import { ErrorWithLabel } from "../../rqbit-web"; @@ -19,7 +23,7 @@ export const FileSelectionModal = (props: { listTorrentResponse: AddTorrentResponse | null; listTorrentError: ErrorWithLabel | null; listTorrentLoading: boolean; - data: string | File; + data: TorrentInput; }) => { let { onHide, diff --git a/crates/librqbit/webui/src/context.tsx b/crates/librqbit/webui/src/context.tsx index ab7646a..7bf10b8 100644 --- a/crates/librqbit/webui/src/context.tsx +++ b/crates/librqbit/webui/src/context.tsx @@ -29,9 +29,6 @@ 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 9094c1d..b4012d8 100644 --- a/crates/librqbit/webui/src/http-api.ts +++ b/crates/librqbit/webui/src/http-api.ts @@ -6,6 +6,7 @@ import { SessionStats, TorrentDetails, TorrentStats, + isLocalTorrentFile, } from "./api-types"; // Define API URL and base path @@ -99,6 +100,12 @@ export const API: RqbitAPI & { getVersion: () => Promise } = { }, uploadTorrent: (data, opts): Promise => { + if (isLocalTorrentFile(data)) { + return Promise.reject({ + text: "Local torrent file paths are only supported in rqbit desktop.", + }); + } + let url = "/torrents?&overwrite=true"; if (opts?.list_only) { url += "&list_only=true"; @@ -151,13 +158,6 @@ 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 faf2081..0fd9a71 100644 --- a/crates/librqbit/webui/src/stores/torrentStore.ts +++ b/crates/librqbit/webui/src/stores/torrentStore.ts @@ -11,10 +11,6 @@ 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 => { @@ -55,13 +51,4 @@ 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 4e49a7d..6e651da 100644 --- a/crates/rqbit/Cargo.toml +++ b/crates/rqbit/Cargo.toml @@ -24,7 +24,6 @@ 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 d41211a..dde6707 100644 --- a/crates/rqbit/src/main.rs +++ b/crates/rqbit/src/main.rs @@ -922,6 +922,3 @@ 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 b48609f..5cdb53f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ serde_with = "3.4.0" parking_lot = "0.12.1" gethostname = "0.5.0" tauri-plugin-shell = "2" +gtk = "0.18.2" [features] # this feature is used for production builds or when `devPath` points to the filesystem diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 4380ebc..62916e9 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -4,16 +4,17 @@ mod config; use std::{ - env, + ffi::OsString, 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; @@ -28,19 +29,13 @@ use librqbit::{ AddTorrent, AddTorrentOptions, Api, ApiError, PeerConnectionOptions, Session, SessionOptions, SessionPersistenceConfig, }; -use parking_lot::RwLock; -use serde::Serialize; -use tauri::{AppHandle, Emitter}; +use parking_lot::{Mutex, RwLock}; +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Manager}; use tracing::{error, error_span, info, warn}; const ERR_NOT_CONFIGURED: ApiError = 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, @@ -50,10 +45,302 @@ struct StateShared { struct State { config_filename: String, shared: Arc>>, - pending_launch_inputs: Arc>>, + pending_torrent_inputs: Mutex>, init_logging: InitLoggingResult, } +const TORRENT_INPUTS_EVENT: &str = "torrent-inputs"; + +#[derive(Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum PendingTorrentInput { + FilePath { path: String }, + Url { url: String }, +} + +#[derive(Deserialize, Serialize)] +struct InstanceMessage { + inputs: Vec, +} + +fn pending_torrent_input_from_arg(arg: OsString) -> Option { + let raw = arg.to_string_lossy(); + let arg = raw.trim(); + if arg.is_empty() { + return None; + } + + let lower_arg = arg.to_ascii_lowercase(); + if lower_arg.starts_with("magnet:") + || lower_arg.starts_with("http://") + || lower_arg.starts_with("https://") + { + return Some(PendingTorrentInput::Url { + url: arg.to_owned(), + }); + } + + let path = if let Some(path) = arg.strip_prefix("file://") { + file_uri_to_path(path)? + } else { + PathBuf::from(arg) + }; + + if path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("torrent")) + { + return Some(PendingTorrentInput::FilePath { + path: path.to_string_lossy().into_owned(), + }); + } + + None +} + +fn file_uri_to_path(uri_path: &str) -> Option { + let path = if let Some(path) = uri_path.strip_prefix("localhost/") { + format!("/{path}") + } else { + uri_path.to_owned() + }; + percent_decode(&path).map(PathBuf::from) +} + +fn percent_decode(value: &str) -> Option { + fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } + } + + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut idx = 0; + + while idx < bytes.len() { + if bytes[idx] == b'%' { + let high = *bytes.get(idx + 1)?; + let low = *bytes.get(idx + 2)?; + decoded.push(hex_value(high)? << 4 | hex_value(low)?); + idx += 3; + } else { + decoded.push(bytes[idx]); + idx += 1; + } + } + + String::from_utf8(decoded).ok() +} + +fn pending_torrent_inputs_from_args() -> Vec { + std::env::args_os() + .skip(1) + .filter_map(pending_torrent_input_from_arg) + .collect() +} + +#[cfg(unix)] +enum SingleInstance { + Primary(UnixListener), + Secondary, + Disabled, +} + +#[cfg(unix)] +fn acquire_single_instance(inputs: &[PendingTorrentInput]) -> SingleInstance { + let socket_path = single_instance_socket_path(); + + if send_instance_message(&socket_path, inputs).is_ok() { + return SingleInstance::Secondary; + } + + match bind_single_instance_socket(&socket_path) { + Ok(listener) => SingleInstance::Primary(listener), + Err(e) => { + warn!(error = ?e, path = ?socket_path, "single instance socket disabled"); + SingleInstance::Disabled + } + } +} + +#[cfg(unix)] +fn single_instance_socket_path() -> PathBuf { + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + return PathBuf::from(runtime_dir).join("rqbit-desktop.sock"); + } + + let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_owned()); + std::env::temp_dir().join(format!("rqbit-desktop-{user}.sock")) +} + +#[cfg(unix)] +fn send_instance_message(path: &Path, inputs: &[PendingTorrentInput]) -> anyhow::Result<()> { + let mut stream = UnixStream::connect(path)?; + serde_json::to_writer( + &mut stream, + &InstanceMessage { + inputs: inputs.to_vec(), + }, + )?; + stream.write_all(b"\n")?; + Ok(()) +} + +#[cfg(unix)] +fn bind_single_instance_socket(path: &Path) -> anyhow::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("error creating socket directory {parent:?}"))?; + } + + match UnixListener::bind(path) { + Ok(listener) => Ok(listener), + Err(e) if path.exists() => { + std::fs::remove_file(path) + .with_context(|| format!("error removing stale socket {path:?}"))?; + UnixListener::bind(path) + .with_context(|| format!("error binding single instance socket {path:?}")) + } + Err(e) => Err(e).with_context(|| format!("error binding single instance socket {path:?}")), + } +} + +#[cfg(unix)] +fn start_single_instance_listener(listener: UnixListener, app: tauri::AppHandle) { + std::thread::spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let app = app.clone(); + std::thread::spawn(move || handle_single_instance_stream(stream, app)); + } + Err(e) => warn!("error accepting single instance connection: {:#}", e), + } + } + }); +} + +#[cfg(unix)] +fn handle_single_instance_stream(mut stream: UnixStream, app: tauri::AppHandle) { + let mut body = String::new(); + if let Err(e) = stream.read_to_string(&mut body) { + warn!("error reading single instance message: {:#}", e); + return; + } + + let message: InstanceMessage = match serde_json::from_str(&body) { + Ok(message) => message, + Err(e) => { + warn!("error parsing single instance message: {:#}", e); + return; + } + }; + + bring_main_window_to_front(&app); + + if message.inputs.is_empty() { + return; + } + + let state = app.state::(); + state.pending_torrent_inputs.lock().extend(message.inputs); + + if let Err(e) = app.emit(TORRENT_INPUTS_EVENT, ()) { + warn!("error emitting torrent inputs event: {:#}", e); + } +} + +fn bring_main_window_to_front(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } +} + +#[cfg(target_os = "linux")] +fn apply_gtk_window_controls_layout(app: &tauri::App) { + use gtk::prelude::*; + + let Some(window) = app.get_webview_window("main") else { + return; + }; + let Ok(gtk_window) = window.gtk_window() else { + return; + }; + let Some(titlebar) = gtk_window.titlebar() else { + return; + }; + let Some(header) = find_gtk_header_bar(&titlebar) else { + warn!("could not find GTK header bar for window controls layout"); + return; + }; + + apply_gtk_header_bar_layout(&header); + + let header_for_resize = header.downgrade(); + gtk_window.connect_resizable_notify(move |_| { + if let Some(header) = header_for_resize.upgrade() { + apply_gtk_header_bar_layout(&header); + } + }); + + if let Some(settings) = gtk::Settings::default() { + let header_for_settings = header.downgrade(); + settings.connect_gtk_decoration_layout_notify(move |_| { + if let Some(header) = header_for_settings.upgrade() { + apply_gtk_header_bar_layout(&header); + } + }); + } +} + +#[cfg(target_os = "linux")] +fn find_gtk_header_bar(widget: >k::Widget) -> Option { + use gtk::prelude::*; + + if let Ok(header) = widget.clone().downcast::() { + return Some(header); + } + + let Ok(container) = widget.clone().downcast::() else { + return None; + }; + + for child in container.children() { + if let Some(header) = find_gtk_header_bar(&child) { + return Some(header); + } + } + + None +} + +#[cfg(target_os = "linux")] +fn apply_gtk_header_bar_layout(header: >k::HeaderBar) { + use gtk::prelude::*; + + let layout = gtk_window_controls_layout(); + header.set_decoration_layout(Some(&layout)); + info!(%layout, "applied GTK window controls layout"); +} + +#[cfg(target_os = "linux")] +fn gtk_window_controls_layout() -> String { + use gtk::prelude::*; + + gtk::Settings::default() + .and_then(|settings| settings.gtk_decoration_layout()) + .map(|layout| layout.to_string()) + .filter(|layout| !layout.trim().is_empty()) + .unwrap_or_else(|| "menu:minimize,maximize,close".to_string()) +} + fn read_config(path: &str) -> anyhow::Result { let rdr = BufReader::new(File::open(path)?); let mut config: RqbitDesktopConfig = serde_json::from_reader(rdr)?; @@ -184,7 +471,11 @@ async fn api_from_config( } impl State { - async fn new(init_logging: InitLoggingResult) -> Self { + async fn new( + init_logging: InitLoggingResult, + pending_torrent_inputs: Vec, + ) -> Self { + let pending_torrent_inputs = Mutex::new(pending_torrent_inputs); let config_filename = directories::ProjectDirs::from("com", "rqbit", "desktop") .expect("directories::ProjectDirs::from") .config_dir() @@ -206,7 +497,7 @@ impl State { return Self { config_filename, shared, - pending_launch_inputs: Arc::new(RwLock::new(Vec::new())), + pending_torrent_inputs, init_logging, }; } @@ -215,7 +506,7 @@ impl State { config_filename, init_logging, shared: Arc::new(RwLock::new(None)), - pending_launch_inputs: Arc::new(RwLock::new(Vec::new())), + pending_torrent_inputs, } } @@ -258,211 +549,6 @@ 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, @@ -488,18 +574,10 @@ 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?; - drain_pending_launch_inputs( - state.shared.clone(), - state.pending_launch_inputs.clone(), - Some(app_handle), - ) - .await; - Ok(EmptyJsonResponse {}) + state.configure(config).await.map(|_| EmptyJsonResponse {}) } #[tauri::command] @@ -536,6 +614,21 @@ async fn torrent_create_from_base64_file( .await } +#[tauri::command] +async fn torrent_create_from_file_path( + state: tauri::State<'_, State>, + path: String, + opts: Option, +) -> Result { + let bytes = std::fs::read(&path) + .with_context(|| format!("error reading torrent file {path:?}")) + .map_err(|e| ApiError::new_from_anyhow(StatusCode::BAD_REQUEST, e))?; + state + .api()? + .api_add_torrent(AddTorrent::TorrentFileBytes(bytes.into()), opts) + .await +} + #[tauri::command] async fn torrent_details( state: tauri::State<'_, State>, @@ -560,17 +653,6 @@ 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>, @@ -613,36 +695,69 @@ async fn stats(state: tauri::State<'_, State>) -> Result Result { - let path = PathBuf::from(path); - let metadata = std::fs::metadata(&path) - .with_context(|| format!("couldn't access output folder {}", path.display())) - .map_err(|e| ApiError::new_from_anyhow(StatusCode::BAD_REQUEST, e))?; +fn take_startup_torrent_inputs(state: tauri::State<'_, State>) -> Vec { + let mut inputs = state.pending_torrent_inputs.lock(); + std::mem::take(&mut *inputs) +} - if !metadata.is_dir() { - return Err(ApiError::new_from_anyhow( - StatusCode::BAD_REQUEST, - anyhow::anyhow!("output path is not a folder: {}", path.display()), - )); - } - - let status = Command::new("xdg-open") - .arg(&path) - .status() - .or_else(|_| Command::new("gio").arg("open").arg(&path).status()) - .with_context(|| format!("couldn't open output folder {}", path.display())) +#[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))?; - - if !status.success() { - return Err(ApiError::new_from_anyhow( - StatusCode::INTERNAL_SERVER_ERROR, - anyhow::anyhow!("file manager exited with status {status}"), - )); - } - Ok(EmptyJsonResponse {}) } +fn nearest_existing_path(mut path: PathBuf) -> PathBuf { + let original = path.clone(); + while !path.exists() { + if !path.pop() { + return original; + } + } + path +} + +#[cfg(target_os = "linux")] +fn open_file_manager(path: &Path) -> anyhow::Result<()> { + let mut command = Command::new("xdg-open"); + command.arg(path); + spawn_open_command(command) +} + +#[cfg(target_os = "macos")] +fn open_file_manager(path: &Path) -> anyhow::Result<()> { + let mut command = Command::new("open"); + command.arg(path); + spawn_open_command(command) +} + +#[cfg(target_os = "windows")] +fn open_file_manager(path: &Path) -> anyhow::Result<()> { + let mut command = Command::new("explorer"); + command.arg(path); + spawn_open_command(command) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn open_file_manager(_path: &Path) -> anyhow::Result<()> { + anyhow::bail!("opening downloaded files is not supported on this platform") +} + +fn spawn_open_command(mut command: Command) -> anyhow::Result<()> { + let mut child = command.spawn().context("error opening path")?; + std::thread::spawn(move || { + if let Err(e) = child.wait() { + warn!("error waiting for opener process: {:#}", e); + } + }); + Ok(()) +} + #[tauri::command] fn get_version() -> &'static str { env!("CARGO_PKG_VERSION") @@ -662,39 +777,28 @@ async fn start() { Err(e) => warn!("failed increasing open file limit: {:#}", e), }; - let launch_inputs = collect_launch_inputs(); - let socket_path = desktop_ipc_socket_path(); + let pending_torrent_inputs = pending_torrent_inputs_from_args(); - if !launch_inputs.is_empty() && forward_inputs_to_existing(&socket_path, &launch_inputs).is_ok() - { - return; - } + #[cfg(unix)] + let single_instance_listener = match acquire_single_instance(&pending_torrent_inputs) { + SingleInstance::Primary(listener) => Some(listener), + SingleInstance::Secondary => return, + SingleInstance::Disabled => None, + }; - let state = State::new(init_logging_result).await; - let shared = state.shared.clone(); - let pending = state.pending_launch_inputs.clone(); + let state = State::new(init_logging_result, pending_torrent_inputs).await; tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(state) .setup(move |app| { - let app_handle = app.handle().clone(); - spawn_ipc_listener( - socket_path.clone(), - shared.clone(), - pending.clone(), - app_handle.clone(), - ); + #[cfg(target_os = "linux")] + apply_gtk_window_controls_layout(app); - if !launch_inputs.is_empty() { - tauri::async_runtime::spawn(add_launch_inputs( - shared.clone(), - pending.clone(), - launch_inputs.clone(), - Some(app_handle), - )); + #[cfg(unix)] + if let Some(listener) = single_instance_listener { + start_single_instance_listener(listener, app.handle().clone()); } - Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -703,13 +807,14 @@ 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, - open_output_folder, + torrent_create_from_file_path, + take_startup_torrent_inputs, + torrent_open_output, stats, get_version, config_default, diff --git a/desktop/src/StartupTorrentInputs.tsx b/desktop/src/StartupTorrentInputs.tsx new file mode 100644 index 0000000..b67bccb --- /dev/null +++ b/desktop/src/StartupTorrentInputs.tsx @@ -0,0 +1,144 @@ +import { useContext, useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { + AddTorrentResponse, + ErrorDetails as ApiErrorDetails, + TorrentInput, + localTorrentFile, +} from "rqbit-webui/src/api-types"; +import { APIContext } from "rqbit-webui/src/context"; +import { FileSelectionModal } from "rqbit-webui/src/components/modal/FileSelectionModal"; +import { ErrorWithLabel } from "rqbit-webui/src/rqbit-web"; + +type PendingTorrentInput = + | { type: "file_path"; path: string } + | { type: "url"; url: string }; + +const TORRENT_INPUTS_EVENT = "torrent-inputs"; + +const toTorrentInput = (input: PendingTorrentInput): TorrentInput => { + if (input.type === "file_path") { + return localTorrentFile(input.path); + } + return input.url; +}; + +export const StartupTorrentInputs = ({ enabled }: { enabled: boolean }) => { + const API = useContext(APIContext); + const [queue, setQueue] = useState([]); + const [activeInput, setActiveInput] = useState(null); + const [listTorrentResponse, setListTorrentResponse] = + useState(null); + const [listTorrentError, setListTorrentError] = + useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!enabled) { + return; + } + + let unlisten: (() => void) | null = null; + let cancelled = false; + + const drainPendingInputs = () => { + invoke("take_startup_torrent_inputs").then( + (inputs) => { + if (!cancelled && inputs.length > 0) { + setQueue((queue) => [...queue, ...inputs.map(toTorrentInput)]); + } + }, + (e) => { + console.error("error reading startup torrent inputs", e); + }, + ); + }; + + listen(TORRENT_INPUTS_EVENT, drainPendingInputs).then( + (cleanup) => { + if (cancelled) { + cleanup(); + return; + } + unlisten = cleanup; + drainPendingInputs(); + }, + (e) => { + console.error("error listening for startup torrent inputs", e); + drainPendingInputs(); + }, + ); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [enabled]); + + useEffect(() => { + if (!activeInput && queue.length > 0) { + const [next, ...remaining] = queue; + setActiveInput(next); + setQueue(remaining); + } + }, [activeInput, queue]); + + useEffect(() => { + if (!activeInput) { + return; + } + + let cancelled = false; + setLoading(true); + setListTorrentError(null); + setListTorrentResponse(null); + + API.uploadTorrent(activeInput, { list_only: true }) + .then( + (response) => { + if (!cancelled) { + setListTorrentResponse(response); + } + }, + (e) => { + if (!cancelled) { + setListTorrentError({ + text: "Error listing torrent files", + details: e as ApiErrorDetails, + }); + } + }, + ) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [API, activeInput]); + + const clearActive = () => { + setActiveInput(null); + setListTorrentError(null); + setListTorrentResponse(null); + setLoading(false); + }; + + if (!activeInput) { + return null; + } + + return ( + + ); +}; diff --git a/desktop/src/api.tsx b/desktop/src/api.tsx index ae2a468..d59593a 100644 --- a/desktop/src/api.tsx +++ b/desktop/src/api.tsx @@ -7,6 +7,7 @@ import { TorrentStats, ErrorDetails, SessionStats, + isLocalTorrentFile, } from "rqbit-webui/src/api-types"; import { InvokeArgs, invoke } from "@tauri-apps/api/core"; @@ -110,11 +111,23 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => { } ); } + if (isLocalTorrentFile(data)) { + return await invokeAPI( + "torrent_create_from_file_path", + { + path: data.path, + opts: opts ?? {}, + } + ); + } return await invokeAPI("torrent_create_from_url", { url: data, opts: opts ?? {}, }); }, + openTorrentOutput: function (id: number): Promise { + return invokeAPI("torrent_open_output", { id }); + }, updateOnlyFiles: function (id, files): Promise { return invokeAPI("torrent_action_configure", { id: id, @@ -133,12 +146,6 @@ 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 e823cd0..f66c7ab 100644 --- a/desktop/src/rqbit-desktop.tsx +++ b/desktop/src/rqbit-desktop.tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { useMemo, useState } from "react"; import { RqbitWebUI } from "rqbit-webui/src/rqbit-web"; import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration"; import { ConfigModal } from "./configure"; @@ -7,13 +6,7 @@ import { IconButton } from "rqbit-webui/src/components/buttons/IconButton"; import { BsSliders2 } from "react-icons/bs"; import { APIContext } from "rqbit-webui/src/context"; import { makeAPI } from "./api"; -import { useTorrentStore } from "rqbit-webui/src/stores/torrentStore"; - -const TORRENTS_CHANGED_EVENT = "rqbit-desktop-torrents-changed"; - -type TorrentsChangedPayload = { - id?: number; -}; +import { StartupTorrentInputs } from "./StartupTorrentInputs"; export const RqbitDesktop: React.FC<{ version: string; @@ -25,31 +18,7 @@ export const RqbitDesktop: React.FC<{ currentState.config ?? defaultConfig, ); let [configurationOpened, setConfigurationOpened] = useState(false); - - useEffect(() => { - if (!configured) { - return; - } - - let cancelled = false; - const unlisten = listen(TORRENTS_CHANGED_EVENT, (event) => { - if (event.payload.id != null) { - useTorrentStore.getState().focusTorrent(event.payload.id); - } - - setTimeout(() => { - if (cancelled) { - return; - } - useTorrentStore.getState().refreshTorrents(); - }, 250); - }); - - return () => { - cancelled = true; - unlisten.then((cleanup) => cleanup()); - }; - }, [configured]); + const api = useMemo(() => makeAPI(config), [config]); const configButton = ( + {configured && ( - + <> + + + )}