feat(desktop): improve torrent file integration

This commit is contained in:
Lionel DARNIS 2026-05-30 10:45:45 +02:00
parent 00b9748516
commit a1d4aab93f
12 changed files with 717 additions and 18 deletions

View file

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