Compare commits
No commits in common. "5d68aaf35220fecde955a363b420a2691519ce05" and "00b97485160ff5b5aa2b379ea0815d568ec665f0" have entirely different histories.
5d68aaf352
...
00b9748516
14 changed files with 13 additions and 638 deletions
60
Cargo.lock
generated
60
Cargo.lock
generated
|
|
@ -7409,63 +7409,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"
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export interface TorrentFileAttributes {
|
|||
export interface TorrentDetails {
|
||||
name: string | null;
|
||||
info_hash: string;
|
||||
output_folder?: string;
|
||||
files: Array<TorrentFile>;
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +201,5 @@ 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,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 (
|
||||
<div className="container mx-auto">
|
||||
|
|
@ -24,18 +19,7 @@ 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, 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<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);
|
||||
|
|
@ -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 (
|
||||
<RefreshTorrentStatsContext.Provider
|
||||
value={{ refresh: forceStatsRefreshCallback }}
|
||||
>
|
||||
<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>
|
||||
<TorrentRow
|
||||
id={id}
|
||||
detailsResponse={detailsResponse}
|
||||
statsResponse={statsResponse}
|
||||
/>
|
||||
</RefreshTorrentStatsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -159,7 +159,6 @@ export const TorrentRow: React.FC<{
|
|||
<TorrentActions
|
||||
id={id}
|
||||
statsResponse={statsResponse}
|
||||
outputFolder={detailsResponse?.output_folder}
|
||||
extendedView={extendedView}
|
||||
setExtendedView={setExtendedView}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { IconButton } from "./IconButton";
|
|||
import { DeleteTorrentModal } from "../modal/DeleteTorrentModal";
|
||||
import {
|
||||
FaCog,
|
||||
FaFolderOpen,
|
||||
FaPause,
|
||||
FaPlay,
|
||||
FaTrash,
|
||||
|
|
@ -17,26 +16,24 @@ 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<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)
|
||||
|
|
@ -119,19 +116,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 (
|
||||
<div className="flex w-full justify-center gap-2 dark:text-slate-300">
|
||||
{canUnpause && (
|
||||
|
|
@ -152,11 +136,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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<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,9 +29,6 @@ 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,13 +151,6 @@ 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,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<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 };
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -4,14 +4,10 @@
|
|||
mod config;
|
||||
|
||||
use std::{
|
||||
env,
|
||||
fs::{File, OpenOptions},
|
||||
io::{BufReader, BufWriter, Read, Write},
|
||||
os::unix::net::{UnixListener, UnixStream},
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
io::{BufReader, BufWriter},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
thread,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
|
|
@ -30,17 +26,10 @@ 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,
|
||||
|
|
@ -50,7 +39,6 @@ struct StateShared {
|
|||
struct State {
|
||||
config_filename: String,
|
||||
shared: Arc<RwLock<Option<StateShared>>>,
|
||||
pending_launch_inputs: Arc<RwLock<Vec<String>>>,
|
||||
init_logging: InitLoggingResult,
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +194,6 @@ impl State {
|
|||
return Self {
|
||||
config_filename,
|
||||
shared,
|
||||
pending_launch_inputs: Arc::new(RwLock::new(Vec::new())),
|
||||
init_logging,
|
||||
};
|
||||
}
|
||||
|
|
@ -215,7 +202,6 @@ impl State {
|
|||
config_filename,
|
||||
init_logging,
|
||||
shared: Arc::new(RwLock::new(None)),
|
||||
pending_launch_inputs: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,211 +244,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<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>,
|
||||
|
|
@ -488,18 +269,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<EmptyJsonResponse, ApiError> {
|
||||
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]
|
||||
|
|
@ -560,17 +333,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<EmptyJsonResponse, ApiError> {
|
||||
state
|
||||
.api()?
|
||||
.api_torrents_delete_all(remove_files)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn torrent_action_pause(
|
||||
state: tauri::State<'_, State>,
|
||||
|
|
@ -612,37 +374,6 @@ 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")
|
||||
|
|
@ -662,54 +393,22 @@ 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,12 +133,6 @@ 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,5 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useState } from "react";
|
||||
import { RqbitWebUI } from "rqbit-webui/src/rqbit-web";
|
||||
import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration";
|
||||
import { ConfigModal } from "./configure";
|
||||
|
|
@ -7,13 +6,6 @@ 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;
|
||||
|
|
@ -26,31 +18,6 @@ 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