feat(desktop): improve torrent file integration
This commit is contained in:
parent
00b9748516
commit
a1d4aab93f
12 changed files with 717 additions and 18 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4641,6 +4641,7 @@ dependencies = [
|
|||
"base64 0.22.1",
|
||||
"directories 5.0.1",
|
||||
"gethostname 0.5.0",
|
||||
"gtk",
|
||||
"http",
|
||||
"librqbit",
|
||||
"parking_lot",
|
||||
|
|
|
|||
|
|
@ -23,9 +23,30 @@ export interface TorrentFileAttributes {
|
|||
export interface TorrentDetails {
|
||||
name: string | null;
|
||||
info_hash: string;
|
||||
output_folder: string;
|
||||
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 {
|
||||
id: number | null;
|
||||
details: TorrentDetails;
|
||||
|
|
@ -192,9 +213,10 @@ export interface RqbitAPI {
|
|||
filename?: string | null,
|
||||
) => string | null;
|
||||
uploadTorrent: (
|
||||
data: string | File,
|
||||
data: TorrentInput,
|
||||
opts?: AddTorrentOptions,
|
||||
) => Promise<AddTorrentResponse>;
|
||||
openTorrentOutput?: (index: number) => Promise<void>;
|
||||
|
||||
pause: (index: number) => Promise<void>;
|
||||
updateOnlyFiles: (index: number, files: number[]) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -93,9 +93,82 @@ export const TorrentRow: React.FC<{
|
|||
};
|
||||
|
||||
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 (
|
||||
<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">
|
||||
{/* Icon */}
|
||||
<div className="hidden md:block">{statusIcon("w-10 h-10")}</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
FaPlay,
|
||||
FaTrash,
|
||||
FaClipboardList,
|
||||
FaFolderOpen,
|
||||
} from "react-icons/fa";
|
||||
import { useErrorStore } from "../../stores/errorStore";
|
||||
import { ErrorComponent } from "../ErrorComponent";
|
||||
|
|
@ -34,6 +35,22 @@ export const TorrentActions: React.FC<{
|
|||
|
||||
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 = () => {
|
||||
setDisabled(true);
|
||||
API.start(id)
|
||||
|
|
@ -136,6 +153,11 @@ export const TorrentActions: React.FC<{
|
|||
<FaCog className="hover:text-green-600" />
|
||||
</IconButton>
|
||||
)}
|
||||
{API.openTorrentOutput && (
|
||||
<IconButton onClick={openOutput} disabled={disabled}>
|
||||
<FaFolderOpen className="hover:text-blue-500" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={startDeleting} disabled={disabled}>
|
||||
<FaTrash className="hover:text-red-500" />
|
||||
</IconButton>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ReactNode, useContext, useEffect, useState } from "react";
|
|||
import {
|
||||
AddTorrentResponse,
|
||||
ErrorDetails as ApiErrorDetails,
|
||||
TorrentInput,
|
||||
} from "../../api-types";
|
||||
import { APIContext } from "../../context";
|
||||
import { ErrorWithLabel } from "../../rqbit-web";
|
||||
|
|
@ -10,7 +11,7 @@ import { Button } from "./Button";
|
|||
|
||||
export const UploadButton: React.FC<{
|
||||
onClick: () => void;
|
||||
data: string | File | null;
|
||||
data: TorrentInput | null;
|
||||
resetData: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { useContext, useEffect, useState } from "react";
|
||||
import { AddTorrentResponse, AddTorrentOptions } from "../../api-types";
|
||||
import {
|
||||
AddTorrentResponse,
|
||||
AddTorrentOptions,
|
||||
TorrentInput,
|
||||
} from "../../api-types";
|
||||
import { APIContext } from "../../context";
|
||||
import { ErrorComponent } from "../ErrorComponent";
|
||||
import { ErrorWithLabel } from "../../rqbit-web";
|
||||
|
|
@ -19,7 +23,7 @@ export const FileSelectionModal = (props: {
|
|||
listTorrentResponse: AddTorrentResponse | null;
|
||||
listTorrentError: ErrorWithLabel | null;
|
||||
listTorrentLoading: boolean;
|
||||
data: string | File;
|
||||
data: TorrentInput;
|
||||
}) => {
|
||||
let {
|
||||
onHide,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
SessionStats,
|
||||
TorrentDetails,
|
||||
TorrentStats,
|
||||
isLocalTorrentFile,
|
||||
} from "./api-types";
|
||||
|
||||
// Define API URL and base path
|
||||
|
|
@ -99,6 +100,12 @@ export const API: RqbitAPI & { getVersion: () => Promise<string> } = {
|
|||
},
|
||||
|
||||
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";
|
||||
if (opts?.list_only) {
|
||||
url += "&list_only=true";
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ serde_with = "3.4.0"
|
|||
parking_lot = "0.12.1"
|
||||
gethostname = "0.5.0"
|
||||
tauri-plugin-shell = "2"
|
||||
gtk = "0.18.2"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
|
|
|
|||
|
|
@ -4,12 +4,17 @@
|
|||
mod config;
|
||||
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
fs::{File, OpenOptions},
|
||||
io::{BufReader, BufWriter},
|
||||
path::Path,
|
||||
io::{BufReader, BufWriter, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
|
||||
use anyhow::Context;
|
||||
use config::RqbitDesktopConfig;
|
||||
use http::StatusCode;
|
||||
|
|
@ -24,8 +29,9 @@ use librqbit::{
|
|||
AddTorrent, AddTorrentOptions, Api, ApiError, PeerConnectionOptions, Session, SessionOptions,
|
||||
SessionPersistenceConfig,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use serde::Serialize;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Manager};
|
||||
use tracing::{error, error_span, info, warn};
|
||||
|
||||
const ERR_NOT_CONFIGURED: ApiError =
|
||||
|
|
@ -39,9 +45,302 @@ struct StateShared {
|
|||
struct State {
|
||||
config_filename: String,
|
||||
shared: Arc<RwLock<Option<StateShared>>>,
|
||||
pending_torrent_inputs: Mutex<Vec<PendingTorrentInput>>,
|
||||
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> {
|
||||
let rdr = BufReader::new(File::open(path)?);
|
||||
let mut config: RqbitDesktopConfig = serde_json::from_reader(rdr)?;
|
||||
|
|
@ -172,7 +471,11 @@ async fn api_from_config(
|
|||
}
|
||||
|
||||
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")
|
||||
.expect("directories::ProjectDirs::from")
|
||||
.config_dir()
|
||||
|
|
@ -194,6 +497,7 @@ impl State {
|
|||
return Self {
|
||||
config_filename,
|
||||
shared,
|
||||
pending_torrent_inputs,
|
||||
init_logging,
|
||||
};
|
||||
}
|
||||
|
|
@ -202,6 +506,7 @@ impl State {
|
|||
config_filename,
|
||||
init_logging,
|
||||
shared: Arc::new(RwLock::new(None)),
|
||||
pending_torrent_inputs,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -309,6 +614,21 @@ async fn torrent_create_from_base64_file(
|
|||
.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]
|
||||
async fn torrent_details(
|
||||
state: tauri::State<'_, State>,
|
||||
|
|
@ -374,6 +694,70 @@ async fn stats(state: tauri::State<'_, State>) -> Result<SessionStatsSnapshot, A
|
|||
Ok(state.api()?.api_session_stats())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn take_startup_torrent_inputs(state: tauri::State<'_, State>) -> Vec<PendingTorrentInput> {
|
||||
let mut inputs = state.pending_torrent_inputs.lock();
|
||||
std::mem::take(&mut *inputs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn torrent_open_output(
|
||||
state: tauri::State<'_, State>,
|
||||
id: TorrentIdOrHash,
|
||||
) -> Result<EmptyJsonResponse, ApiError> {
|
||||
let details = state.api()?.api_torrent_details(id)?;
|
||||
let path = nearest_existing_path(PathBuf::from(details.output_folder));
|
||||
open_file_manager(&path)
|
||||
.map_err(|e| ApiError::new_from_anyhow(StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
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]
|
||||
fn get_version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
|
|
@ -393,11 +777,30 @@ async fn start() {
|
|||
Err(e) => warn!("failed increasing open file limit: {:#}", e),
|
||||
};
|
||||
|
||||
let state = State::new(init_logging_result).await;
|
||||
let pending_torrent_inputs = pending_torrent_inputs_from_args();
|
||||
|
||||
#[cfg(unix)]
|
||||
let single_instance_listener = match acquire_single_instance(&pending_torrent_inputs) {
|
||||
SingleInstance::Primary(listener) => Some(listener),
|
||||
SingleInstance::Secondary => return,
|
||||
SingleInstance::Disabled => None,
|
||||
};
|
||||
|
||||
let state = State::new(init_logging_result, pending_torrent_inputs).await;
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(state)
|
||||
.setup(move |app| {
|
||||
#[cfg(target_os = "linux")]
|
||||
apply_gtk_window_controls_layout(app);
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Some(listener) = single_instance_listener {
|
||||
start_single_instance_listener(listener, app.handle().clone());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
torrents_list,
|
||||
torrent_details,
|
||||
|
|
@ -409,6 +812,9 @@ async fn start() {
|
|||
torrent_action_start,
|
||||
torrent_action_configure,
|
||||
torrent_create_from_base64_file,
|
||||
torrent_create_from_file_path,
|
||||
take_startup_torrent_inputs,
|
||||
torrent_open_output,
|
||||
stats,
|
||||
get_version,
|
||||
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,
|
||||
ErrorDetails,
|
||||
SessionStats,
|
||||
isLocalTorrentFile,
|
||||
} from "rqbit-webui/src/api-types";
|
||||
|
||||
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", {
|
||||
url: data,
|
||||
opts: opts ?? {},
|
||||
});
|
||||
},
|
||||
openTorrentOutput: function (id: number): Promise<void> {
|
||||
return invokeAPI<void>("torrent_open_output", { id });
|
||||
},
|
||||
updateOnlyFiles: function (id, files): Promise<void> {
|
||||
return invokeAPI<void>("torrent_action_configure", {
|
||||
id: id,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { RqbitWebUI } from "rqbit-webui/src/rqbit-web";
|
||||
import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration";
|
||||
import { ConfigModal } from "./configure";
|
||||
|
|
@ -6,6 +6,7 @@ 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 { StartupTorrentInputs } from "./StartupTorrentInputs";
|
||||
|
||||
export const RqbitDesktop: React.FC<{
|
||||
version: string;
|
||||
|
|
@ -17,6 +18,7 @@ export const RqbitDesktop: React.FC<{
|
|||
currentState.config ?? defaultConfig,
|
||||
);
|
||||
let [configurationOpened, setConfigurationOpened] = useState<boolean>(false);
|
||||
const api = useMemo(() => makeAPI(config), [config]);
|
||||
|
||||
const configButton = (
|
||||
<IconButton
|
||||
|
|
@ -29,13 +31,16 @@ export const RqbitDesktop: React.FC<{
|
|||
);
|
||||
|
||||
return (
|
||||
<APIContext.Provider value={makeAPI(config)}>
|
||||
<APIContext.Provider value={api}>
|
||||
{configured && (
|
||||
<RqbitWebUI
|
||||
title={`Rqbit Desktop`}
|
||||
version={version}
|
||||
menuButtons={[configButton]}
|
||||
></RqbitWebUI>
|
||||
<>
|
||||
<RqbitWebUI
|
||||
title={`Rqbit Desktop`}
|
||||
version={version}
|
||||
menuButtons={[configButton]}
|
||||
></RqbitWebUI>
|
||||
<StartupTorrentInputs enabled={configured} />
|
||||
</>
|
||||
)}
|
||||
<ConfigModal
|
||||
show={!configured || configurationOpened}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue