Compare commits
1 commit
local/yoda
...
backup/pre
| Author | SHA1 | Date | |
|---|---|---|---|
| a1d4aab93f |
22 changed files with 709 additions and 680 deletions
80
Cargo.lock
generated
80
Cargo.lock
generated
|
|
@ -2781,15 +2781,6 @@ dependencies = [
|
||||||
"windows-targets 0.53.0",
|
"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]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
|
|
@ -3186,15 +3177,6 @@ dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mimalloc"
|
|
||||||
version = "0.1.52"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
|
||||||
dependencies = [
|
|
||||||
"libmimalloc-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
|
|
@ -4636,7 +4618,6 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
"librqbit-upnp-serve",
|
"librqbit-upnp-serve",
|
||||||
"mimalloc",
|
|
||||||
"openssl",
|
"openssl",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"parse_duration",
|
"parse_duration",
|
||||||
|
|
@ -4660,6 +4641,7 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"directories 5.0.1",
|
"directories 5.0.1",
|
||||||
"gethostname 0.5.0",
|
"gethostname 0.5.0",
|
||||||
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
|
@ -7428,63 +7410,3 @@ dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.101",
|
"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())
|
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(
|
pub async fn api_torrent_action_update_only_files(
|
||||||
&self,
|
&self,
|
||||||
idx: TorrentIdOrHash,
|
idx: TorrentIdOrHash,
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,6 @@ pub fn make_api_router(state: ApiState) -> Router {
|
||||||
"/torrents/{id}/delete",
|
"/torrents/{id}/delete",
|
||||||
post(torrents::h_torrent_action_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(
|
.route(
|
||||||
"/torrents/{id}/update_only_files",
|
"/torrents/{id}/update_only_files",
|
||||||
post(torrents::h_torrent_action_update_only_files),
|
post(torrents::h_torrent_action_update_only_files),
|
||||||
|
|
|
||||||
|
|
@ -146,25 +146,6 @@ pub async fn h_torrent_action_delete(
|
||||||
.map(axum::Json)
|
.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)]
|
#[derive(Deserialize)]
|
||||||
pub struct UpdateOnlyFilesRequest {
|
pub struct UpdateOnlyFilesRequest {
|
||||||
only_files: Vec<usize>,
|
only_files: Vec<usize>,
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,30 @@ export interface TorrentFileAttributes {
|
||||||
export interface TorrentDetails {
|
export interface TorrentDetails {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
info_hash: string;
|
info_hash: string;
|
||||||
output_folder?: string;
|
output_folder: string;
|
||||||
files: Array<TorrentFile>;
|
files: Array<TorrentFile>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalTorrentFile {
|
||||||
|
kind: "local-file";
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TorrentInput = string | File | LocalTorrentFile;
|
||||||
|
|
||||||
|
export function localTorrentFile(path: string): LocalTorrentFile {
|
||||||
|
return {
|
||||||
|
kind: "local-file",
|
||||||
|
path,
|
||||||
|
name: path.split(/[\\/]/).pop() || path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalTorrentFile(data: TorrentInput): data is LocalTorrentFile {
|
||||||
|
return typeof data === "object" && !(data instanceof File) && data.kind === "local-file";
|
||||||
|
}
|
||||||
|
|
||||||
export interface AddTorrentResponse {
|
export interface AddTorrentResponse {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
details: TorrentDetails;
|
details: TorrentDetails;
|
||||||
|
|
@ -193,16 +213,15 @@ export interface RqbitAPI {
|
||||||
filename?: string | null,
|
filename?: string | null,
|
||||||
) => string | null;
|
) => string | null;
|
||||||
uploadTorrent: (
|
uploadTorrent: (
|
||||||
data: string | File,
|
data: TorrentInput,
|
||||||
opts?: AddTorrentOptions,
|
opts?: AddTorrentOptions,
|
||||||
) => Promise<AddTorrentResponse>;
|
) => Promise<AddTorrentResponse>;
|
||||||
|
openTorrentOutput?: (index: number) => Promise<void>;
|
||||||
|
|
||||||
pause: (index: number) => Promise<void>;
|
pause: (index: number) => Promise<void>;
|
||||||
updateOnlyFiles: (index: number, files: number[]) => Promise<void>;
|
updateOnlyFiles: (index: number, files: number[]) => Promise<void>;
|
||||||
start: (index: number) => Promise<void>;
|
start: (index: number) => Promise<void>;
|
||||||
forget: (index: number) => Promise<void>;
|
forget: (index: number) => Promise<void>;
|
||||||
delete: (index: number) => Promise<void>;
|
delete: (index: number) => Promise<void>;
|
||||||
deleteAll: (removeFiles?: boolean) => Promise<void>;
|
|
||||||
openOutputFolder?: (path: string) => Promise<void>;
|
|
||||||
stats: () => Promise<SessionStats>;
|
stats: () => Promise<SessionStats>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { TorrentsList } from "./TorrentsList";
|
import { TorrentsList } from "./TorrentsList";
|
||||||
import { ErrorComponent } from "./ErrorComponent";
|
import { ErrorComponent } from "./ErrorComponent";
|
||||||
import { useTorrentStore } from "../stores/torrentStore";
|
import { useTorrentStore } from "../stores/torrentStore";
|
||||||
import { useErrorStore } from "../stores/errorStore";
|
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: {}) => {
|
export const RootContent = (props: {}) => {
|
||||||
let closeableError = useErrorStore((state) => state.closeableError);
|
let closeableError = useErrorStore((state) => state.closeableError);
|
||||||
|
|
@ -15,7 +11,6 @@ export const RootContent = (props: {}) => {
|
||||||
let torrentsInitiallyLoading = useTorrentStore(
|
let torrentsInitiallyLoading = useTorrentStore(
|
||||||
(state) => state.torrentsInitiallyLoading
|
(state) => state.torrentsInitiallyLoading
|
||||||
);
|
);
|
||||||
const [deleteAllOpened, setDeleteAllOpened] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
|
|
@ -24,18 +19,7 @@ export const RootContent = (props: {}) => {
|
||||||
remove={() => setCloseableError(null)}
|
remove={() => setCloseableError(null)}
|
||||||
/>
|
/>
|
||||||
<ErrorComponent error={otherError} />
|
<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} />
|
<TorrentsList torrents={torrents} loading={torrentsInitiallyLoading} />
|
||||||
<DeleteAllTorrentsModal
|
|
||||||
show={deleteAllOpened}
|
|
||||||
onHide={() => setDeleteAllOpened(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useContext, useEffect, useRef, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
TorrentDetails,
|
TorrentDetails,
|
||||||
TorrentId,
|
TorrentId,
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
import { APIContext, RefreshTorrentStatsContext } from "../context";
|
import { APIContext, RefreshTorrentStatsContext } from "../context";
|
||||||
import { customSetInterval } from "../helper/customSetInterval";
|
import { customSetInterval } from "../helper/customSetInterval";
|
||||||
import { loopUntilSuccess } from "../helper/loopUntilSuccess";
|
import { loopUntilSuccess } from "../helper/loopUntilSuccess";
|
||||||
import { useTorrentStore } from "../stores/torrentStore";
|
|
||||||
import { TorrentRow } from "./TorrentRow";
|
import { TorrentRow } from "./TorrentRow";
|
||||||
|
|
||||||
export const Torrent: React.FC<{
|
export const Torrent: React.FC<{
|
||||||
|
|
@ -22,13 +21,7 @@ export const Torrent: React.FC<{
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [forceStatsRefresh, setForceStatsRefresh] = useState(0);
|
const [forceStatsRefresh, setForceStatsRefresh] = useState(0);
|
||||||
const rowRef = useRef<HTMLDivElement>(null);
|
|
||||||
const API = useContext(APIContext);
|
const API = useContext(APIContext);
|
||||||
const focusedTorrentId = useTorrentStore((state) => state.focusedTorrentId);
|
|
||||||
const clearFocusedTorrent = useTorrentStore(
|
|
||||||
(state) => state.clearFocusedTorrent,
|
|
||||||
);
|
|
||||||
const isFocusedTorrent = focusedTorrentId === id;
|
|
||||||
|
|
||||||
const forceStatsRefreshCallback = () => {
|
const forceStatsRefreshCallback = () => {
|
||||||
setForceStatsRefresh(forceStatsRefresh + 1);
|
setForceStatsRefresh(forceStatsRefresh + 1);
|
||||||
|
|
@ -72,38 +65,15 @@ export const Torrent: React.FC<{
|
||||||
[forceStatsRefresh],
|
[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 (
|
return (
|
||||||
<RefreshTorrentStatsContext.Provider
|
<RefreshTorrentStatsContext.Provider
|
||||||
value={{ refresh: forceStatsRefreshCallback }}
|
value={{ refresh: forceStatsRefreshCallback }}
|
||||||
>
|
>
|
||||||
<div
|
<TorrentRow
|
||||||
ref={rowRef}
|
id={id}
|
||||||
className={
|
detailsResponse={detailsResponse}
|
||||||
isFocusedTorrent
|
statsResponse={statsResponse}
|
||||||
? "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>
|
</RefreshTorrentStatsContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -93,9 +93,82 @@ export const TorrentRow: React.FC<{
|
||||||
};
|
};
|
||||||
|
|
||||||
const [extendedView, setExtendedView] = useState(false);
|
const [extendedView, setExtendedView] = useState(false);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contextMenu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => setContextMenu(null);
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setContextMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("click", close);
|
||||||
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("click", close);
|
||||||
|
window.removeEventListener("keydown", closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [contextMenu]);
|
||||||
|
|
||||||
|
const openOutput = () => {
|
||||||
|
if (!API.openTorrentOutput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
API.openTorrentOutput(id).catch((e) => {
|
||||||
|
setCloseableError({
|
||||||
|
text: `Error opening torrent output id=${id}`,
|
||||||
|
details: e as ErrorDetails,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (event: React.MouseEvent) => {
|
||||||
|
if (!API.openTorrentOutput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
setContextMenu({ x: event.clientX, y: event.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col border p-2 border-gray-200 rounded-xl shadow-xs hover:drop-shadow-sm dark:bg-slate-800 dark:border-slate-900">
|
<div
|
||||||
|
className="flex flex-col border p-2 border-gray-200 rounded-xl shadow-xs hover:drop-shadow-sm dark:bg-slate-800 dark:border-slate-900"
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
>
|
||||||
|
{contextMenu && (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 min-w-52 overflow-hidden rounded-md border border-gray-200 bg-white py-1 text-sm shadow-lg dark:border-slate-700 dark:bg-slate-800"
|
||||||
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="block w-full px-3 py-2 text-left text-gray-800 hover:bg-gray-100 dark:text-slate-100 dark:hover:bg-slate-700"
|
||||||
|
onClick={openOutput}
|
||||||
|
>
|
||||||
|
Open download folder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="block w-full px-3 py-2 text-left text-gray-800 hover:bg-gray-100 dark:text-slate-100 dark:hover:bg-slate-700"
|
||||||
|
onClick={() => {
|
||||||
|
setExtendedView(true);
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show files
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<section className="flex flex-col lg:flex-row items-center gap-2">
|
<section className="flex flex-col lg:flex-row items-center gap-2">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="hidden md:block">{statusIcon("w-10 h-10")}</div>
|
<div className="hidden md:block">{statusIcon("w-10 h-10")}</div>
|
||||||
|
|
@ -159,7 +232,6 @@ export const TorrentRow: React.FC<{
|
||||||
<TorrentActions
|
<TorrentActions
|
||||||
id={id}
|
id={id}
|
||||||
statsResponse={statsResponse}
|
statsResponse={statsResponse}
|
||||||
outputFolder={detailsResponse?.output_folder}
|
|
||||||
extendedView={extendedView}
|
extendedView={extendedView}
|
||||||
setExtendedView={setExtendedView}
|
setExtendedView={setExtendedView}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import { IconButton } from "./IconButton";
|
||||||
import { DeleteTorrentModal } from "../modal/DeleteTorrentModal";
|
import { DeleteTorrentModal } from "../modal/DeleteTorrentModal";
|
||||||
import {
|
import {
|
||||||
FaCog,
|
FaCog,
|
||||||
FaFolderOpen,
|
|
||||||
FaPause,
|
FaPause,
|
||||||
FaPlay,
|
FaPlay,
|
||||||
FaTrash,
|
FaTrash,
|
||||||
FaClipboardList,
|
FaClipboardList,
|
||||||
|
FaFolderOpen,
|
||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
import { useErrorStore } from "../../stores/errorStore";
|
import { useErrorStore } from "../../stores/errorStore";
|
||||||
import { ErrorComponent } from "../ErrorComponent";
|
import { ErrorComponent } from "../ErrorComponent";
|
||||||
|
|
@ -17,26 +17,40 @@ import { ErrorComponent } from "../ErrorComponent";
|
||||||
export const TorrentActions: React.FC<{
|
export const TorrentActions: React.FC<{
|
||||||
id: number;
|
id: number;
|
||||||
statsResponse: TorrentStats;
|
statsResponse: TorrentStats;
|
||||||
outputFolder?: string;
|
|
||||||
extendedView: boolean;
|
extendedView: boolean;
|
||||||
setExtendedView: (extendedView: boolean) => void;
|
setExtendedView: (extendedView: boolean) => void;
|
||||||
}> = ({ id, statsResponse, outputFolder, extendedView, setExtendedView }) => {
|
}> = ({ id, statsResponse, extendedView, setExtendedView }) => {
|
||||||
let state = statsResponse.state;
|
let state = statsResponse.state;
|
||||||
|
|
||||||
let [disabled, setDisabled] = useState<boolean>(false);
|
let [disabled, setDisabled] = useState<boolean>(false);
|
||||||
let [deleting, setDeleting] = useState<boolean>(false);
|
let [deleting, setDeleting] = useState<boolean>(false);
|
||||||
|
|
||||||
let refreshCtx = useContext(RefreshTorrentStatsContext);
|
let refreshCtx = useContext(RefreshTorrentStatsContext);
|
||||||
const API = useContext(APIContext);
|
|
||||||
|
|
||||||
const canPause = state == "live";
|
const canPause = state == "live";
|
||||||
const canUnpause = state == "paused" || state == "error";
|
const canUnpause = state == "paused" || state == "error";
|
||||||
const canConfigure = state == "paused" || state == "live";
|
const canConfigure = state == "paused" || state == "live";
|
||||||
const canOpenOutputFolder =
|
|
||||||
statsResponse.finished && !!outputFolder && !!API.openOutputFolder;
|
|
||||||
|
|
||||||
const setCloseableError = useErrorStore((state) => state.setCloseableError);
|
const setCloseableError = useErrorStore((state) => state.setCloseableError);
|
||||||
|
|
||||||
|
const API = useContext(APIContext);
|
||||||
|
|
||||||
|
const openOutput = () => {
|
||||||
|
if (!API.openTorrentOutput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabled(true);
|
||||||
|
API.openTorrentOutput(id)
|
||||||
|
.catch((e) => {
|
||||||
|
setCloseableError({
|
||||||
|
text: `Error opening torrent output id=${id}`,
|
||||||
|
details: e,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => setDisabled(false));
|
||||||
|
};
|
||||||
|
|
||||||
const unpause = () => {
|
const unpause = () => {
|
||||||
setDisabled(true);
|
setDisabled(true);
|
||||||
API.start(id)
|
API.start(id)
|
||||||
|
|
@ -119,19 +133,6 @@ export const TorrentActions: React.FC<{
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openOutputFolder = () => {
|
|
||||||
if (!API.openOutputFolder || !outputFolder) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
API.openOutputFolder(outputFolder).catch((e) => {
|
|
||||||
setCloseableError({
|
|
||||||
text: `Error opening output folder for torrent id=${id}`,
|
|
||||||
details: e,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full justify-center gap-2 dark:text-slate-300">
|
<div className="flex w-full justify-center gap-2 dark:text-slate-300">
|
||||||
{canUnpause && (
|
{canUnpause && (
|
||||||
|
|
@ -152,9 +153,9 @@ export const TorrentActions: React.FC<{
|
||||||
<FaCog className="hover:text-green-600" />
|
<FaCog className="hover:text-green-600" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{canOpenOutputFolder && (
|
{API.openTorrentOutput && (
|
||||||
<IconButton onClick={openOutputFolder} disabled={disabled}>
|
<IconButton onClick={openOutput} disabled={disabled}>
|
||||||
<FaFolderOpen className="hover:text-green-600" />
|
<FaFolderOpen className="hover:text-blue-500" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<IconButton onClick={startDeleting} disabled={disabled}>
|
<IconButton onClick={startDeleting} disabled={disabled}>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { ReactNode, useContext, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AddTorrentResponse,
|
AddTorrentResponse,
|
||||||
ErrorDetails as ApiErrorDetails,
|
ErrorDetails as ApiErrorDetails,
|
||||||
|
TorrentInput,
|
||||||
} from "../../api-types";
|
} from "../../api-types";
|
||||||
import { APIContext } from "../../context";
|
import { APIContext } from "../../context";
|
||||||
import { ErrorWithLabel } from "../../rqbit-web";
|
import { ErrorWithLabel } from "../../rqbit-web";
|
||||||
|
|
@ -10,7 +11,7 @@ import { Button } from "./Button";
|
||||||
|
|
||||||
export const UploadButton: React.FC<{
|
export const UploadButton: React.FC<{
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
data: string | File | null;
|
data: TorrentInput | null;
|
||||||
resetData: () => void;
|
resetData: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { useContext, useEffect, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { AddTorrentResponse, AddTorrentOptions } from "../../api-types";
|
import {
|
||||||
|
AddTorrentResponse,
|
||||||
|
AddTorrentOptions,
|
||||||
|
TorrentInput,
|
||||||
|
} from "../../api-types";
|
||||||
import { APIContext } from "../../context";
|
import { APIContext } from "../../context";
|
||||||
import { ErrorComponent } from "../ErrorComponent";
|
import { ErrorComponent } from "../ErrorComponent";
|
||||||
import { ErrorWithLabel } from "../../rqbit-web";
|
import { ErrorWithLabel } from "../../rqbit-web";
|
||||||
|
|
@ -19,7 +23,7 @@ export const FileSelectionModal = (props: {
|
||||||
listTorrentResponse: AddTorrentResponse | null;
|
listTorrentResponse: AddTorrentResponse | null;
|
||||||
listTorrentError: ErrorWithLabel | null;
|
listTorrentError: ErrorWithLabel | null;
|
||||||
listTorrentLoading: boolean;
|
listTorrentLoading: boolean;
|
||||||
data: string | File;
|
data: TorrentInput;
|
||||||
}) => {
|
}) => {
|
||||||
let {
|
let {
|
||||||
onHide,
|
onHide,
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,6 @@ export const APIContext = createContext<RqbitAPI>({
|
||||||
delete: () => {
|
delete: () => {
|
||||||
throw new Error("Function not implemented.");
|
throw new Error("Function not implemented.");
|
||||||
},
|
},
|
||||||
deleteAll: () => {
|
|
||||||
throw new Error("Function not implemented.");
|
|
||||||
},
|
|
||||||
getTorrentStreamUrl: () => {
|
getTorrentStreamUrl: () => {
|
||||||
throw new Error("Function not implemented.");
|
throw new Error("Function not implemented.");
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
SessionStats,
|
SessionStats,
|
||||||
TorrentDetails,
|
TorrentDetails,
|
||||||
TorrentStats,
|
TorrentStats,
|
||||||
|
isLocalTorrentFile,
|
||||||
} from "./api-types";
|
} from "./api-types";
|
||||||
|
|
||||||
// Define API URL and base path
|
// Define API URL and base path
|
||||||
|
|
@ -99,6 +100,12 @@ export const API: RqbitAPI & { getVersion: () => Promise<string> } = {
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadTorrent: (data, opts): Promise<AddTorrentResponse> => {
|
uploadTorrent: (data, opts): Promise<AddTorrentResponse> => {
|
||||||
|
if (isLocalTorrentFile(data)) {
|
||||||
|
return Promise.reject({
|
||||||
|
text: "Local torrent file paths are only supported in rqbit desktop.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let url = "/torrents?&overwrite=true";
|
let url = "/torrents?&overwrite=true";
|
||||||
if (opts?.list_only) {
|
if (opts?.list_only) {
|
||||||
url += "&list_only=true";
|
url += "&list_only=true";
|
||||||
|
|
@ -151,13 +158,6 @@ export const API: RqbitAPI & { getVersion: () => Promise<string> } = {
|
||||||
delete: (index: number): Promise<void> => {
|
delete: (index: number): Promise<void> => {
|
||||||
return makeRequest("POST", `/torrents/${index}/delete`);
|
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> => {
|
getVersion: async (): Promise<string> => {
|
||||||
const r = await makeRequest("GET", "/");
|
const r = await makeRequest("GET", "/");
|
||||||
return r.version;
|
return r.version;
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,6 @@ export interface TorrentStore {
|
||||||
|
|
||||||
refreshTorrents: () => void;
|
refreshTorrents: () => void;
|
||||||
setRefreshTorrents: (callback: () => void) => void;
|
setRefreshTorrents: (callback: () => void) => void;
|
||||||
|
|
||||||
focusedTorrentId: number | null;
|
|
||||||
focusTorrent: (id: number) => void;
|
|
||||||
clearFocusedTorrent: (id: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const torrentIdEquals = (t1: TorrentId, t2: TorrentId): boolean => {
|
const torrentIdEquals = (t1: TorrentId, t2: TorrentId): boolean => {
|
||||||
|
|
@ -55,13 +51,4 @@ export const useTorrentStore = create<TorrentStore>((set) => ({
|
||||||
}),
|
}),
|
||||||
refreshTorrents: () => {},
|
refreshTorrents: () => {},
|
||||||
setRefreshTorrents: (callback) => set({ refreshTorrents: callback }),
|
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,7 +24,6 @@ postgres = ["librqbit/postgres"]
|
||||||
disable-upload = ["librqbit/disable-upload"]
|
disable-upload = ["librqbit/disable-upload"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mimalloc = { version = "0.1", default-features = false }
|
|
||||||
librqbit = { version = "8.1.1", path = "../librqbit", default-features = false, features = [
|
librqbit = { version = "8.1.1", path = "../librqbit", default-features = false, features = [
|
||||||
"http-api",
|
"http-api",
|
||||||
"http-api-client",
|
"http-api-client",
|
||||||
|
|
|
||||||
|
|
@ -922,6 +922,3 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[global_allocator]
|
|
||||||
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ serde_with = "3.4.0"
|
||||||
parking_lot = "0.12.1"
|
parking_lot = "0.12.1"
|
||||||
gethostname = "0.5.0"
|
gethostname = "0.5.0"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
|
gtk = "0.18.2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,17 @@
|
||||||
mod config;
|
mod config;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
ffi::OsString,
|
||||||
fs::{File, OpenOptions},
|
fs::{File, OpenOptions},
|
||||||
io::{BufReader, BufWriter, Read, Write},
|
io::{BufReader, BufWriter, Read, Write},
|
||||||
os::unix::net::{UnixListener, UnixStream},
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::Command,
|
process::Command,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
thread,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use config::RqbitDesktopConfig;
|
use config::RqbitDesktopConfig;
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
|
|
@ -28,19 +29,13 @@ use librqbit::{
|
||||||
AddTorrent, AddTorrentOptions, Api, ApiError, PeerConnectionOptions, Session, SessionOptions,
|
AddTorrent, AddTorrentOptions, Api, ApiError, PeerConnectionOptions, Session, SessionOptions,
|
||||||
SessionPersistenceConfig,
|
SessionPersistenceConfig,
|
||||||
};
|
};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::{Mutex, RwLock};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{Emitter, Manager};
|
||||||
use tracing::{error, error_span, info, warn};
|
use tracing::{error, error_span, info, warn};
|
||||||
|
|
||||||
const ERR_NOT_CONFIGURED: ApiError =
|
const ERR_NOT_CONFIGURED: ApiError =
|
||||||
ApiError::new_from_text(StatusCode::FAILED_DEPENDENCY, "not configured");
|
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 {
|
struct StateShared {
|
||||||
config: config::RqbitDesktopConfig,
|
config: config::RqbitDesktopConfig,
|
||||||
|
|
@ -50,10 +45,302 @@ struct StateShared {
|
||||||
struct State {
|
struct State {
|
||||||
config_filename: String,
|
config_filename: String,
|
||||||
shared: Arc<RwLock<Option<StateShared>>>,
|
shared: Arc<RwLock<Option<StateShared>>>,
|
||||||
pending_launch_inputs: Arc<RwLock<Vec<String>>>,
|
pending_torrent_inputs: Mutex<Vec<PendingTorrentInput>>,
|
||||||
init_logging: InitLoggingResult,
|
init_logging: InitLoggingResult,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TORRENT_INPUTS_EVENT: &str = "torrent-inputs";
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
enum PendingTorrentInput {
|
||||||
|
FilePath { path: String },
|
||||||
|
Url { url: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
struct InstanceMessage {
|
||||||
|
inputs: Vec<PendingTorrentInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_torrent_input_from_arg(arg: OsString) -> Option<PendingTorrentInput> {
|
||||||
|
let raw = arg.to_string_lossy();
|
||||||
|
let arg = raw.trim();
|
||||||
|
if arg.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower_arg = arg.to_ascii_lowercase();
|
||||||
|
if lower_arg.starts_with("magnet:")
|
||||||
|
|| lower_arg.starts_with("http://")
|
||||||
|
|| lower_arg.starts_with("https://")
|
||||||
|
{
|
||||||
|
return Some(PendingTorrentInput::Url {
|
||||||
|
url: arg.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = if let Some(path) = arg.strip_prefix("file://") {
|
||||||
|
file_uri_to_path(path)?
|
||||||
|
} else {
|
||||||
|
PathBuf::from(arg)
|
||||||
|
};
|
||||||
|
|
||||||
|
if path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("torrent"))
|
||||||
|
{
|
||||||
|
return Some(PendingTorrentInput::FilePath {
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_uri_to_path(uri_path: &str) -> Option<PathBuf> {
|
||||||
|
let path = if let Some(path) = uri_path.strip_prefix("localhost/") {
|
||||||
|
format!("/{path}")
|
||||||
|
} else {
|
||||||
|
uri_path.to_owned()
|
||||||
|
};
|
||||||
|
percent_decode(&path).map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(value: &str) -> Option<String> {
|
||||||
|
fn hex_value(value: u8) -> Option<u8> {
|
||||||
|
match value {
|
||||||
|
b'0'..=b'9' => Some(value - b'0'),
|
||||||
|
b'a'..=b'f' => Some(value - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(value - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
let mut decoded = Vec::with_capacity(bytes.len());
|
||||||
|
let mut idx = 0;
|
||||||
|
|
||||||
|
while idx < bytes.len() {
|
||||||
|
if bytes[idx] == b'%' {
|
||||||
|
let high = *bytes.get(idx + 1)?;
|
||||||
|
let low = *bytes.get(idx + 2)?;
|
||||||
|
decoded.push(hex_value(high)? << 4 | hex_value(low)?);
|
||||||
|
idx += 3;
|
||||||
|
} else {
|
||||||
|
decoded.push(bytes[idx]);
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String::from_utf8(decoded).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_torrent_inputs_from_args() -> Vec<PendingTorrentInput> {
|
||||||
|
std::env::args_os()
|
||||||
|
.skip(1)
|
||||||
|
.filter_map(pending_torrent_input_from_arg)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
enum SingleInstance {
|
||||||
|
Primary(UnixListener),
|
||||||
|
Secondary,
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn acquire_single_instance(inputs: &[PendingTorrentInput]) -> SingleInstance {
|
||||||
|
let socket_path = single_instance_socket_path();
|
||||||
|
|
||||||
|
if send_instance_message(&socket_path, inputs).is_ok() {
|
||||||
|
return SingleInstance::Secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
match bind_single_instance_socket(&socket_path) {
|
||||||
|
Ok(listener) => SingleInstance::Primary(listener),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = ?e, path = ?socket_path, "single instance socket disabled");
|
||||||
|
SingleInstance::Disabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn single_instance_socket_path() -> PathBuf {
|
||||||
|
if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") {
|
||||||
|
return PathBuf::from(runtime_dir).join("rqbit-desktop.sock");
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_owned());
|
||||||
|
std::env::temp_dir().join(format!("rqbit-desktop-{user}.sock"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn send_instance_message(path: &Path, inputs: &[PendingTorrentInput]) -> anyhow::Result<()> {
|
||||||
|
let mut stream = UnixStream::connect(path)?;
|
||||||
|
serde_json::to_writer(
|
||||||
|
&mut stream,
|
||||||
|
&InstanceMessage {
|
||||||
|
inputs: inputs.to_vec(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
stream.write_all(b"\n")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn bind_single_instance_socket(path: &Path) -> anyhow::Result<UnixListener> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("error creating socket directory {parent:?}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
match UnixListener::bind(path) {
|
||||||
|
Ok(listener) => Ok(listener),
|
||||||
|
Err(e) if path.exists() => {
|
||||||
|
std::fs::remove_file(path)
|
||||||
|
.with_context(|| format!("error removing stale socket {path:?}"))?;
|
||||||
|
UnixListener::bind(path)
|
||||||
|
.with_context(|| format!("error binding single instance socket {path:?}"))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e).with_context(|| format!("error binding single instance socket {path:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn start_single_instance_listener(listener: UnixListener, app: tauri::AppHandle) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
match stream {
|
||||||
|
Ok(stream) => {
|
||||||
|
let app = app.clone();
|
||||||
|
std::thread::spawn(move || handle_single_instance_stream(stream, app));
|
||||||
|
}
|
||||||
|
Err(e) => warn!("error accepting single instance connection: {:#}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn handle_single_instance_stream(mut stream: UnixStream, app: tauri::AppHandle) {
|
||||||
|
let mut body = String::new();
|
||||||
|
if let Err(e) = stream.read_to_string(&mut body) {
|
||||||
|
warn!("error reading single instance message: {:#}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message: InstanceMessage = match serde_json::from_str(&body) {
|
||||||
|
Ok(message) => message,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("error parsing single instance message: {:#}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bring_main_window_to_front(&app);
|
||||||
|
|
||||||
|
if message.inputs.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = app.state::<State>();
|
||||||
|
state.pending_torrent_inputs.lock().extend(message.inputs);
|
||||||
|
|
||||||
|
if let Err(e) = app.emit(TORRENT_INPUTS_EVENT, ()) {
|
||||||
|
warn!("error emitting torrent inputs event: {:#}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bring_main_window_to_front(app: &tauri::AppHandle) {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn apply_gtk_window_controls_layout(app: &tauri::App) {
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
let Some(window) = app.get_webview_window("main") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(gtk_window) = window.gtk_window() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(titlebar) = gtk_window.titlebar() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(header) = find_gtk_header_bar(&titlebar) else {
|
||||||
|
warn!("could not find GTK header bar for window controls layout");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_gtk_header_bar_layout(&header);
|
||||||
|
|
||||||
|
let header_for_resize = header.downgrade();
|
||||||
|
gtk_window.connect_resizable_notify(move |_| {
|
||||||
|
if let Some(header) = header_for_resize.upgrade() {
|
||||||
|
apply_gtk_header_bar_layout(&header);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(settings) = gtk::Settings::default() {
|
||||||
|
let header_for_settings = header.downgrade();
|
||||||
|
settings.connect_gtk_decoration_layout_notify(move |_| {
|
||||||
|
if let Some(header) = header_for_settings.upgrade() {
|
||||||
|
apply_gtk_header_bar_layout(&header);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn find_gtk_header_bar(widget: >k::Widget) -> Option<gtk::HeaderBar> {
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
if let Ok(header) = widget.clone().downcast::<gtk::HeaderBar>() {
|
||||||
|
return Some(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(container) = widget.clone().downcast::<gtk::Container>() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
for child in container.children() {
|
||||||
|
if let Some(header) = find_gtk_header_bar(&child) {
|
||||||
|
return Some(header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn apply_gtk_header_bar_layout(header: >k::HeaderBar) {
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
let layout = gtk_window_controls_layout();
|
||||||
|
header.set_decoration_layout(Some(&layout));
|
||||||
|
info!(%layout, "applied GTK window controls layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn gtk_window_controls_layout() -> String {
|
||||||
|
use gtk::prelude::*;
|
||||||
|
|
||||||
|
gtk::Settings::default()
|
||||||
|
.and_then(|settings| settings.gtk_decoration_layout())
|
||||||
|
.map(|layout| layout.to_string())
|
||||||
|
.filter(|layout| !layout.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "menu:minimize,maximize,close".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn read_config(path: &str) -> anyhow::Result<RqbitDesktopConfig> {
|
fn read_config(path: &str) -> anyhow::Result<RqbitDesktopConfig> {
|
||||||
let rdr = BufReader::new(File::open(path)?);
|
let rdr = BufReader::new(File::open(path)?);
|
||||||
let mut config: RqbitDesktopConfig = serde_json::from_reader(rdr)?;
|
let mut config: RqbitDesktopConfig = serde_json::from_reader(rdr)?;
|
||||||
|
|
@ -184,7 +471,11 @@ async fn api_from_config(
|
||||||
}
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
async fn new(init_logging: InitLoggingResult) -> Self {
|
async fn new(
|
||||||
|
init_logging: InitLoggingResult,
|
||||||
|
pending_torrent_inputs: Vec<PendingTorrentInput>,
|
||||||
|
) -> Self {
|
||||||
|
let pending_torrent_inputs = Mutex::new(pending_torrent_inputs);
|
||||||
let config_filename = directories::ProjectDirs::from("com", "rqbit", "desktop")
|
let config_filename = directories::ProjectDirs::from("com", "rqbit", "desktop")
|
||||||
.expect("directories::ProjectDirs::from")
|
.expect("directories::ProjectDirs::from")
|
||||||
.config_dir()
|
.config_dir()
|
||||||
|
|
@ -206,7 +497,7 @@ impl State {
|
||||||
return Self {
|
return Self {
|
||||||
config_filename,
|
config_filename,
|
||||||
shared,
|
shared,
|
||||||
pending_launch_inputs: Arc::new(RwLock::new(Vec::new())),
|
pending_torrent_inputs,
|
||||||
init_logging,
|
init_logging,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +506,7 @@ impl State {
|
||||||
config_filename,
|
config_filename,
|
||||||
init_logging,
|
init_logging,
|
||||||
shared: Arc::new(RwLock::new(None)),
|
shared: Arc::new(RwLock::new(None)),
|
||||||
pending_launch_inputs: Arc::new(RwLock::new(Vec::new())),
|
pending_torrent_inputs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,211 +549,6 @@ impl State {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_torrent_launch_input(input: &str) -> bool {
|
|
||||||
input.starts_with("magnet:")
|
|
||||||
|| input.starts_with("http://")
|
|
||||||
|| input.starts_with("https://")
|
|
||||||
|| input.starts_with("file://")
|
|
||||||
|| Path::new(input)
|
|
||||||
.extension()
|
|
||||||
.and_then(|extension| extension.to_str())
|
|
||||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("torrent"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_launch_inputs() -> Vec<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)]
|
#[derive(Default, Serialize)]
|
||||||
struct CurrentState {
|
struct CurrentState {
|
||||||
config: Option<RqbitDesktopConfig>,
|
config: Option<RqbitDesktopConfig>,
|
||||||
|
|
@ -488,18 +574,10 @@ fn config_current(state: tauri::State<'_, State>) -> CurrentState {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn config_change(
|
async fn config_change(
|
||||||
app_handle: AppHandle,
|
|
||||||
state: tauri::State<'_, State>,
|
state: tauri::State<'_, State>,
|
||||||
config: RqbitDesktopConfig,
|
config: RqbitDesktopConfig,
|
||||||
) -> Result<EmptyJsonResponse, ApiError> {
|
) -> Result<EmptyJsonResponse, ApiError> {
|
||||||
state.configure(config).await?;
|
state.configure(config).await.map(|_| EmptyJsonResponse {})
|
||||||
drain_pending_launch_inputs(
|
|
||||||
state.shared.clone(),
|
|
||||||
state.pending_launch_inputs.clone(),
|
|
||||||
Some(app_handle),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
Ok(EmptyJsonResponse {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -536,6 +614,21 @@ async fn torrent_create_from_base64_file(
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn torrent_create_from_file_path(
|
||||||
|
state: tauri::State<'_, State>,
|
||||||
|
path: String,
|
||||||
|
opts: Option<AddTorrentOptions>,
|
||||||
|
) -> Result<ApiAddTorrentResponse, ApiError> {
|
||||||
|
let bytes = std::fs::read(&path)
|
||||||
|
.with_context(|| format!("error reading torrent file {path:?}"))
|
||||||
|
.map_err(|e| ApiError::new_from_anyhow(StatusCode::BAD_REQUEST, e))?;
|
||||||
|
state
|
||||||
|
.api()?
|
||||||
|
.api_add_torrent(AddTorrent::TorrentFileBytes(bytes.into()), opts)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn torrent_details(
|
async fn torrent_details(
|
||||||
state: tauri::State<'_, State>,
|
state: tauri::State<'_, State>,
|
||||||
|
|
@ -560,17 +653,6 @@ async fn torrent_action_delete(
|
||||||
state.api()?.api_torrent_action_delete(id).await
|
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]
|
#[tauri::command]
|
||||||
async fn torrent_action_pause(
|
async fn torrent_action_pause(
|
||||||
state: tauri::State<'_, State>,
|
state: tauri::State<'_, State>,
|
||||||
|
|
@ -613,36 +695,69 @@ async fn stats(state: tauri::State<'_, State>) -> Result<SessionStatsSnapshot, A
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn open_output_folder(path: String) -> Result<EmptyJsonResponse, ApiError> {
|
fn take_startup_torrent_inputs(state: tauri::State<'_, State>) -> Vec<PendingTorrentInput> {
|
||||||
let path = PathBuf::from(path);
|
let mut inputs = state.pending_torrent_inputs.lock();
|
||||||
let metadata = std::fs::metadata(&path)
|
std::mem::take(&mut *inputs)
|
||||||
.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() {
|
#[tauri::command]
|
||||||
return Err(ApiError::new_from_anyhow(
|
async fn torrent_open_output(
|
||||||
StatusCode::BAD_REQUEST,
|
state: tauri::State<'_, State>,
|
||||||
anyhow::anyhow!("output path is not a folder: {}", path.display()),
|
id: TorrentIdOrHash,
|
||||||
));
|
) -> Result<EmptyJsonResponse, ApiError> {
|
||||||
}
|
let details = state.api()?.api_torrent_details(id)?;
|
||||||
|
let path = nearest_existing_path(PathBuf::from(details.output_folder));
|
||||||
let status = Command::new("xdg-open")
|
open_file_manager(&path)
|
||||||
.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))?;
|
.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 {})
|
Ok(EmptyJsonResponse {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn nearest_existing_path(mut path: PathBuf) -> PathBuf {
|
||||||
|
let original = path.clone();
|
||||||
|
while !path.exists() {
|
||||||
|
if !path.pop() {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn open_file_manager(path: &Path) -> anyhow::Result<()> {
|
||||||
|
let mut command = Command::new("xdg-open");
|
||||||
|
command.arg(path);
|
||||||
|
spawn_open_command(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn open_file_manager(path: &Path) -> anyhow::Result<()> {
|
||||||
|
let mut command = Command::new("open");
|
||||||
|
command.arg(path);
|
||||||
|
spawn_open_command(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn open_file_manager(path: &Path) -> anyhow::Result<()> {
|
||||||
|
let mut command = Command::new("explorer");
|
||||||
|
command.arg(path);
|
||||||
|
spawn_open_command(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||||
|
fn open_file_manager(_path: &Path) -> anyhow::Result<()> {
|
||||||
|
anyhow::bail!("opening downloaded files is not supported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_open_command(mut command: Command) -> anyhow::Result<()> {
|
||||||
|
let mut child = command.spawn().context("error opening path")?;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(e) = child.wait() {
|
||||||
|
warn!("error waiting for opener process: {:#}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_version() -> &'static str {
|
fn get_version() -> &'static str {
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
|
|
@ -662,39 +777,28 @@ async fn start() {
|
||||||
Err(e) => warn!("failed increasing open file limit: {:#}", e),
|
Err(e) => warn!("failed increasing open file limit: {:#}", e),
|
||||||
};
|
};
|
||||||
|
|
||||||
let launch_inputs = collect_launch_inputs();
|
let pending_torrent_inputs = pending_torrent_inputs_from_args();
|
||||||
let socket_path = desktop_ipc_socket_path();
|
|
||||||
|
|
||||||
if !launch_inputs.is_empty() && forward_inputs_to_existing(&socket_path, &launch_inputs).is_ok()
|
#[cfg(unix)]
|
||||||
{
|
let single_instance_listener = match acquire_single_instance(&pending_torrent_inputs) {
|
||||||
return;
|
SingleInstance::Primary(listener) => Some(listener),
|
||||||
}
|
SingleInstance::Secondary => return,
|
||||||
|
SingleInstance::Disabled => None,
|
||||||
|
};
|
||||||
|
|
||||||
let state = State::new(init_logging_result).await;
|
let state = State::new(init_logging_result, pending_torrent_inputs).await;
|
||||||
let shared = state.shared.clone();
|
|
||||||
let pending = state.pending_launch_inputs.clone();
|
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.manage(state)
|
.manage(state)
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let app_handle = app.handle().clone();
|
#[cfg(target_os = "linux")]
|
||||||
spawn_ipc_listener(
|
apply_gtk_window_controls_layout(app);
|
||||||
socket_path.clone(),
|
|
||||||
shared.clone(),
|
|
||||||
pending.clone(),
|
|
||||||
app_handle.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if !launch_inputs.is_empty() {
|
#[cfg(unix)]
|
||||||
tauri::async_runtime::spawn(add_launch_inputs(
|
if let Some(listener) = single_instance_listener {
|
||||||
shared.clone(),
|
start_single_instance_listener(listener, app.handle().clone());
|
||||||
pending.clone(),
|
|
||||||
launch_inputs.clone(),
|
|
||||||
Some(app_handle),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
|
@ -703,13 +807,14 @@ async fn start() {
|
||||||
torrent_stats,
|
torrent_stats,
|
||||||
torrent_create_from_url,
|
torrent_create_from_url,
|
||||||
torrent_action_delete,
|
torrent_action_delete,
|
||||||
torrents_delete_all,
|
|
||||||
torrent_action_pause,
|
torrent_action_pause,
|
||||||
torrent_action_forget,
|
torrent_action_forget,
|
||||||
torrent_action_start,
|
torrent_action_start,
|
||||||
torrent_action_configure,
|
torrent_action_configure,
|
||||||
torrent_create_from_base64_file,
|
torrent_create_from_base64_file,
|
||||||
open_output_folder,
|
torrent_create_from_file_path,
|
||||||
|
take_startup_torrent_inputs,
|
||||||
|
torrent_open_output,
|
||||||
stats,
|
stats,
|
||||||
get_version,
|
get_version,
|
||||||
config_default,
|
config_default,
|
||||||
|
|
|
||||||
144
desktop/src/StartupTorrentInputs.tsx
Normal file
144
desktop/src/StartupTorrentInputs.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { useContext, useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import {
|
||||||
|
AddTorrentResponse,
|
||||||
|
ErrorDetails as ApiErrorDetails,
|
||||||
|
TorrentInput,
|
||||||
|
localTorrentFile,
|
||||||
|
} from "rqbit-webui/src/api-types";
|
||||||
|
import { APIContext } from "rqbit-webui/src/context";
|
||||||
|
import { FileSelectionModal } from "rqbit-webui/src/components/modal/FileSelectionModal";
|
||||||
|
import { ErrorWithLabel } from "rqbit-webui/src/rqbit-web";
|
||||||
|
|
||||||
|
type PendingTorrentInput =
|
||||||
|
| { type: "file_path"; path: string }
|
||||||
|
| { type: "url"; url: string };
|
||||||
|
|
||||||
|
const TORRENT_INPUTS_EVENT = "torrent-inputs";
|
||||||
|
|
||||||
|
const toTorrentInput = (input: PendingTorrentInput): TorrentInput => {
|
||||||
|
if (input.type === "file_path") {
|
||||||
|
return localTorrentFile(input.path);
|
||||||
|
}
|
||||||
|
return input.url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StartupTorrentInputs = ({ enabled }: { enabled: boolean }) => {
|
||||||
|
const API = useContext(APIContext);
|
||||||
|
const [queue, setQueue] = useState<TorrentInput[]>([]);
|
||||||
|
const [activeInput, setActiveInput] = useState<TorrentInput | null>(null);
|
||||||
|
const [listTorrentResponse, setListTorrentResponse] =
|
||||||
|
useState<AddTorrentResponse | null>(null);
|
||||||
|
const [listTorrentError, setListTorrentError] =
|
||||||
|
useState<ErrorWithLabel | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unlisten: (() => void) | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const drainPendingInputs = () => {
|
||||||
|
invoke<PendingTorrentInput[]>("take_startup_torrent_inputs").then(
|
||||||
|
(inputs) => {
|
||||||
|
if (!cancelled && inputs.length > 0) {
|
||||||
|
setQueue((queue) => [...queue, ...inputs.map(toTorrentInput)]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
console.error("error reading startup torrent inputs", e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
listen(TORRENT_INPUTS_EVENT, drainPendingInputs).then(
|
||||||
|
(cleanup) => {
|
||||||
|
if (cancelled) {
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unlisten = cleanup;
|
||||||
|
drainPendingInputs();
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
console.error("error listening for startup torrent inputs", e);
|
||||||
|
drainPendingInputs();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeInput && queue.length > 0) {
|
||||||
|
const [next, ...remaining] = queue;
|
||||||
|
setActiveInput(next);
|
||||||
|
setQueue(remaining);
|
||||||
|
}
|
||||||
|
}, [activeInput, queue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setListTorrentError(null);
|
||||||
|
setListTorrentResponse(null);
|
||||||
|
|
||||||
|
API.uploadTorrent(activeInput, { list_only: true })
|
||||||
|
.then(
|
||||||
|
(response) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setListTorrentResponse(response);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setListTorrentError({
|
||||||
|
text: "Error listing torrent files",
|
||||||
|
details: e as ApiErrorDetails,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [API, activeInput]);
|
||||||
|
|
||||||
|
const clearActive = () => {
|
||||||
|
setActiveInput(null);
|
||||||
|
setListTorrentError(null);
|
||||||
|
setListTorrentResponse(null);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!activeInput) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileSelectionModal
|
||||||
|
onHide={clearActive}
|
||||||
|
listTorrentError={listTorrentError}
|
||||||
|
listTorrentResponse={listTorrentResponse}
|
||||||
|
data={activeInput}
|
||||||
|
listTorrentLoading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
TorrentStats,
|
TorrentStats,
|
||||||
ErrorDetails,
|
ErrorDetails,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
|
isLocalTorrentFile,
|
||||||
} from "rqbit-webui/src/api-types";
|
} from "rqbit-webui/src/api-types";
|
||||||
|
|
||||||
import { InvokeArgs, invoke } from "@tauri-apps/api/core";
|
import { InvokeArgs, invoke } from "@tauri-apps/api/core";
|
||||||
|
|
@ -110,11 +111,23 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isLocalTorrentFile(data)) {
|
||||||
|
return await invokeAPI<AddTorrentResponse>(
|
||||||
|
"torrent_create_from_file_path",
|
||||||
|
{
|
||||||
|
path: data.path,
|
||||||
|
opts: opts ?? {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
return await invokeAPI<AddTorrentResponse>("torrent_create_from_url", {
|
return await invokeAPI<AddTorrentResponse>("torrent_create_from_url", {
|
||||||
url: data,
|
url: data,
|
||||||
opts: opts ?? {},
|
opts: opts ?? {},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
openTorrentOutput: function (id: number): Promise<void> {
|
||||||
|
return invokeAPI<void>("torrent_open_output", { id });
|
||||||
|
},
|
||||||
updateOnlyFiles: function (id, files): Promise<void> {
|
updateOnlyFiles: function (id, files): Promise<void> {
|
||||||
return invokeAPI<void>("torrent_action_configure", {
|
return invokeAPI<void>("torrent_action_configure", {
|
||||||
id: id,
|
id: id,
|
||||||
|
|
@ -133,12 +146,6 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => {
|
||||||
delete: function (id: number): Promise<void> {
|
delete: function (id: number): Promise<void> {
|
||||||
return invokeAPI<void>("torrent_action_delete", { id });
|
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: () => {
|
getTorrentStreamUrl: () => {
|
||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { RqbitWebUI } from "rqbit-webui/src/rqbit-web";
|
import { RqbitWebUI } from "rqbit-webui/src/rqbit-web";
|
||||||
import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration";
|
import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration";
|
||||||
import { ConfigModal } from "./configure";
|
import { ConfigModal } from "./configure";
|
||||||
|
|
@ -7,13 +6,7 @@ import { IconButton } from "rqbit-webui/src/components/buttons/IconButton";
|
||||||
import { BsSliders2 } from "react-icons/bs";
|
import { BsSliders2 } from "react-icons/bs";
|
||||||
import { APIContext } from "rqbit-webui/src/context";
|
import { APIContext } from "rqbit-webui/src/context";
|
||||||
import { makeAPI } from "./api";
|
import { makeAPI } from "./api";
|
||||||
import { useTorrentStore } from "rqbit-webui/src/stores/torrentStore";
|
import { StartupTorrentInputs } from "./StartupTorrentInputs";
|
||||||
|
|
||||||
const TORRENTS_CHANGED_EVENT = "rqbit-desktop-torrents-changed";
|
|
||||||
|
|
||||||
type TorrentsChangedPayload = {
|
|
||||||
id?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RqbitDesktop: React.FC<{
|
export const RqbitDesktop: React.FC<{
|
||||||
version: string;
|
version: string;
|
||||||
|
|
@ -25,31 +18,7 @@ export const RqbitDesktop: React.FC<{
|
||||||
currentState.config ?? defaultConfig,
|
currentState.config ?? defaultConfig,
|
||||||
);
|
);
|
||||||
let [configurationOpened, setConfigurationOpened] = useState<boolean>(false);
|
let [configurationOpened, setConfigurationOpened] = useState<boolean>(false);
|
||||||
|
const api = useMemo(() => makeAPI(config), [config]);
|
||||||
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 = (
|
const configButton = (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
@ -62,13 +31,16 @@ export const RqbitDesktop: React.FC<{
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<APIContext.Provider value={makeAPI(config)}>
|
<APIContext.Provider value={api}>
|
||||||
{configured && (
|
{configured && (
|
||||||
<RqbitWebUI
|
<>
|
||||||
title={`Rqbit Desktop`}
|
<RqbitWebUI
|
||||||
version={version}
|
title={`Rqbit Desktop`}
|
||||||
menuButtons={[configButton]}
|
version={version}
|
||||||
></RqbitWebUI>
|
menuButtons={[configButton]}
|
||||||
|
></RqbitWebUI>
|
||||||
|
<StartupTorrentInputs enabled={configured} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<ConfigModal
|
<ConfigModal
|
||||||
show={!configured || configurationOpened}
|
show={!configured || configurationOpened}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue