diff --git a/crates/librqbit/webui/src/api-types.ts b/crates/librqbit/webui/src/api-types.ts index 58436e7..6580d3e 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,6 @@ export interface RqbitAPI { start: (index: number) => Promise; forget: (index: number) => Promise; delete: (index: number) => Promise; + openOutputFolder?: (path: string) => Promise; stats: () => Promise; } 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/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/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index d8bd8ea..9488203 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] @@ -374,6 +601,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,11 +651,41 @@ 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, @@ -409,6 +697,7 @@ async fn start() { 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..7f1d97f 100644 --- a/desktop/src/api.tsx +++ b/desktop/src/api.tsx @@ -133,6 +133,9 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => { delete: function (id: number): Promise { return invokeAPI("torrent_action_delete", { id }); }, + 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 = ( {