From 53868ad45e4b50ca53284bc64381e1e71d8be8db Mon Sep 17 00:00:00 2001 From: Igor Katson Date: Thu, 7 Dec 2023 00:13:11 +0000 Subject: [PATCH] Desktop state persistence --- crates/librqbit/src/api.rs | 4 + desktop/src-tauri/src/config.rs | 14 +-- desktop/src-tauri/src/main.rs | 206 ++++++++++++++++++++++---------- desktop/src/configuration.tsx | 5 + desktop/src/configure.tsx | 2 + desktop/src/main.tsx | 10 +- desktop/src/rqbit-desktop.tsx | 9 +- 7 files changed, 171 insertions(+), 79 deletions(-) diff --git a/crates/librqbit/src/api.rs b/crates/librqbit/src/api.rs index 8e3f34a..3c9454d 100644 --- a/crates/librqbit/src/api.rs +++ b/crates/librqbit/src/api.rs @@ -40,6 +40,10 @@ impl Api { } } + pub fn session(&self) -> &Arc { + &self.session + } + pub fn mgr_handle(&self, idx: TorrentId) -> Result { self.session .get(idx) diff --git a/desktop/src-tauri/src/config.rs b/desktop/src-tauri/src/config.rs index d5449b7..6e4c8a0 100644 --- a/desktop/src-tauri/src/config.rs +++ b/desktop/src-tauri/src/config.rs @@ -8,7 +8,7 @@ use librqbit::{dht::PersistentDht, Session}; use serde::{Deserialize, Serialize}; use serde_with::serde_as; -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RqbitDesktopConfigDht { pub disable: bool, pub disable_persistence: bool, @@ -25,7 +25,7 @@ impl Default for RqbitDesktopConfigDht { } } -#[derive(Clone, Copy, Serialize, Deserialize)] +#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub struct RqbitDesktopConfigTcpListen { pub disable: bool, pub min_port: u16, @@ -43,7 +43,7 @@ impl Default for RqbitDesktopConfigTcpListen { } } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RqbitDesktopConfigPersistence { pub disable: bool, pub filename: PathBuf, @@ -59,7 +59,7 @@ impl Default for RqbitDesktopConfigPersistence { } #[serde_as] -#[derive(Clone, Copy, Serialize, Deserialize)] +#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub struct RqbitDesktopConfigPeerOpts { #[serde_as(as = "serde_with::DurationSeconds")] pub connect_timeout: Duration, @@ -78,7 +78,7 @@ impl Default for RqbitDesktopConfigPeerOpts { } #[serde_as] -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RqbitDesktopConfigHttpApi { pub disable: bool, pub listen_addr: SocketAddr, @@ -95,12 +95,12 @@ impl Default for RqbitDesktopConfigHttpApi { } } -#[derive(Clone, Copy, Default, Serialize, Deserialize)] +#[derive(Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct RqbitDesktopConfigUpnp { pub disable: bool, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RqbitDesktopConfig { pub default_download_location: PathBuf, pub dht: RqbitDesktopConfigDht, diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 68b4c1a..7978cdc 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -3,7 +3,11 @@ mod config; -use std::sync::Arc; +use std::{ + fs::{File, OpenOptions}, + io::{BufReader, BufWriter}, + sync::Arc, +}; use anyhow::Context; use config::RqbitDesktopConfig; @@ -18,104 +22,177 @@ use librqbit::{ SessionOptions, }; use parking_lot::RwLock; -use tracing::error_span; +use serde::Serialize; +use tracing::{error, error_span}; const ERR_NOT_CONFIGURED: ApiError = ApiError::new_from_text(StatusCode::FAILED_DEPENDENCY, "not configured"); struct StateShared { config: config::RqbitDesktopConfig, - api: Api, - session: Arc, + api: Option, } +type RustLogReloadTx = tokio::sync::mpsc::UnboundedSender; + impl StateShared {} struct State { + config_filename: String, shared: Arc>>, - rust_log_reload_tx: tokio::sync::mpsc::UnboundedSender, + rust_log_reload_tx: RustLogReloadTx, +} + +fn read_config(path: &str) -> anyhow::Result { + let rdr = BufReader::new(File::open(path)?); + Ok(serde_json::from_reader(rdr)?) +} + +fn write_config(path: &str, config: &RqbitDesktopConfig) -> anyhow::Result<()> { + let tmp = format!("{}.tmp", path); + let mut tmp_file = BufWriter::new( + OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(&tmp)?, + ); + serde_json::to_writer(&mut tmp_file, config)?; + std::fs::rename(tmp, path)?; + Ok(()) +} + +async fn api_from_config( + rust_log_reload_tx: &RustLogReloadTx, + config: &RqbitDesktopConfig, +) -> anyhow::Result { + let session = Session::new_with_opts( + config.default_download_location.clone(), + SessionOptions { + disable_dht: config.dht.disable, + disable_dht_persistence: config.dht.disable_persistence, + dht_config: Some(PersistentDhtConfig { + config_filename: Some(config.dht.persistence_filename.clone()), + ..Default::default() + }), + persistence: !config.persistence.disable, + persistence_filename: Some(config.persistence.filename.clone()), + 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(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); + } + Ok(api) } impl State { + async fn new(rust_log_reload_tx: tokio::sync::mpsc::UnboundedSender) -> Self { + let config_filename = directories::ProjectDirs::from("com", "rqbit", "desktop") + .expect("directories::ProjectDirs::from") + .config_dir() + .to_str() + .expect("to_str()") + .to_owned(); + + if let Ok(config) = read_config(&config_filename) { + let api = api_from_config(&rust_log_reload_tx, &config).await.ok(); + let shared = Arc::new(RwLock::new(Some(StateShared { config, api }))); + + return Self { + config_filename, + shared, + rust_log_reload_tx, + }; + } + + Self { + config_filename, + rust_log_reload_tx, + shared: Arc::new(RwLock::new(None)), + } + } + fn api(&self) -> Result { let g = self.shared.read(); - match &*g { - Some(s) => Ok(s.api.clone()), + match g.as_ref().and_then(|s| s.api.as_ref()) { + Some(api) => Ok(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 g = self.shared.read(); + if let Some(shared) = g.as_ref() { + if shared.api.is_some() && shared.config == config { + // The config didn't change, and the API is running, nothing to do. + return Ok(()); + } + } } - let config_clone = config.clone(); + let existing = self.shared.write().as_mut().and_then(|s| s.api.take()); - 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); + if let Some(api) = existing { + api.session().stop().await; } - *self.shared.write() = Some(StateShared { - config: config_clone, - api, - session, + let api = api_from_config(&self.rust_log_reload_tx, &config).await?; + if let Err(e) = write_config(&self.config_filename, &config) { + error!("error writing config: {:#}", e); + } + + let mut g = self.shared.write(); + *g = Some(StateShared { + config, + api: Some(api), }); - Ok(()) } } +#[derive(Default, Serialize)] +struct CurrentState { + config: Option, + configured: bool, +} + #[tauri::command] fn config_default() -> config::RqbitDesktopConfig { config::RqbitDesktopConfig::default() } #[tauri::command] -fn config_current(state: tauri::State<'_, State>) -> Option { - state.current_config() +fn config_current(state: tauri::State<'_, State>) -> CurrentState { + let g = state.shared.read(); + match &*g { + Some(s) => CurrentState { + config: Some(s.config.clone()), + configured: s.api.is_some(), + }, + None => Default::default(), + } } #[tauri::command] @@ -247,11 +324,10 @@ async fn start() { tauri::async_runtime::set(tokio::runtime::Handle::current()); let rust_log_reload_tx = init_logging(); + let state = State::new(rust_log_reload_tx).await; + tauri::Builder::default() - .manage(State { - shared: Arc::new(RwLock::new(None)), - rust_log_reload_tx, - }) + .manage(state) .invoke_handler(tauri::generate_handler![ torrents_list, torrent_details, diff --git a/desktop/src/configuration.tsx b/desktop/src/configuration.tsx index a21552f..da39cc3 100644 --- a/desktop/src/configuration.tsx +++ b/desktop/src/configuration.tsx @@ -43,3 +43,8 @@ export interface RqbitDesktopConfig { peer_opts: RqbitDesktopConfigPeerOpts; http_api: RqbitDesktopConfigHttpApi; } + +export interface CurrentDesktopState { + config: RqbitDesktopConfig | null, + configured: boolean, +} \ No newline at end of file diff --git a/desktop/src/configure.tsx b/desktop/src/configure.tsx index 9044abc..fe36eee 100644 --- a/desktop/src/configure.tsx +++ b/desktop/src/configure.tsx @@ -164,6 +164,7 @@ export const ConfigModal: React.FC<{ name="dht.disable_persistence" checked={!config.dht.disable_persistence} onChange={handleToggleChange} + disabled={config.dht.disable} help="Enable to store DHT state in a file periodically. If disabled, DHT will bootstrap from scratch on restart." /> @@ -172,6 +173,7 @@ export const ConfigModal: React.FC<{ name="dht.persistence_filename" value={config.dht.persistence_filename} inputType="text" + disabled={config.dht.disable} onChange={handleInputChange} help="The filename to store DHT state into" /> diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 1f7be92..5d68306 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client'; import { APIContext, RqbitWebUI } from "./rqbit-webui-src/rqbit-web"; import { API } from "./api"; import { invoke } from "@tauri-apps/api"; -import { RqbitDesktopConfig } from "./configuration"; +import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration"; import { RqbitDesktop } from "./rqbit-desktop"; async function get_version(): Promise { @@ -14,11 +14,15 @@ async function get_default_config(): Promise { return invoke("config_default"); } -Promise.all([get_version(), get_default_config()]).then(([version, config]) => { +async function get_current_config(): Promise { + return invoke("config_current"); +} + +Promise.all([get_version(), get_default_config(), get_current_config()]).then(([version, defaultConfig, currentState]) => { ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - + ); diff --git a/desktop/src/rqbit-desktop.tsx b/desktop/src/rqbit-desktop.tsx index a20699c..f61015d 100644 --- a/desktop/src/rqbit-desktop.tsx +++ b/desktop/src/rqbit-desktop.tsx @@ -1,15 +1,16 @@ import { useState } from "react"; import { RqbitWebUI } from "./rqbit-webui-src/rqbit-web"; -import { RqbitDesktopConfig } from "./configuration"; +import { CurrentDesktopState, RqbitDesktopConfig } from "./configuration"; import { ConfigModal } from "./configure"; export const RqbitDesktop: React.FC<{ version: string, defaultConfig: RqbitDesktopConfig, -}> = ({ version, defaultConfig }) => { - let [configured, setConfigured] = useState(false); - let [config, setConfig] = useState(defaultConfig); + currentState: CurrentDesktopState, +}> = ({ version, defaultConfig, currentState }) => { + let [configured, setConfigured] = useState(currentState.configured); + let [config, setConfig] = useState(currentState.config ?? defaultConfig); let [configurationOpened, setConfigurationOpened] = useState(false); return <>