From a3475784e994ba6057a2362845fe49969355c38e Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Wed, 6 Dec 2023 14:30:32 +0000 Subject: [PATCH] Initial commit for desktop configuration. Broken now --- crates/dht/src/persistence.rs | 18 ++-- crates/librqbit/src/api.rs | 1 + crates/librqbit/src/api_error.rs | 11 ++ crates/librqbit/src/session.rs | 16 ++- desktop/src-tauri/Cargo.lock | 2 + desktop/src-tauri/Cargo.toml | 2 + desktop/src-tauri/src/config.rs | 132 +++++++++++++++++++++++ desktop/src-tauri/src/main.rs | 179 +++++++++++++++++++++++-------- 8 files changed, 304 insertions(+), 57 deletions(-) create mode 100644 desktop/src-tauri/src/config.rs diff --git a/crates/dht/src/persistence.rs b/crates/dht/src/persistence.rs index 2c002bb..b931910 100644 --- a/crates/dht/src/persistence.rs +++ b/crates/dht/src/persistence.rs @@ -68,18 +68,24 @@ fn dump_dht(dht: &Dht, filename: &Path, tempfile_name: &Path) -> anyhow::Result< } impl PersistentDht { + pub fn default_persistence_filename() -> anyhow::Result { + let dirs = get_configuration_directory("dht")?; + let path = dirs.cache_dir().join("dht.json"); + Ok(path) + } + pub async fn create(config: Option) -> anyhow::Result { let mut config = config.unwrap_or_default(); let config_filename = match config.config_filename.take() { Some(config_filename) => config_filename, - None => { - let dirs = get_configuration_directory("dht")?; - let path = dirs.cache_dir().join("dht.json"); - info!("will store DHT routing table to {:?} periodically", &path); - path - } + None => Self::default_persistence_filename()?, }; + info!( + "will store DHT routing table to {:?} periodically", + &config_filename + ); + if let Some(parent) = config_filename.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("error creating dir {:?}", &parent))?; diff --git a/crates/librqbit/src/api.rs b/crates/librqbit/src/api.rs index bdfb41b..8e3f34a 100644 --- a/crates/librqbit/src/api.rs +++ b/crates/librqbit/src/api.rs @@ -26,6 +26,7 @@ pub type Result = std::result::Result; /// Library API for use in different web frameworks. /// Contains all methods you might want to expose with (de)serializable inputs/outputs. +#[derive(Clone)] pub struct Api { session: Arc, rust_log_reload_tx: Option>, diff --git a/crates/librqbit/src/api_error.rs b/crates/librqbit/src/api_error.rs index 7f7e4f4..82b5e27 100644 --- a/crates/librqbit/src/api_error.rs +++ b/crates/librqbit/src/api_error.rs @@ -27,6 +27,14 @@ impl ApiError { } } + pub const fn new_from_text(status: StatusCode, text: &'static str) -> Self { + Self { + status: Some(status), + kind: ApiErrorKind::Text(text), + plaintext: false, + } + } + #[allow(dead_code)] pub fn not_implemented(msg: &str) -> Self { Self { @@ -69,6 +77,7 @@ impl ApiError { enum ApiErrorKind { TorrentNotFound(usize), DhtDisabled, + Text(&'static str), Other(anyhow::Error), } @@ -91,6 +100,7 @@ impl Serialize for ApiError { ApiErrorKind::TorrentNotFound(_) => "torrent_not_found", ApiErrorKind::DhtDisabled => "dht_disabled", ApiErrorKind::Other(_) => "internal_error", + ApiErrorKind::Text(_) => "internal_error", }, human_readable: format!("{self}"), status: self.status().as_u16(), @@ -130,6 +140,7 @@ impl std::fmt::Display for ApiError { ApiErrorKind::TorrentNotFound(idx) => write!(f, "torrent {idx} not found"), ApiErrorKind::Other(err) => write!(f, "{err:?}"), ApiErrorKind::DhtDisabled => write!(f, "DHT is disabled"), + ApiErrorKind::Text(t) => write!(f, "{t}"), } } } diff --git a/crates/librqbit/src/session.rs b/crates/librqbit/src/session.rs index dcc9bd6..1b74d88 100644 --- a/crates/librqbit/src/session.rs +++ b/crates/librqbit/src/session.rs @@ -373,6 +373,11 @@ impl Session { Self::new_with_opts(output_folder, SessionOptions::default()).await } + pub fn default_persistence_filename() -> anyhow::Result { + let dir = get_configuration_directory("session")?; + Ok(dir.data_dir().join("session.json")) + } + /// Create a new session with options. pub async fn new_with_opts( output_folder: PathBuf, @@ -405,9 +410,7 @@ impl Session { let peer_opts = opts.peer_opts.unwrap_or_default(); let persistence_filename = match opts.persistence_filename { Some(filename) => filename, - None => get_configuration_directory("session")? - .data_dir() - .join("session.json"), + None => Self::default_persistence_filename()?, }; let spawner = BlockingSpawner::default(); @@ -608,7 +611,8 @@ impl Session { } } - fn spawn( + /// Spawn a task in the context of the session. + pub fn spawn( &self, name: &str, span: tracing::Span, @@ -626,7 +630,9 @@ impl Session { }); } - pub fn stop(&self) { + /// Stop the session and all managed tasks. + // TODO: this probably doesn't kill everything properly. + pub async fn stop(&self) { let _ = self.cancel_tx.send(()); } diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 0c7c4be..6c31929 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -3015,8 +3015,10 @@ dependencies = [ "directories", "http 1.0.0", "librqbit", + "parking_lot", "serde", "serde_json", + "serde_with", "tauri", "tauri-build", "tokio", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 5a1f860..317b51b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -24,6 +24,8 @@ http = "1.0.0" directories = "5.0.1" tracing-subscriber = {version = "0.3.18", features = ["env-filter"] } tracing = "0.1" +serde_with = "3.4.0" +parking_lot = "0.12.1" [features] # this feature is used for production builds or when `devPath` points to the filesystem diff --git a/desktop/src-tauri/src/config.rs b/desktop/src-tauri/src/config.rs new file mode 100644 index 0000000..d5449b7 --- /dev/null +++ b/desktop/src-tauri/src/config.rs @@ -0,0 +1,132 @@ +use std::{ + net::{Ipv4Addr, SocketAddr, SocketAddrV4}, + path::PathBuf, + time::Duration, +}; + +use librqbit::{dht::PersistentDht, Session}; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; + +#[derive(Clone, Serialize, Deserialize)] +pub struct RqbitDesktopConfigDht { + pub disable: bool, + pub disable_persistence: bool, + pub persistence_filename: PathBuf, +} + +impl Default for RqbitDesktopConfigDht { + fn default() -> Self { + Self { + disable: false, + disable_persistence: false, + persistence_filename: PersistentDht::default_persistence_filename().unwrap(), + } + } +} + +#[derive(Clone, Copy, Serialize, Deserialize)] +pub struct RqbitDesktopConfigTcpListen { + pub disable: bool, + pub min_port: u16, + pub max_port: u16, +} + +impl Default for RqbitDesktopConfigTcpListen { + fn default() -> Self { + Self { + disable: false, + // TODO: use consts from librqbit + min_port: 4240, + max_port: 4260, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RqbitDesktopConfigPersistence { + pub disable: bool, + pub filename: PathBuf, +} + +impl Default for RqbitDesktopConfigPersistence { + fn default() -> Self { + Self { + disable: false, + filename: Session::default_persistence_filename().unwrap(), + } + } +} + +#[serde_as] +#[derive(Clone, Copy, Serialize, Deserialize)] +pub struct RqbitDesktopConfigPeerOpts { + #[serde_as(as = "serde_with::DurationSeconds")] + pub connect_timeout: Duration, + + #[serde_as(as = "serde_with::DurationSeconds")] + pub read_write_timeout: Duration, +} + +impl Default for RqbitDesktopConfigPeerOpts { + fn default() -> Self { + Self { + connect_timeout: Duration::from_secs(2), + read_write_timeout: Duration::from_secs(10), + } + } +} + +#[serde_as] +#[derive(Clone, Serialize, Deserialize)] +pub struct RqbitDesktopConfigHttpApi { + pub disable: bool, + pub listen_addr: SocketAddr, + pub read_only: bool, +} + +impl Default for RqbitDesktopConfigHttpApi { + fn default() -> Self { + Self { + disable: Default::default(), + listen_addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 3030)), + read_only: false, + } + } +} + +#[derive(Clone, Copy, Default, Serialize, Deserialize)] +pub struct RqbitDesktopConfigUpnp { + pub disable: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RqbitDesktopConfig { + pub default_download_location: PathBuf, + pub dht: RqbitDesktopConfigDht, + pub tcp_listen: RqbitDesktopConfigTcpListen, + pub upnp: RqbitDesktopConfigUpnp, + pub persistence: RqbitDesktopConfigPersistence, + pub peer_opts: RqbitDesktopConfigPeerOpts, + pub http_api: RqbitDesktopConfigHttpApi, +} + +impl Default for RqbitDesktopConfig { + fn default() -> Self { + let download_folder = directories::UserDirs::new() + .expect("directories::UserDirs::new()") + .download_dir() + .expect("download_dir()") + .to_path_buf(); + + Self { + default_download_location: download_folder, + dht: Default::default(), + tcp_listen: Default::default(), + upnp: Default::default(), + persistence: Default::default(), + peer_opts: Default::default(), + http_api: Default::default(), + } + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 9cb1621..68b4c1a 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,24 +1,134 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod config; + +use std::sync::Arc; + use anyhow::Context; +use config::RqbitDesktopConfig; use http::StatusCode; use librqbit::{ api::{ ApiAddTorrentResponse, EmptyJsonResponse, TorrentDetailsResponse, TorrentListResponse, TorrentStats, }, - librqbit_spawn, AddTorrent, AddTorrentOptions, Api, ApiError, Session, SessionOptions, + dht::PersistentDhtConfig, + librqbit_spawn, AddTorrent, AddTorrentOptions, Api, ApiError, PeerConnectionOptions, Session, + SessionOptions, }; +use parking_lot::RwLock; use tracing::error_span; -struct State { +const ERR_NOT_CONFIGURED: ApiError = + ApiError::new_from_text(StatusCode::FAILED_DEPENDENCY, "not configured"); + +struct StateShared { + config: config::RqbitDesktopConfig, api: Api, + session: Arc, +} + +impl StateShared {} + +struct State { + shared: Arc>>, + rust_log_reload_tx: tokio::sync::mpsc::UnboundedSender, +} + +impl State { + fn api(&self) -> Result { + let g = self.shared.read(); + match &*g { + Some(s) => Ok(s.api.clone()), + None => Err(ERR_NOT_CONFIGURED), + } + } + + fn current_config(&self) -> Option { + self.shared.read().as_ref().map(|s| s.config.clone()) + } + + async fn configure(&self, config: RqbitDesktopConfig) -> Result<(), ApiError> { + let existing = self.shared.write().take(); + + if let Some(existing) = existing { + existing.session.stop().await; + } + + let config_clone = config.clone(); + + let session = Session::new_with_opts( + config.default_download_location, + SessionOptions { + disable_dht: config.dht.disable, + disable_dht_persistence: config.dht.disable_persistence, + dht_config: Some(PersistentDhtConfig { + config_filename: Some(config.dht.persistence_filename), + ..Default::default() + }), + persistence: !config.persistence.disable, + persistence_filename: Some(config.persistence.filename), + peer_opts: Some(PeerConnectionOptions { + connect_timeout: Some(config.peer_opts.connect_timeout), + read_write_timeout: Some(config.peer_opts.read_write_timeout), + ..Default::default() + }), + listen_port_range: if !config.tcp_listen.disable { + Some(config.tcp_listen.min_port..config.tcp_listen.max_port) + } else { + None + }, + enable_upnp_port_forwarding: !config.upnp.disable, + ..Default::default() + }, + ) + .await + .context("couldn't set up librqbit session")?; + + let api = Api::new(session.clone(), None); + + if !config.http_api.disable { + let http_api_task = librqbit::http_api::HttpApi::new( + session.clone(), + Some(self.rust_log_reload_tx.clone()), + ) + .make_http_api_and_run(config.http_api.listen_addr, config.http_api.read_only); + + session.spawn("http api", error_span!("http_api"), http_api_task); + } + + *self.shared.write() = Some(StateShared { + config: config_clone, + api, + session, + }); + + Ok(()) + } } #[tauri::command] -fn torrents_list(state: tauri::State) -> TorrentListResponse { - state.api.api_torrent_list() +fn config_default() -> config::RqbitDesktopConfig { + config::RqbitDesktopConfig::default() +} + +#[tauri::command] +fn config_current(state: tauri::State<'_, State>) -> Option { + state.current_config() +} + +#[tauri::command] +async fn config_change( + state: tauri::State<'_, State>, + config: RqbitDesktopConfig, +) -> Result { + state.configure(config).await.map(|_| EmptyJsonResponse {}) +} + +#[tauri::command] +fn torrents_list(state: tauri::State) -> Result { + Ok(state.api()?.api_torrent_list()) } #[tauri::command] @@ -28,7 +138,7 @@ async fn torrent_create_from_url( opts: Option, ) -> Result { state - .api + .api()? .api_add_torrent(AddTorrent::Url(url.into()), opts) .await } @@ -45,7 +155,7 @@ async fn torrent_create_from_base64_file( .context("invalid base64") .map_err(|e| ApiError::new_from_anyhow(StatusCode::BAD_REQUEST, e))?; state - .api + .api()? .api_add_torrent(AddTorrent::TorrentFileBytes(bytes.into()), opts) .await } @@ -55,7 +165,7 @@ async fn torrent_details( state: tauri::State<'_, State>, id: usize, ) -> Result { - state.api.api_torrent_details(id) + state.api()?.api_torrent_details(id) } #[tauri::command] @@ -63,7 +173,7 @@ async fn torrent_stats( state: tauri::State<'_, State>, id: usize, ) -> Result { - state.api.api_stats_v1(id) + state.api()?.api_stats_v1(id) } #[tauri::command] @@ -71,7 +181,7 @@ async fn torrent_action_delete( state: tauri::State<'_, State>, id: usize, ) -> Result { - state.api.api_torrent_action_delete(id) + state.api()?.api_torrent_action_delete(id) } #[tauri::command] @@ -79,7 +189,7 @@ async fn torrent_action_pause( state: tauri::State<'_, State>, id: usize, ) -> Result { - state.api.api_torrent_action_pause(id) + state.api()?.api_torrent_action_pause(id) } #[tauri::command] @@ -87,7 +197,7 @@ async fn torrent_action_forget( state: tauri::State<'_, State>, id: usize, ) -> Result { - state.api.api_torrent_action_forget(id) + state.api()?.api_torrent_action_forget(id) } #[tauri::command] @@ -95,7 +205,7 @@ async fn torrent_action_start( state: tauri::State<'_, State>, id: usize, ) -> Result { - state.api.api_torrent_action_start(id) + state.api()?.api_torrent_action_start(id) } #[tauri::command] @@ -133,41 +243,15 @@ fn init_logging() -> tokio::sync::mpsc::UnboundedSender { reload_tx } -async fn start_session() { +async fn start() { + tauri::async_runtime::set(tokio::runtime::Handle::current()); let rust_log_reload_tx = init_logging(); - tauri::async_runtime::set(tokio::runtime::Handle::current()); - - let download_folder = directories::UserDirs::new() - .expect("directories::UserDirs::new()") - .download_dir() - .expect("download_dir()") - .to_path_buf(); - - let session = Session::new_with_opts( - download_folder, - SessionOptions { - disable_dht: false, - disable_dht_persistence: false, - persistence: true, - listen_port_range: Some(4240..4260), - ..Default::default() - }, - ) - .await - .expect("couldn't set up librqbit session"); - - let api = Api::new(session.clone(), None); - - librqbit_spawn( - "http api", - error_span!("http_api"), - librqbit::http_api::HttpApi::new(session, Some(rust_log_reload_tx)) - .make_http_api_and_run("127.0.0.1:3030".parse().unwrap(), false), - ); - tauri::Builder::default() - .manage(State { api }) + .manage(State { + shared: Arc::new(RwLock::new(None)), + rust_log_reload_tx, + }) .invoke_handler(tauri::generate_handler![ torrents_list, torrent_details, @@ -178,7 +262,10 @@ async fn start_session() { torrent_action_forget, torrent_action_start, torrent_create_from_base64_file, - get_version + get_version, + config_default, + config_current, + config_change, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -189,5 +276,5 @@ fn main() { .enable_all() .build() .expect("couldn't set up tokio runtime") - .block_on(start_session()) + .block_on(start()) }