Compare commits

...

10 commits

Author SHA1 Message Date
a1d4aab93f feat(desktop): improve torrent file integration 2026-05-30 10:45:45 +02:00
Igor Katson
00b9748516
Update crate versions
Some checks failed
Release binaries for Linux and Docker / release-linux (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Release binaries for Linux and Docker / build-docker-cross (linux/amd64) (push) Has been cancelled
Release binaries for Linux and Docker / build-docker-cross (linux/arm/v7) (push) Has been cancelled
Release binaries for Linux and Docker / build-docker-cross (linux/arm64) (push) Has been cancelled
Release binaries for Linux and Docker / build-push-docker-multiarch (push) Has been cancelled
Release binaries for OSX / release-macos (push) Has been cancelled
Release binaries for Windows / release-windows (push) Has been cancelled
Release binaries for Linux and Docker / release-linux (amd64, ubuntu-24.04) (push) Has been cancelled
2025-06-12 12:31:23 +01:00
Igor Katson
99bf295028
Do not try to delete padding files as they dont exist 2025-06-12 12:29:14 +01:00
Igor Katson
1409dffb8f
Fix invalid torrent names to use from_utf8_lossy #316 2025-06-12 12:28:51 +01:00
Igor Katson
acf67b2439
WebUI: padding files are not checked by default 2025-06-12 12:28:10 +01:00
Igor Katson
963f8167de
Fix a very rare bug where DHT paniced 2025-06-12 12:27:23 +01:00
Igor Katson
559fca8552
Merge pull request #376 from ikatson/prepare-8.1.0
Prepare for releasing 8.1.0
2025-06-05 11:58:20 +01:00
Igor Katson
9b5b9e6ba7
Update all subcrates versions 2025-06-05 11:52:50 +01:00
Igor Katson
68a85ab41b
Update npm packages 2025-06-05 11:46:35 +01:00
Igor Katson
4307efe112
Update min Rust to 1.85 2025-06-05 11:42:35 +01:00
30 changed files with 1490 additions and 711 deletions

View file

@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
rust_version: ["1.78", "1.83"]
rust_version: ["1.85", "1.87"]
steps:
- name: rustup toolchain install ${{ matrix.rust_version }}
run: |
@ -45,7 +45,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
prefix-key: v1
- name: Run tests
if: ${{ matrix.os == 'windows-latest' }}
run: cargo test --workspace
@ -53,7 +53,7 @@ jobs:
- name: Run tests
if: ${{ matrix.os == 'macos-latest' }}
run: ulimit -n unlimited && cargo test --workspace
- name: Run tests
if: ${{ matrix.os == 'ubuntu-latest' }}
run: cargo test

19
Cargo.lock generated
View file

@ -2794,7 +2794,7 @@ dependencies = [
[[package]]
name = "librqbit"
version = "8.0.0"
version = "8.1.1"
dependencies = [
"anyhow",
"arc-swap",
@ -2860,7 +2860,7 @@ dependencies = [
[[package]]
name = "librqbit-bencode"
version = "3.0.2"
version = "3.1.0"
dependencies = [
"anyhow",
"bytes",
@ -2881,7 +2881,7 @@ dependencies = [
[[package]]
name = "librqbit-clone-to-owned"
version = "3.0.0"
version = "3.0.1"
dependencies = [
"bytes",
]
@ -2912,7 +2912,7 @@ dependencies = [
[[package]]
name = "librqbit-dht"
version = "5.2.0"
version = "5.3.1"
dependencies = [
"anyhow",
"backoff",
@ -2940,7 +2940,7 @@ dependencies = [
[[package]]
name = "librqbit-peer-protocol"
version = "4.2.0"
version = "4.3.0"
dependencies = [
"anyhow",
"bincode 1.3.3",
@ -2966,7 +2966,7 @@ dependencies = [
[[package]]
name = "librqbit-tracker-comms"
version = "2.1.0"
version = "3.0.0"
dependencies = [
"anyhow",
"async-stream",
@ -3006,7 +3006,7 @@ dependencies = [
[[package]]
name = "librqbit-upnp-serve"
version = "1.0.0"
version = "1.0.1"
dependencies = [
"anyhow",
"axum 0.8.4",
@ -4606,7 +4606,7 @@ dependencies = [
[[package]]
name = "rqbit"
version = "8.0.0"
version = "8.1.1"
dependencies = [
"anyhow",
"bytes",
@ -4635,12 +4635,13 @@ dependencies = [
[[package]]
name = "rqbit-desktop"
version = "8.0.0"
version = "8.1.1"
dependencies = [
"anyhow",
"base64 0.22.1",
"directories 5.0.1",
"gethostname 0.5.0",
"gtk",
"http",
"librqbit",
"parking_lot",

View file

@ -1,6 +1,6 @@
[package]
name = "librqbit-bencode"
version = "3.0.2"
version = "3.1.0"
edition = "2021"
description = "Bencode serialization and deserialization using Serde"
license = "Apache-2.0"

View file

@ -1,6 +1,6 @@
[package]
name = "librqbit-clone-to-owned"
version = "3.0.0"
version = "3.0.1"
edition = "2021"
description = "Util traits to represent something that can be made owned and change type at the same time."
license = "Apache-2.0"

View file

@ -1,6 +1,6 @@
[package]
name = "librqbit-dht"
version = "5.2.0"
version = "5.3.1"
edition = "2021"
description = "DHT implementation, used in rqbit torrent client."
license = "Apache-2.0"
@ -28,7 +28,7 @@ serde = { version = "1", features = ["derive"] }
leaky-bucket = "1.1"
serde_json = "1"
hex = "0.4"
bencode = { path = "../bencode", default-features = false, package = "librqbit-bencode", version = "3" }
bencode = { path = "../bencode", default-features = false, package = "librqbit-bencode", version = "3.1" }
anyhow = "1"
parking_lot = "0.12"
tracing = "0.1"

View file

@ -971,9 +971,10 @@ impl DhtWorker {
loop {
interval.tick().await;
let mut found = 0;
let now = Instant::now();
for node in self.dht.routing_table.read().iter() {
if matches!(
node.status(),
node.status(now),
NodeStatus::Questionable | NodeStatus::Unknown
) {
found += 1;

View file

@ -286,10 +286,11 @@ impl BucketTree {
};
// Try replace a bad node
let now = Instant::now();
if let Some(bad_node) = nodes
.nodes
.iter_mut()
.find(|r| matches!(r.status(), NodeStatus::Bad))
.find(|r| matches!(r.status(now), NodeStatus::Bad))
{
std::mem::swap(bad_node, &mut new_node);
nodes.nodes.sort_by_key(|n| n.id);
@ -395,7 +396,7 @@ impl Serialize for RoutingTableNode {
let mut s = serializer.serialize_struct("RoutingTableNode", 3)?;
s.serialize_field("id", &self.id.as_string())?;
s.serialize_field("addr", &self.addr)?;
s.serialize_field("status", &self.status())?;
s.serialize_field("status", &self.status(Instant::now()))?;
if let Some(l) = self.last_request {
s.serialize_field("last_request_ago", &l.elapsed())?;
}
@ -425,7 +426,7 @@ impl RoutingTableNode {
pub fn addr(&self) -> SocketAddr {
self.addr
}
pub fn status(&self) -> NodeStatus {
pub fn status(&self, now: Instant) -> NodeStatus {
match (self.last_request, self.last_response, self.last_query) {
// Nodes become bad when they fail to respond to multiple queries in a row.
(Some(_), _, _) if self.errors_in_a_row >= 2 => NodeStatus::Bad,
@ -434,7 +435,7 @@ impl RoutingTableNode {
// A node is also good if it has ever responded to one of our queries and has sent
// us a query within the last 15 minutes.
(Some(_), Some(last_incoming), _) | (Some(_), Some(_), Some(last_incoming))
if last_incoming.elapsed() < INACTIVITY_TIMEOUT =>
if now - last_incoming < INACTIVITY_TIMEOUT =>
{
NodeStatus::Good
}
@ -442,9 +443,9 @@ impl RoutingTableNode {
// After 15 minutes of inactivity, a node becomes questionable.
// The moment we send a request to it, it stops becoming questionable and becomes Unknown / Bad.
(last_outgoing, _, Some(last_incoming)) | (last_outgoing, Some(last_incoming), _)
if last_incoming.elapsed() > INACTIVITY_TIMEOUT
if now - last_incoming > INACTIVITY_TIMEOUT
&& last_outgoing
.map(|e| e.elapsed() > INACTIVITY_TIMEOUT)
.map(|e| now - e > INACTIVITY_TIMEOUT)
.unwrap_or(true) =>
{
NodeStatus::Questionable
@ -504,11 +505,12 @@ impl RoutingTable {
for node in self.buckets.iter() {
result.push(node);
}
let now = Instant::now();
result.sort_by_key(|n| {
// Query decent nodes first.
let status = match n.status() {
let status = match n.status(now) {
NodeStatus::Good => 0,
NodeStatus::Questionable => 0,
NodeStatus::Questionable => 1,
NodeStatus::Unknown => 2,
NodeStatus::Bad => 3,
};

View file

@ -1,6 +1,6 @@
[package]
name = "librqbit"
version = "8.0.0"
version = "8.1.1"
authors = ["Igor Katson <igor.katson@gmail.com>"]
edition = "2021"
description = "The main library used by rqbit torrent client. The binary is just a small wrapper on top of it."
@ -49,14 +49,14 @@ sqlx = { version = "0.8", features = [
], default-features = false, optional = true }
home = { version = "0.5", optional = true }
bencode = { path = "../bencode", default-features = false, package = "librqbit-bencode", version = "3" }
tracker_comms = { path = "../tracker_comms", default-features = false, package = "librqbit-tracker-comms", version = "2.1" }
bencode = { path = "../bencode", default-features = false, package = "librqbit-bencode", version = "3.1" }
tracker_comms = { path = "../tracker_comms", default-features = false, package = "librqbit-tracker-comms", version = "3" }
buffers = { path = "../buffers", package = "librqbit-buffers", version = "4.2" }
librqbit-core = { path = "../librqbit_core", default-features = false, version = "5" }
clone_to_owned = { path = "../clone_to_owned", package = "librqbit-clone-to-owned", version = "3" }
peer_binary_protocol = { path = "../peer_binary_protocol", default-features = false, package = "librqbit-peer-protocol", version = "4.2" }
peer_binary_protocol = { path = "../peer_binary_protocol", default-features = false, package = "librqbit-peer-protocol", version = "4.3" }
sha1w = { path = "../sha1w", default-features = false, package = "librqbit-sha1-wrapper", version = "4.1" }
dht = { path = "../dht", package = "librqbit-dht", default-features = false, version = "5.2.0" }
dht = { path = "../dht", package = "librqbit-dht", default-features = false, version = "5.3.1" }
librqbit-upnp = { path = "../upnp", version = "1" }
upnp-serve = { path = "../upnp-serve", package = "librqbit-upnp-serve", default-features = false, version = "1", optional = true }

View file

@ -566,9 +566,13 @@ fn make_torrent_details(
Ok(TorrentDetailsResponse {
id,
info_hash: info_hash.as_string(),
name: name
.map(|s| s.to_owned())
.or_else(|| info.and_then(|i| i.name.as_ref().map(|b| b.to_string()))),
name: name.map(|s| s.to_owned()).or_else(|| {
info.and_then(|i| {
i.name
.as_ref()
.map(|b| String::from_utf8_lossy(b.as_ref()).into())
})
}),
files: Some(files),
output_folder,
stats: None,

View file

@ -1006,11 +1006,12 @@ impl Session {
}
if let Some(name) = &info.name {
let s =
std::str::from_utf8(name.as_slice()).context("invalid UTF-8 in torrent name")?;
let pb = PathBuf::from(s);
check_valid(&pb)?;
return Ok(Some(pb));
let s = String::from_utf8_lossy(name.as_slice());
if !s.is_empty() {
let pb = PathBuf::from(s.as_ref());
check_valid(&pb)?;
return Ok(Some(pb));
}
};
if let Some(name) = magnet_name {
let pb = PathBuf::from(name);
@ -1472,6 +1473,9 @@ pub(crate) struct ResolveMagnetResult {
fn remove_files_and_dirs(infos: &FileInfos, files: &dyn TorrentStorage) {
let mut all_dirs = HashSet::new();
for (id, fi) in infos.iter().enumerate() {
if fi.attrs.padding {
continue;
}
let mut fname = &*fi.relative_filename;
if let Err(e) = files.remove_file(id, fname) {
warn!(?fi.relative_filename, error=?e, "could not delete file");

View file

@ -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>;

View file

@ -25,13 +25,13 @@ type FileTree = {
const newFileTree = (
torrentDetails: TorrentDetails,
stats: TorrentStats | null,
stats: TorrentStats | null
): FileTree => {
const newFileTreeInner = (
name: string,
id: string,
files: TorrentFileForCheckbox[],
depth: number,
depth: number
): FileTree => {
let directFiles: TorrentFileForCheckbox[] = [];
let groups: FileTree[] = [];
@ -54,7 +54,7 @@ const newFileTree = (
let sortedGroupsByName = sortBy(
Object.entries(groupsByName),
([k, _]) => k,
([k, _]) => k
);
let childId = 0;
@ -87,7 +87,7 @@ const newFileTree = (
};
})
.filter((f) => f !== null),
0,
0
);
};
@ -177,7 +177,7 @@ const FileTreeComponent: React.FC<{
label={`${
tree.name ? tree.name + ", " : ""
} ${getTotalSelectedFiles()} files, ${formatBytes(
getTotalSelectedBytes(),
getTotalSelectedBytes()
)}`}
name={tree.id}
onChange={handleToggleTree}
@ -253,7 +253,7 @@ export const FileListInput: React.FC<{
}) => {
let fileTree = useMemo(
() => newFileTree(torrentDetails, torrentStats),
[torrentDetails, torrentStats],
[torrentDetails, torrentStats]
);
return (

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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,
@ -39,7 +43,15 @@ export const FileSelectionModal = (props: {
useEffect(() => {
setSelectedFiles(
new Set(listTorrentResponse?.details.files.map((_, i) => i)),
new Set(
listTorrentResponse?.details.files.flatMap((file, idx) => {
if (file.attributes.padding) {
return [];
} else {
return [idx];
}
})
)
);
setOutputFolder(listTorrentResponse?.output_folder || "");
}, [listTorrentResponse]);
@ -79,7 +91,7 @@ export const FileSelectionModal = (props: {
},
(e) => {
setUploadError({ text: "Error starting torrent", details: e });
},
}
)
.finally(() => setUploading(false));
};

View file

@ -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";

View file

@ -24,7 +24,7 @@ rand = "0.9"
parking_lot = "0.12"
serde = { version = "1", features = ["derive"] }
buffers = { path = "../buffers", package = "librqbit-buffers", version = "4.2" }
bencode = { path = "../bencode", default-features = false, package = "librqbit-bencode", version = "3.0.1" }
bencode = { path = "../bencode", default-features = false, package = "librqbit-bencode", version = "3.1" }
clone_to_owned = { path = "../clone_to_owned", package = "librqbit-clone-to-owned", version = "3" }
itertools = "0.14"
directories = "6"

View file

@ -1,8 +1,8 @@
use data_encoding::BASE32;
use serde::{Deserialize, Deserializer, Serialize};
use std::{cmp::Ordering, str::FromStr};
use std::str::FromStr;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Id<const N: usize>(pub [u8; N]);
impl<const N: usize> Id<N> {
@ -166,25 +166,6 @@ impl<'de, const N: usize> Deserialize<'de> for Id<N> {
}
}
impl<const N: usize> PartialOrd<Id<N>> for Id<N> {
fn partial_cmp(&self, other: &Id<N>) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl<const N: usize> Ord for Id<N> {
fn cmp(&self, other: &Id<N>) -> Ordering {
for (s, o) in self.0.iter().copied().zip(other.0.iter().copied()) {
match s.cmp(&o) {
Ordering::Less => return Ordering::Less,
Ordering::Equal => continue,
Ordering::Greater => return Ordering::Greater,
}
}
Ordering::Equal
}
}
/// A 20-byte hash used throughout librqbit, for torrent info hashes, peer ids etc.
pub type Id20 = Id<20>;
/// A 32-byte hash used in Bittorrent V2, for torrent info hashes, piece hashing, etc.

View file

@ -1,6 +1,6 @@
[package]
name = "librqbit-peer-protocol"
version = "4.2.0"
version = "4.3.0"
edition = "2021"
description = "Protocol for working with torrent peers. Used in rqbit torrent client."
license = "Apache-2.0"
@ -22,7 +22,7 @@ serde = { version = "1", features = ["derive"] }
bincode = "1"
byteorder = "1"
buffers = { path = "../buffers", package = "librqbit-buffers", version = "4.2" }
bencode = { path = "../bencode", default-features = false, package = "librqbit-bencode", version = "3" }
bencode = { path = "../bencode", default-features = false, package = "librqbit-bencode", version = "3.1" }
clone_to_owned = { path = "../clone_to_owned", package = "librqbit-clone-to-owned", version = "3" }
librqbit-core = { path = "../librqbit_core", default-features = false, version = "5" }
bitvec = "1"

View file

@ -1,6 +1,6 @@
[package]
name = "rqbit"
version = "8.0.0"
version = "8.1.1"
authors = ["Igor Katson <igor.katson@gmail.com>"]
edition = "2021"
description = "A bittorrent command line client and server."
@ -24,7 +24,7 @@ postgres = ["librqbit/postgres"]
disable-upload = ["librqbit/disable-upload"]
[dependencies]
librqbit = { version = "8.0.0", path = "../librqbit", default-features = false, features = [
librqbit = { version = "8.1.1", path = "../librqbit", default-features = false, features = [
"http-api",
"http-api-client",
"tracing-subscriber-utils",

View file

@ -1,6 +1,6 @@
[package]
name = "librqbit-tracker-comms"
version = "2.1.0"
version = "3.0.0"
edition = "2018"
description = "Common interface around various sha1 implementations used in rqbit torrent client."
license = "Apache-2.0"
@ -30,7 +30,7 @@ urlencoding = "2"
rand = "0.9"
tracing = "0.1.40"
reqwest = { version = "0.12", default-features = false, features = ["json"] }
bencode = { path = "../bencode", default-features = false, package = "librqbit-bencode", version = "3" }
bencode = { path = "../bencode", default-features = false, package = "librqbit-bencode", version = "3.1" }
url = { version = "2", default-features = false }
parking_lot = "0.12.3"
tokio-util = "0.7.13"

View file

@ -1,6 +1,6 @@
[package]
name = "librqbit-upnp-serve"
version = "1.0.0"
version = "1.0.1"
edition = "2021"
description = "Simple UPnP MediaServer implementation"
license = "Apache-2.0"

1286
desktop/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,25 +9,25 @@
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.0.1",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-shell": "^2.2.1",
"lodash.debounce": "^4.0.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0",
"rqbit-webui": "file:../crates/librqbit/webui"
},
"devDependencies": {
"@tauri-apps/cli": "2.0.0",
"@types/lodash.debounce": "^4.0.9",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.0.2",
"vite": "^5.4.8",
"vite-plugin-svgr": "^4.2.0"
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.5.1",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.4",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-plugin-svgr": "^4.3.0"
}
}

View file

@ -1,6 +1,6 @@
[package]
name = "rqbit-desktop"
version = "8.0.0"
version = "8.1.1"
description = "rqbit torrent client"
authors = ["you"]
license = ""
@ -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

View file

@ -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: &gtk::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: &gtk::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,

View 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}
/>
);
};

View file

@ -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,

View file

@ -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}