diff --git a/Cargo.lock b/Cargo.lock index 226024a..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", @@ -7409,3 +7428,63 @@ dependencies = [ "quote", "syn 2.0.101", ] + +[[patch.unused]] +name = "dpi" +version = "0.1.2" + +[[patch.unused]] +name = "winit" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-android" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-appkit" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-common" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-core" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-orbital" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-uikit" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-wayland" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-web" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-win32" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "winit-x11" +version = "0.31.0-beta.2" + +[[patch.unused]] +name = "softbuffer" +version = "0.4.1" + +[[patch.unused]] +name = "cosmic-freedesktop-icons" +version = "0.4.0" + +[[patch.unused]] +name = "smithay-clipboard" +version = "0.8.0" 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 58436e7..e7ee454 100644 --- a/crates/librqbit/webui/src/api-types.ts +++ b/crates/librqbit/webui/src/api-types.ts @@ -23,6 +23,7 @@ export interface TorrentFileAttributes { export interface TorrentDetails { name: string | null; info_hash: string; + output_folder?: string; files: Array; } @@ -201,5 +202,7 @@ export interface RqbitAPI { start: (index: number) => Promise; forget: (index: number) => Promise; delete: (index: number) => Promise; + deleteAll: (removeFiles?: boolean) => Promise; + openOutputFolder?: (path: string) => Promise; stats: () => Promise; } diff --git a/crates/librqbit/webui/src/components/RootContent.tsx b/crates/librqbit/webui/src/components/RootContent.tsx index f1387e4..f232916 100644 --- a/crates/librqbit/webui/src/components/RootContent.tsx +++ b/crates/librqbit/webui/src/components/RootContent.tsx @@ -1,7 +1,11 @@ +import { useState } from "react"; import { TorrentsList } from "./TorrentsList"; import { ErrorComponent } from "./ErrorComponent"; import { useTorrentStore } from "../stores/torrentStore"; import { useErrorStore } from "../stores/errorStore"; +import { IconButton } from "./buttons/IconButton"; +import { FiTrash2 } from "react-icons/fi"; +import { DeleteAllTorrentsModal } from "./modal/DeleteAllTorrentsModal"; export const RootContent = (props: {}) => { let closeableError = useErrorStore((state) => state.closeableError); @@ -11,6 +15,7 @@ export const RootContent = (props: {}) => { let torrentsInitiallyLoading = useTorrentStore( (state) => state.torrentsInitiallyLoading ); + const [deleteAllOpened, setDeleteAllOpened] = useState(false); return (
@@ -19,7 +24,18 @@ export const RootContent = (props: {}) => { remove={() => setCloseableError(null)} /> + {torrents && torrents.length > 0 && ( +
+ setDeleteAllOpened(true)}> + + +
+ )} + setDeleteAllOpened(false)} + />
); }; diff --git a/crates/librqbit/webui/src/components/Torrent.tsx b/crates/librqbit/webui/src/components/Torrent.tsx index 380b05f..6ed4590 100644 --- a/crates/librqbit/webui/src/components/Torrent.tsx +++ b/crates/librqbit/webui/src/components/Torrent.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { TorrentDetails, TorrentId, @@ -9,6 +9,7 @@ import { import { APIContext, RefreshTorrentStatsContext } from "../context"; import { customSetInterval } from "../helper/customSetInterval"; import { loopUntilSuccess } from "../helper/loopUntilSuccess"; +import { useTorrentStore } from "../stores/torrentStore"; import { TorrentRow } from "./TorrentRow"; export const Torrent: React.FC<{ @@ -21,7 +22,13 @@ export const Torrent: React.FC<{ null, ); const [forceStatsRefresh, setForceStatsRefresh] = useState(0); + const rowRef = useRef(null); const API = useContext(APIContext); + const focusedTorrentId = useTorrentStore((state) => state.focusedTorrentId); + const clearFocusedTorrent = useTorrentStore( + (state) => state.clearFocusedTorrent, + ); + const isFocusedTorrent = focusedTorrentId === id; const forceStatsRefreshCallback = () => { setForceStatsRefresh(forceStatsRefresh + 1); @@ -65,15 +72,38 @@ export const Torrent: React.FC<{ [forceStatsRefresh], ); + useEffect(() => { + if (!isFocusedTorrent) { + return; + } + + rowRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + + const timeout = setTimeout(() => clearFocusedTorrent(id), 3000); + return () => clearTimeout(timeout); + }, [clearFocusedTorrent, id, isFocusedTorrent]); + return ( - +
+ +
); }; diff --git a/crates/librqbit/webui/src/components/TorrentRow.tsx b/crates/librqbit/webui/src/components/TorrentRow.tsx index c7f3e3e..c7815b7 100644 --- a/crates/librqbit/webui/src/components/TorrentRow.tsx +++ b/crates/librqbit/webui/src/components/TorrentRow.tsx @@ -159,6 +159,7 @@ export const TorrentRow: React.FC<{ diff --git a/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx b/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx index cda77d8..749535e 100644 --- a/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx +++ b/crates/librqbit/webui/src/components/buttons/TorrentActions.tsx @@ -5,6 +5,7 @@ import { IconButton } from "./IconButton"; import { DeleteTorrentModal } from "../modal/DeleteTorrentModal"; import { FaCog, + FaFolderOpen, FaPause, FaPlay, FaTrash, @@ -16,24 +17,26 @@ import { ErrorComponent } from "../ErrorComponent"; export const TorrentActions: React.FC<{ id: number; statsResponse: TorrentStats; + outputFolder?: string; extendedView: boolean; setExtendedView: (extendedView: boolean) => void; -}> = ({ id, statsResponse, extendedView, setExtendedView }) => { +}> = ({ id, statsResponse, outputFolder, extendedView, setExtendedView }) => { let state = statsResponse.state; let [disabled, setDisabled] = useState(false); let [deleting, setDeleting] = useState(false); let refreshCtx = useContext(RefreshTorrentStatsContext); + const API = useContext(APIContext); const canPause = state == "live"; const canUnpause = state == "paused" || state == "error"; const canConfigure = state == "paused" || state == "live"; + const canOpenOutputFolder = + statsResponse.finished && !!outputFolder && !!API.openOutputFolder; const setCloseableError = useErrorStore((state) => state.setCloseableError); - const API = useContext(APIContext); - const unpause = () => { setDisabled(true); API.start(id) @@ -116,6 +119,19 @@ export const TorrentActions: React.FC<{ }); }; + const openOutputFolder = () => { + if (!API.openOutputFolder || !outputFolder) { + return; + } + + API.openOutputFolder(outputFolder).catch((e) => { + setCloseableError({ + text: `Error opening output folder for torrent id=${id}`, + details: e, + }); + }); + }; + return (
{canUnpause && ( @@ -136,6 +152,11 @@ export const TorrentActions: React.FC<{ )} + {canOpenOutputFolder && ( + + + + )} diff --git a/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx b/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx new file mode 100644 index 0000000..824a352 --- /dev/null +++ b/crates/librqbit/webui/src/components/modal/DeleteAllTorrentsModal.tsx @@ -0,0 +1,89 @@ +import { useContext, useState } from "react"; +import { APIContext } from "../../context"; +import { ErrorWithLabel } from "../../rqbit-web"; +import { ErrorComponent } from "../ErrorComponent"; +import { Spinner } from "../Spinner"; +import { Modal } from "./Modal"; +import { ModalBody } from "./ModalBody"; +import { ModalFooter } from "./ModalFooter"; +import { Button } from "../buttons/Button"; +import { useTorrentStore } from "../../stores/torrentStore"; + +export const DeleteAllTorrentsModal: React.FC<{ + show: boolean; + onHide: () => void; +}> = ({ show, onHide }) => { + if (!show) { + return null; + } + const [deleteFiles, setDeleteFiles] = useState(false); + const [error, setError] = useState(null); + const [deleting, setDeleting] = useState(false); + + const API = useContext(APIContext); + const refreshTorrents = useTorrentStore((state) => state.refreshTorrents); + + const close = () => { + setDeleteFiles(false); + setError(null); + setDeleting(false); + onHide(); + }; + + const deleteAllTorrents = () => { + setDeleting(true); + + API.deleteAll(deleteFiles) + .then(() => { + refreshTorrents(); + close(); + }) + .catch((e) => { + setError({ + text: "Error deleting all torrents", + details: e, + }); + setDeleting(false); + }); + }; + + return ( + + +

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

+ +
+ setDeleteFiles(!deleteFiles)} + checked={deleteFiles} + placeholder="Also delete files" + /> + +
+ + {error && } +
+ + + {deleting && } + + + +
+ ); +}; diff --git a/crates/librqbit/webui/src/context.tsx b/crates/librqbit/webui/src/context.tsx index 7bf10b8..ab7646a 100644 --- a/crates/librqbit/webui/src/context.tsx +++ b/crates/librqbit/webui/src/context.tsx @@ -29,6 +29,9 @@ export const APIContext = createContext({ delete: () => { throw new Error("Function not implemented."); }, + deleteAll: () => { + throw new Error("Function not implemented."); + }, getTorrentStreamUrl: () => { throw new Error("Function not implemented."); }, diff --git a/crates/librqbit/webui/src/http-api.ts b/crates/librqbit/webui/src/http-api.ts index 2e00df3..9094c1d 100644 --- a/crates/librqbit/webui/src/http-api.ts +++ b/crates/librqbit/webui/src/http-api.ts @@ -151,6 +151,13 @@ export const API: RqbitAPI & { getVersion: () => Promise } = { delete: (index: number): Promise => { return makeRequest("POST", `/torrents/${index}/delete`); }, + deleteAll: (removeFiles: boolean = false): Promise => { + let url = "/torrents/delete_all"; + if (removeFiles) { + url += "?remove_files=true"; + } + return makeRequest("POST", url); + }, getVersion: async (): Promise => { const r = await makeRequest("GET", "/"); return r.version; diff --git a/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/src/main.rs b/desktop/src-tauri/src/main.rs index d8bd8ea..4380ebc 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -4,10 +4,14 @@ mod config; use std::{ + env, fs::{File, OpenOptions}, - io::{BufReader, BufWriter}, - path::Path, + io::{BufReader, BufWriter, Read, Write}, + os::unix::net::{UnixListener, UnixStream}, + path::{Path, PathBuf}, + process::Command, sync::Arc, + thread, }; use anyhow::Context; @@ -26,10 +30,17 @@ use librqbit::{ }; use parking_lot::RwLock; use serde::Serialize; +use tauri::{AppHandle, Emitter}; use tracing::{error, error_span, info, warn}; const ERR_NOT_CONFIGURED: ApiError = ApiError::new_from_text(StatusCode::FAILED_DEPENDENCY, "not configured"); +const TORRENTS_CHANGED_EVENT: &str = "rqbit-desktop-torrents-changed"; + +#[derive(Clone, Serialize)] +struct TorrentsChangedPayload { + id: usize, +} struct StateShared { config: config::RqbitDesktopConfig, @@ -39,6 +50,7 @@ struct StateShared { struct State { config_filename: String, shared: Arc>>, + pending_launch_inputs: Arc>>, init_logging: InitLoggingResult, } @@ -194,6 +206,7 @@ impl State { return Self { config_filename, shared, + pending_launch_inputs: Arc::new(RwLock::new(Vec::new())), init_logging, }; } @@ -202,6 +215,7 @@ impl State { config_filename, init_logging, shared: Arc::new(RwLock::new(None)), + pending_launch_inputs: Arc::new(RwLock::new(Vec::new())), } } @@ -244,6 +258,211 @@ impl State { } } +fn is_torrent_launch_input(input: &str) -> bool { + input.starts_with("magnet:") + || input.starts_with("http://") + || input.starts_with("https://") + || input.starts_with("file://") + || Path::new(input) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("torrent")) +} + +fn collect_launch_inputs() -> Vec { + env::args() + .skip(1) + .filter(|arg| is_torrent_launch_input(arg)) + .collect() +} + +fn desktop_ipc_socket_path() -> PathBuf { + let runtime_dir = env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(env::temp_dir); + let user = env::var("USER").unwrap_or_else(|_| "user".to_string()); + runtime_dir.join(format!("rqbit-desktop-{user}.sock")) +} + +fn forward_inputs_to_existing(socket_path: &Path, inputs: &[String]) -> anyhow::Result<()> { + let mut stream = UnixStream::connect(socket_path)?; + let payload = serde_json::to_vec(inputs)?; + stream.write_all(&payload)?; + Ok(()) +} + +fn bind_ipc_listener(socket_path: &Path) -> std::io::Result { + if UnixStream::connect(socket_path).is_err() { + let _ = std::fs::remove_file(socket_path); + } + + if let Some(parent) = socket_path.parent() { + std::fs::create_dir_all(parent)?; + } + + UnixListener::bind(socket_path) +} + +fn spawn_ipc_listener( + socket_path: PathBuf, + shared: Arc>>, + pending: Arc>>, + app_handle: AppHandle, +) { + let listener = match bind_ipc_listener(&socket_path) { + Ok(listener) => listener, + Err(err) => { + warn!(path=%socket_path.display(), error=%err, "couldn't bind desktop IPC socket"); + return; + } + }; + + let handle = tokio::runtime::Handle::current(); + + thread::spawn(move || { + for stream in listener.incoming() { + let shared = shared.clone(); + let pending = pending.clone(); + let app_handle = app_handle.clone(); + match stream { + Ok(mut stream) => { + let mut payload = String::new(); + if let Err(err) = stream.read_to_string(&mut payload) { + warn!(error=%err, "couldn't read desktop IPC payload"); + continue; + } + + let inputs = match serde_json::from_str::>(&payload) { + Ok(inputs) => inputs, + Err(err) => { + warn!(error=%err, "couldn't parse desktop IPC payload"); + continue; + } + }; + + handle.spawn(async move { + add_launch_inputs(shared, pending, inputs, Some(app_handle)).await; + }); + } + Err(err) => warn!(error=%err, "desktop IPC accept failed"), + } + } + }); +} + +async fn add_launch_inputs( + shared: Arc>>, + pending: Arc>>, + inputs: Vec, + app_handle: Option, +) { + for input in inputs { + match add_launch_input(shared.clone(), pending.clone(), &input).await { + Ok(Some(id)) => { + if let Some(app_handle) = &app_handle { + if let Err(err) = + app_handle.emit(TORRENTS_CHANGED_EVENT, TorrentsChangedPayload { id }) + { + warn!(input=%input, error=%err, "couldn't emit desktop torrent change event"); + } + } + } + Ok(None) => {} + Err(err) => { + warn!(input=%input, error=%err, "couldn't add torrent from desktop launch input"); + } + } + } +} + +async fn add_launch_input( + shared: Arc>>, + pending: Arc>>, + input: &str, +) -> anyhow::Result> { + let api = { + let g = shared.read(); + g.as_ref().and_then(|state| state.api.as_ref()).cloned() + }; + let Some(api) = api else { + pending.write().push(input.to_owned()); + info!(input=%input, "queued desktop launch input until rqbit is configured"); + return Ok(None); + }; + + let opts = AddTorrentOptions { + overwrite: true, + ..Default::default() + }; + + let torrent = if input.starts_with("magnet:") + || input.starts_with("http://") + || input.starts_with("https://") + { + AddTorrent::Url(input.to_string().into()) + } else { + let path = path_from_launch_input(input)?; + let bytes = std::fs::read(&path) + .with_context(|| format!("couldn't read torrent file {}", path.display()))?; + AddTorrent::TorrentFileBytes(bytes.into()) + }; + + api.api_add_torrent(torrent, Some(opts)) + .await + .map(|response| response.id) + .map_err(|err| anyhow::anyhow!("{err:?}")) +} + +async fn drain_pending_launch_inputs( + shared: Arc>>, + pending: Arc>>, + app_handle: Option, +) { + let inputs = { + let mut pending = pending.write(); + if pending.is_empty() { + return; + } + pending.drain(..).collect::>() + }; + + add_launch_inputs(shared, pending, inputs, app_handle).await; +} + +fn path_from_launch_input(input: &str) -> anyhow::Result { + if let Some(uri_path) = input.strip_prefix("file://localhost/") { + return Ok(PathBuf::from(format!("/{}", percent_decode(uri_path)?))); + } + + if let Some(uri_path) = input.strip_prefix("file://") { + return Ok(PathBuf::from(percent_decode(uri_path)?)); + } + + Ok(PathBuf::from(input)) +} + +fn percent_decode(value: &str) -> anyhow::Result { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] == b'%' { + let Some(hex) = bytes.get(index + 1..index + 3) else { + anyhow::bail!("invalid percent-encoded path"); + }; + let hex = std::str::from_utf8(hex).context("invalid percent-encoded path")?; + decoded.push(u8::from_str_radix(hex, 16).context("invalid percent-encoded path")?); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + + String::from_utf8(decoded).context("invalid UTF-8 path") +} + #[derive(Default, Serialize)] struct CurrentState { config: Option, @@ -269,10 +488,18 @@ fn config_current(state: tauri::State<'_, State>) -> CurrentState { #[tauri::command] async fn config_change( + app_handle: AppHandle, state: tauri::State<'_, State>, config: RqbitDesktopConfig, ) -> Result { - state.configure(config).await.map(|_| EmptyJsonResponse {}) + state.configure(config).await?; + drain_pending_launch_inputs( + state.shared.clone(), + state.pending_launch_inputs.clone(), + Some(app_handle), + ) + .await; + Ok(EmptyJsonResponse {}) } #[tauri::command] @@ -333,6 +560,17 @@ async fn torrent_action_delete( state.api()?.api_torrent_action_delete(id).await } +#[tauri::command] +async fn torrents_delete_all( + state: tauri::State<'_, State>, + remove_files: bool, +) -> Result { + state + .api()? + .api_torrents_delete_all(remove_files) + .await +} + #[tauri::command] async fn torrent_action_pause( state: tauri::State<'_, State>, @@ -374,6 +612,37 @@ async fn stats(state: tauri::State<'_, State>) -> Result Result { + let path = PathBuf::from(path); + let metadata = std::fs::metadata(&path) + .with_context(|| format!("couldn't access output folder {}", path.display())) + .map_err(|e| ApiError::new_from_anyhow(StatusCode::BAD_REQUEST, e))?; + + if !metadata.is_dir() { + return Err(ApiError::new_from_anyhow( + StatusCode::BAD_REQUEST, + anyhow::anyhow!("output path is not a folder: {}", path.display()), + )); + } + + let status = Command::new("xdg-open") + .arg(&path) + .status() + .or_else(|_| Command::new("gio").arg("open").arg(&path).status()) + .with_context(|| format!("couldn't open output folder {}", path.display())) + .map_err(|e| ApiError::new_from_anyhow(StatusCode::INTERNAL_SERVER_ERROR, e))?; + + if !status.success() { + return Err(ApiError::new_from_anyhow( + StatusCode::INTERNAL_SERVER_ERROR, + anyhow::anyhow!("file manager exited with status {status}"), + )); + } + + Ok(EmptyJsonResponse {}) +} + #[tauri::command] fn get_version() -> &'static str { env!("CARGO_PKG_VERSION") @@ -393,22 +662,54 @@ async fn start() { Err(e) => warn!("failed increasing open file limit: {:#}", e), }; + let launch_inputs = collect_launch_inputs(); + let socket_path = desktop_ipc_socket_path(); + + if !launch_inputs.is_empty() && forward_inputs_to_existing(&socket_path, &launch_inputs).is_ok() + { + return; + } + let state = State::new(init_logging_result).await; + let shared = state.shared.clone(); + let pending = state.pending_launch_inputs.clone(); tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(state) + .setup(move |app| { + let app_handle = app.handle().clone(); + spawn_ipc_listener( + socket_path.clone(), + shared.clone(), + pending.clone(), + app_handle.clone(), + ); + + if !launch_inputs.is_empty() { + tauri::async_runtime::spawn(add_launch_inputs( + shared.clone(), + pending.clone(), + launch_inputs.clone(), + Some(app_handle), + )); + } + + Ok(()) + }) .invoke_handler(tauri::generate_handler![ torrents_list, torrent_details, torrent_stats, torrent_create_from_url, torrent_action_delete, + torrents_delete_all, torrent_action_pause, torrent_action_forget, torrent_action_start, torrent_action_configure, torrent_create_from_base64_file, + open_output_folder, stats, get_version, config_default, diff --git a/desktop/src/api.tsx b/desktop/src/api.tsx index e92a8aa..ae2a468 100644 --- a/desktop/src/api.tsx +++ b/desktop/src/api.tsx @@ -133,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 e22ff35..e823cd0 100644 --- a/desktop/src/rqbit-desktop.tsx +++ b/desktop/src/rqbit-desktop.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; import { RqbitWebUI } from "rqbit-webui/src/rqbit-web"; import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration"; import { ConfigModal } from "./configure"; @@ -6,6 +7,13 @@ import { IconButton } from "rqbit-webui/src/components/buttons/IconButton"; import { BsSliders2 } from "react-icons/bs"; import { APIContext } from "rqbit-webui/src/context"; import { makeAPI } from "./api"; +import { useTorrentStore } from "rqbit-webui/src/stores/torrentStore"; + +const TORRENTS_CHANGED_EVENT = "rqbit-desktop-torrents-changed"; + +type TorrentsChangedPayload = { + id?: number; +}; export const RqbitDesktop: React.FC<{ version: string; @@ -18,6 +26,31 @@ export const RqbitDesktop: React.FC<{ ); let [configurationOpened, setConfigurationOpened] = useState(false); + useEffect(() => { + if (!configured) { + return; + } + + let cancelled = false; + const unlisten = listen(TORRENTS_CHANGED_EVENT, (event) => { + if (event.payload.id != null) { + useTorrentStore.getState().focusTorrent(event.payload.id); + } + + setTimeout(() => { + if (cancelled) { + return; + } + useTorrentStore.getState().refreshTorrents(); + }, 250); + }); + + return () => { + cancelled = true; + unlisten.then((cleanup) => cleanup()); + }; + }, [configured]); + const configButton = ( {