Compare commits
5 commits
backup/pre
...
local/yoda
| Author | SHA1 | Date | |
|---|---|---|---|
| f703bb15b7 | |||
| 6ea975935f | |||
| fbd052bc04 | |||
| 5d68aaf352 | |||
| 99219f90bc |
18 changed files with 683 additions and 13 deletions
79
Cargo.lock
generated
79
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -325,6 +325,48 @@ impl Api {
|
|||
Ok(Default::default())
|
||||
}
|
||||
|
||||
/// Delete all torrents, optionally keeping the downloaded data.
|
||||
pub async fn api_torrents_delete_all(
|
||||
&self,
|
||||
remove_files: bool,
|
||||
) -> Result<EmptyJsonResponse> {
|
||||
let ids: Vec<usize> = {
|
||||
let g = self.session.with_torrents(|torrents| {
|
||||
torrents.map(|(id, _)| id).collect()
|
||||
});
|
||||
g
|
||||
};
|
||||
|
||||
if ids.is_empty() {
|
||||
return Ok(Default::default());
|
||||
}
|
||||
|
||||
// Collect errors but continue deleting all torrents
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
for id in ids {
|
||||
if let Err(e) = self
|
||||
.session
|
||||
.delete(TorrentIdOrHash::Id(id), remove_files)
|
||||
.await
|
||||
{
|
||||
warn!(id, error=?e, "error deleting torrent");
|
||||
errors.push(format!("Torrent {}: {:?}", id, e));
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(ApiError::new_from_anyhow(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
anyhow::anyhow!(
|
||||
"Failed to delete some torrents:\n{}",
|
||||
errors.join("\n")
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Default::default())
|
||||
}
|
||||
|
||||
pub async fn api_torrent_action_update_only_files(
|
||||
&self,
|
||||
idx: TorrentIdOrHash,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -146,6 +146,25 @@ pub async fn h_torrent_action_delete(
|
|||
.map(axum::Json)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct DeleteAllQueryParams {
|
||||
/// Supprime aussi les donnees telechargees. Absent = false, on ne garde
|
||||
/// que la suppression des torrents.
|
||||
#[serde(default)]
|
||||
pub remove_files: bool,
|
||||
}
|
||||
|
||||
pub async fn h_torrents_delete_all(
|
||||
State(state): State<ApiState>,
|
||||
Query(params): Query<DeleteAllQueryParams>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
state
|
||||
.api
|
||||
.api_torrents_delete_all(params.remove_files)
|
||||
.await
|
||||
.map(axum::Json)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateOnlyFilesRequest {
|
||||
only_files: Vec<usize>,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface TorrentFileAttributes {
|
|||
export interface TorrentDetails {
|
||||
name: string | null;
|
||||
info_hash: string;
|
||||
output_folder?: string;
|
||||
files: Array<TorrentFile>;
|
||||
}
|
||||
|
||||
|
|
@ -201,5 +202,7 @@ export interface RqbitAPI {
|
|||
start: (index: number) => Promise<void>;
|
||||
forget: (index: number) => Promise<void>;
|
||||
delete: (index: number) => Promise<void>;
|
||||
deleteAll: (removeFiles?: boolean) => Promise<void>;
|
||||
openOutputFolder?: (path: string) => Promise<void>;
|
||||
stats: () => Promise<SessionStats>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { useState } from "react";
|
||||
import { TorrentsList } from "./TorrentsList";
|
||||
import { ErrorComponent } from "./ErrorComponent";
|
||||
import { useTorrentStore } from "../stores/torrentStore";
|
||||
import { useErrorStore } from "../stores/errorStore";
|
||||
import { IconButton } from "./buttons/IconButton";
|
||||
import { FiTrash2 } from "react-icons/fi";
|
||||
import { DeleteAllTorrentsModal } from "./modal/DeleteAllTorrentsModal";
|
||||
|
||||
export const RootContent = (props: {}) => {
|
||||
let closeableError = useErrorStore((state) => state.closeableError);
|
||||
|
|
@ -11,6 +15,7 @@ export const RootContent = (props: {}) => {
|
|||
let torrentsInitiallyLoading = useTorrentStore(
|
||||
(state) => state.torrentsInitiallyLoading
|
||||
);
|
||||
const [deleteAllOpened, setDeleteAllOpened] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
|
|
@ -19,7 +24,18 @@ export const RootContent = (props: {}) => {
|
|||
remove={() => setCloseableError(null)}
|
||||
/>
|
||||
<ErrorComponent error={otherError} />
|
||||
{torrents && torrents.length > 0 && (
|
||||
<div className="flex justify-end px-4 py-2">
|
||||
<IconButton onClick={() => setDeleteAllOpened(true)}>
|
||||
<FiTrash2 className="hover:text-red-500" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
<TorrentsList torrents={torrents} loading={torrentsInitiallyLoading} />
|
||||
<DeleteAllTorrentsModal
|
||||
show={deleteAllOpened}
|
||||
onHide={() => setDeleteAllOpened(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useContext, useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
TorrentDetails,
|
||||
TorrentId,
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { APIContext, RefreshTorrentStatsContext } from "../context";
|
||||
import { customSetInterval } from "../helper/customSetInterval";
|
||||
import { loopUntilSuccess } from "../helper/loopUntilSuccess";
|
||||
import { useTorrentStore } from "../stores/torrentStore";
|
||||
import { TorrentRow } from "./TorrentRow";
|
||||
|
||||
export const Torrent: React.FC<{
|
||||
|
|
@ -21,7 +22,13 @@ export const Torrent: React.FC<{
|
|||
null,
|
||||
);
|
||||
const [forceStatsRefresh, setForceStatsRefresh] = useState(0);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const API = useContext(APIContext);
|
||||
const focusedTorrentId = useTorrentStore((state) => state.focusedTorrentId);
|
||||
const clearFocusedTorrent = useTorrentStore(
|
||||
(state) => state.clearFocusedTorrent,
|
||||
);
|
||||
const isFocusedTorrent = focusedTorrentId === id;
|
||||
|
||||
const forceStatsRefreshCallback = () => {
|
||||
setForceStatsRefresh(forceStatsRefresh + 1);
|
||||
|
|
@ -65,15 +72,38 @@ export const Torrent: React.FC<{
|
|||
[forceStatsRefresh],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocusedTorrent) {
|
||||
return;
|
||||
}
|
||||
|
||||
rowRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => clearFocusedTorrent(id), 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [clearFocusedTorrent, id, isFocusedTorrent]);
|
||||
|
||||
return (
|
||||
<RefreshTorrentStatsContext.Provider
|
||||
value={{ refresh: forceStatsRefreshCallback }}
|
||||
>
|
||||
<TorrentRow
|
||||
id={id}
|
||||
detailsResponse={detailsResponse}
|
||||
statsResponse={statsResponse}
|
||||
/>
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={
|
||||
isFocusedTorrent
|
||||
? "rounded-xl ring-2 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-offset-gray-900"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TorrentRow
|
||||
id={id}
|
||||
detailsResponse={detailsResponse}
|
||||
statsResponse={statsResponse}
|
||||
/>
|
||||
</div>
|
||||
</RefreshTorrentStatsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ export const TorrentRow: React.FC<{
|
|||
<TorrentActions
|
||||
id={id}
|
||||
statsResponse={statsResponse}
|
||||
outputFolder={detailsResponse?.output_folder}
|
||||
extendedView={extendedView}
|
||||
setExtendedView={setExtendedView}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<boolean>(false);
|
||||
let [deleting, setDeleting] = useState<boolean>(false);
|
||||
|
||||
let refreshCtx = useContext(RefreshTorrentStatsContext);
|
||||
const API = useContext(APIContext);
|
||||
|
||||
const canPause = state == "live";
|
||||
const canUnpause = state == "paused" || state == "error";
|
||||
const canConfigure = state == "paused" || state == "live";
|
||||
const canOpenOutputFolder =
|
||||
statsResponse.finished && !!outputFolder && !!API.openOutputFolder;
|
||||
|
||||
const setCloseableError = useErrorStore((state) => state.setCloseableError);
|
||||
|
||||
const API = useContext(APIContext);
|
||||
|
||||
const 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 (
|
||||
<div className="flex w-full justify-center gap-2 dark:text-slate-300">
|
||||
{canUnpause && (
|
||||
|
|
@ -136,6 +152,11 @@ export const TorrentActions: React.FC<{
|
|||
<FaCog className="hover:text-green-600" />
|
||||
</IconButton>
|
||||
)}
|
||||
{canOpenOutputFolder && (
|
||||
<IconButton onClick={openOutputFolder} disabled={disabled}>
|
||||
<FaFolderOpen className="hover:text-green-600" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={startDeleting} disabled={disabled}>
|
||||
<FaTrash className="hover:text-red-500" />
|
||||
</IconButton>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import { useContext, useState } from "react";
|
||||
import { APIContext } from "../../context";
|
||||
import { ErrorWithLabel } from "../../rqbit-web";
|
||||
import { ErrorComponent } from "../ErrorComponent";
|
||||
import { Spinner } from "../Spinner";
|
||||
import { Modal } from "./Modal";
|
||||
import { ModalBody } from "./ModalBody";
|
||||
import { ModalFooter } from "./ModalFooter";
|
||||
import { Button } from "../buttons/Button";
|
||||
import { useTorrentStore } from "../../stores/torrentStore";
|
||||
|
||||
export const DeleteAllTorrentsModal: React.FC<{
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
}> = ({ show, onHide }) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||
const [error, setError] = useState<ErrorWithLabel | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const API = useContext(APIContext);
|
||||
const refreshTorrents = useTorrentStore((state) => state.refreshTorrents);
|
||||
|
||||
const close = () => {
|
||||
setDeleteFiles(false);
|
||||
setError(null);
|
||||
setDeleting(false);
|
||||
onHide();
|
||||
};
|
||||
|
||||
const deleteAllTorrents = () => {
|
||||
setDeleting(true);
|
||||
|
||||
API.deleteAll(deleteFiles)
|
||||
.then(() => {
|
||||
refreshTorrents();
|
||||
close();
|
||||
})
|
||||
.catch((e) => {
|
||||
setError({
|
||||
text: "Error deleting all torrents",
|
||||
details: e,
|
||||
});
|
||||
setDeleting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={show} onClose={onHide} title="Delete all torrents">
|
||||
<ModalBody>
|
||||
<p className="text-gray-700 dark:text-slate-300">
|
||||
Are you sure you want to delete <strong>all</strong> torrents? This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="deleteFilesAll"
|
||||
className="form-checkbox h-4 w-4 text-blue-500"
|
||||
onChange={() => setDeleteFiles(!deleteFiles)}
|
||||
checked={deleteFiles}
|
||||
placeholder="Also delete files"
|
||||
/>
|
||||
<label
|
||||
htmlFor="deleteFilesAll"
|
||||
className="ml-2 text-gray-700 dark:text-slate-300"
|
||||
>
|
||||
Also delete downloaded files
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <ErrorComponent error={error} />}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{deleting && <Spinner />}
|
||||
<Button variant="cancel" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={deleteAllTorrents} disabled={deleting}>
|
||||
Delete All
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -29,6 +29,9 @@ export const APIContext = createContext<RqbitAPI>({
|
|||
delete: () => {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
deleteAll: () => {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
getTorrentStreamUrl: () => {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -151,6 +151,13 @@ export const API: RqbitAPI & { getVersion: () => Promise<string> } = {
|
|||
delete: (index: number): Promise<void> => {
|
||||
return makeRequest("POST", `/torrents/${index}/delete`);
|
||||
},
|
||||
deleteAll: (removeFiles: boolean = false): Promise<void> => {
|
||||
let url = "/torrents/delete_all";
|
||||
if (removeFiles) {
|
||||
url += "?remove_files=true";
|
||||
}
|
||||
return makeRequest("POST", url);
|
||||
},
|
||||
getVersion: async (): Promise<string> => {
|
||||
const r = await makeRequest("GET", "/");
|
||||
return r.version;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ export interface TorrentStore {
|
|||
|
||||
refreshTorrents: () => void;
|
||||
setRefreshTorrents: (callback: () => void) => void;
|
||||
|
||||
focusedTorrentId: number | null;
|
||||
focusTorrent: (id: number) => void;
|
||||
clearFocusedTorrent: (id: number) => void;
|
||||
}
|
||||
|
||||
const torrentIdEquals = (t1: TorrentId, t2: TorrentId): boolean => {
|
||||
|
|
@ -51,4 +55,13 @@ export const useTorrentStore = create<TorrentStore>((set) => ({
|
|||
}),
|
||||
refreshTorrents: () => {},
|
||||
setRefreshTorrents: (callback) => set({ refreshTorrents: callback }),
|
||||
focusedTorrentId: null,
|
||||
focusTorrent: (id) => set({ focusedTorrentId: id }),
|
||||
clearFocusedTorrent: (id) =>
|
||||
set((prev) => {
|
||||
if (prev.focusedTorrentId !== id) {
|
||||
return {};
|
||||
}
|
||||
return { focusedTorrentId: null };
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -922,3 +922,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
|
|
|||
|
|
@ -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<RwLock<Option<StateShared>>>,
|
||||
pending_launch_inputs: Arc<RwLock<Vec<String>>>,
|
||||
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<String> {
|
||||
env::args()
|
||||
.skip(1)
|
||||
.filter(|arg| is_torrent_launch_input(arg))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn desktop_ipc_socket_path() -> PathBuf {
|
||||
let runtime_dir = env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(env::temp_dir);
|
||||
let user = env::var("USER").unwrap_or_else(|_| "user".to_string());
|
||||
runtime_dir.join(format!("rqbit-desktop-{user}.sock"))
|
||||
}
|
||||
|
||||
fn forward_inputs_to_existing(socket_path: &Path, inputs: &[String]) -> anyhow::Result<()> {
|
||||
let mut stream = UnixStream::connect(socket_path)?;
|
||||
let payload = serde_json::to_vec(inputs)?;
|
||||
stream.write_all(&payload)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_ipc_listener(socket_path: &Path) -> std::io::Result<UnixListener> {
|
||||
if UnixStream::connect(socket_path).is_err() {
|
||||
let _ = std::fs::remove_file(socket_path);
|
||||
}
|
||||
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
UnixListener::bind(socket_path)
|
||||
}
|
||||
|
||||
fn spawn_ipc_listener(
|
||||
socket_path: PathBuf,
|
||||
shared: Arc<RwLock<Option<StateShared>>>,
|
||||
pending: Arc<RwLock<Vec<String>>>,
|
||||
app_handle: AppHandle,
|
||||
) {
|
||||
let listener = match bind_ipc_listener(&socket_path) {
|
||||
Ok(listener) => listener,
|
||||
Err(err) => {
|
||||
warn!(path=%socket_path.display(), error=%err, "couldn't bind desktop IPC socket");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let shared = shared.clone();
|
||||
let pending = pending.clone();
|
||||
let app_handle = app_handle.clone();
|
||||
match stream {
|
||||
Ok(mut stream) => {
|
||||
let mut payload = String::new();
|
||||
if let Err(err) = stream.read_to_string(&mut payload) {
|
||||
warn!(error=%err, "couldn't read desktop IPC payload");
|
||||
continue;
|
||||
}
|
||||
|
||||
let inputs = match serde_json::from_str::<Vec<String>>(&payload) {
|
||||
Ok(inputs) => inputs,
|
||||
Err(err) => {
|
||||
warn!(error=%err, "couldn't parse desktop IPC payload");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
handle.spawn(async move {
|
||||
add_launch_inputs(shared, pending, inputs, Some(app_handle)).await;
|
||||
});
|
||||
}
|
||||
Err(err) => warn!(error=%err, "desktop IPC accept failed"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn add_launch_inputs(
|
||||
shared: Arc<RwLock<Option<StateShared>>>,
|
||||
pending: Arc<RwLock<Vec<String>>>,
|
||||
inputs: Vec<String>,
|
||||
app_handle: Option<AppHandle>,
|
||||
) {
|
||||
for input in inputs {
|
||||
match add_launch_input(shared.clone(), pending.clone(), &input).await {
|
||||
Ok(Some(id)) => {
|
||||
if let Some(app_handle) = &app_handle {
|
||||
if let Err(err) =
|
||||
app_handle.emit(TORRENTS_CHANGED_EVENT, TorrentsChangedPayload { id })
|
||||
{
|
||||
warn!(input=%input, error=%err, "couldn't emit desktop torrent change event");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(input=%input, error=%err, "couldn't add torrent from desktop launch input");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_launch_input(
|
||||
shared: Arc<RwLock<Option<StateShared>>>,
|
||||
pending: Arc<RwLock<Vec<String>>>,
|
||||
input: &str,
|
||||
) -> anyhow::Result<Option<usize>> {
|
||||
let api = {
|
||||
let g = shared.read();
|
||||
g.as_ref().and_then(|state| state.api.as_ref()).cloned()
|
||||
};
|
||||
let Some(api) = api else {
|
||||
pending.write().push(input.to_owned());
|
||||
info!(input=%input, "queued desktop launch input until rqbit is configured");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let opts = AddTorrentOptions {
|
||||
overwrite: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let torrent = if input.starts_with("magnet:")
|
||||
|| input.starts_with("http://")
|
||||
|| input.starts_with("https://")
|
||||
{
|
||||
AddTorrent::Url(input.to_string().into())
|
||||
} else {
|
||||
let path = path_from_launch_input(input)?;
|
||||
let bytes = std::fs::read(&path)
|
||||
.with_context(|| format!("couldn't read torrent file {}", path.display()))?;
|
||||
AddTorrent::TorrentFileBytes(bytes.into())
|
||||
};
|
||||
|
||||
api.api_add_torrent(torrent, Some(opts))
|
||||
.await
|
||||
.map(|response| response.id)
|
||||
.map_err(|err| anyhow::anyhow!("{err:?}"))
|
||||
}
|
||||
|
||||
async fn drain_pending_launch_inputs(
|
||||
shared: Arc<RwLock<Option<StateShared>>>,
|
||||
pending: Arc<RwLock<Vec<String>>>,
|
||||
app_handle: Option<AppHandle>,
|
||||
) {
|
||||
let inputs = {
|
||||
let mut pending = pending.write();
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
pending.drain(..).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
add_launch_inputs(shared, pending, inputs, app_handle).await;
|
||||
}
|
||||
|
||||
fn path_from_launch_input(input: &str) -> anyhow::Result<PathBuf> {
|
||||
if let Some(uri_path) = input.strip_prefix("file://localhost/") {
|
||||
return Ok(PathBuf::from(format!("/{}", percent_decode(uri_path)?)));
|
||||
}
|
||||
|
||||
if let Some(uri_path) = input.strip_prefix("file://") {
|
||||
return Ok(PathBuf::from(percent_decode(uri_path)?));
|
||||
}
|
||||
|
||||
Ok(PathBuf::from(input))
|
||||
}
|
||||
|
||||
fn percent_decode(value: &str) -> anyhow::Result<String> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'%' {
|
||||
let Some(hex) = bytes.get(index + 1..index + 3) else {
|
||||
anyhow::bail!("invalid percent-encoded path");
|
||||
};
|
||||
let hex = std::str::from_utf8(hex).context("invalid percent-encoded path")?;
|
||||
decoded.push(u8::from_str_radix(hex, 16).context("invalid percent-encoded path")?);
|
||||
index += 3;
|
||||
} else {
|
||||
decoded.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8(decoded).context("invalid UTF-8 path")
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
struct CurrentState {
|
||||
config: Option<RqbitDesktopConfig>,
|
||||
|
|
@ -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<EmptyJsonResponse, ApiError> {
|
||||
state.configure(config).await.map(|_| EmptyJsonResponse {})
|
||||
state.configure(config).await?;
|
||||
drain_pending_launch_inputs(
|
||||
state.shared.clone(),
|
||||
state.pending_launch_inputs.clone(),
|
||||
Some(app_handle),
|
||||
)
|
||||
.await;
|
||||
Ok(EmptyJsonResponse {})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -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<EmptyJsonResponse, ApiError> {
|
||||
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<SessionStatsSnapshot, A
|
|||
Ok(state.api()?.api_session_stats())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_output_folder(path: String) -> Result<EmptyJsonResponse, ApiError> {
|
||||
let path = PathBuf::from(path);
|
||||
let metadata = std::fs::metadata(&path)
|
||||
.with_context(|| format!("couldn't access output folder {}", path.display()))
|
||||
.map_err(|e| ApiError::new_from_anyhow(StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -133,6 +133,12 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => {
|
|||
delete: function (id: number): Promise<void> {
|
||||
return invokeAPI<void>("torrent_action_delete", { id });
|
||||
},
|
||||
deleteAll: function (removeFiles: boolean = false): Promise<void> {
|
||||
return invokeAPI<void>("torrents_delete_all", { removeFiles });
|
||||
},
|
||||
openOutputFolder: function (path: string): Promise<void> {
|
||||
return invokeAPI<void>("open_output_folder", { path });
|
||||
},
|
||||
getTorrentStreamUrl: () => {
|
||||
return "";
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configured) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const unlisten = listen<TorrentsChangedPayload>(TORRENTS_CHANGED_EVENT, (event) => {
|
||||
if (event.payload.id != null) {
|
||||
useTorrentStore.getState().focusTorrent(event.payload.id);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
useTorrentStore.getState().refreshTorrents();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten.then((cleanup) => cleanup());
|
||||
};
|
||||
}, [configured]);
|
||||
|
||||
const configButton = (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue