Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 13 additions & 32 deletions rustatio-desktop/src/commands/instances.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
use rustatio_core::validation;
use rustatio_core::{
FakerConfig, FakerState, RatioFaker, RatioFakerHandle, TorrentInfo, TorrentSummary,
};
use rustatio_core::{FakerConfig, RatioFaker, RatioFakerHandle, TorrentInfo, TorrentSummary};
use rustatio_watch::InstanceSource;
use std::sync::Arc;
use tauri::{AppHandle, State};
Expand All @@ -25,27 +23,7 @@ pub async fn update_instance_config(
config: FakerConfig,
state: State<'_, AppState>,
) -> Result<(), String> {
let mut fakers = state.fakers.write().await;
let instance =
fakers.get_mut(&instance_id).ok_or_else(|| format!("Instance {instance_id} not found"))?;

let stats = instance.faker.stats_snapshot();
if matches!(stats.state, FakerState::Running | FakerState::Starting | FakerState::Paused) {
return Err("Cannot update config while faker is running".to_string());
}

instance.config = config;

instance
.faker
.update_config(instance.config.clone(), Some(state.http_client.clone()))
.await
.map_err(|e| format!("Failed to update faker config: {e}"))?;

drop(fakers);
state.refresh_peer_listener_port().await;

Ok(())
state.apply_instance_config(instance_id, config).await
}

#[tauri::command]
Expand Down Expand Up @@ -102,17 +80,20 @@ pub async fn list_instances(state: State<'_, AppState>) -> Result<Vec<InstanceIn
for (id, instance) in fakers.iter() {
let stats = instance.faker.stats_snapshot();
instances.push(InstanceInfo {
id: *id,
torrent_name: Some(instance.summary.name.clone()),
is_running: matches!(
stats.state,
FakerState::Starting | FakerState::Running | FakerState::Stopping
),
is_paused: matches!(stats.state, FakerState::Paused),
id: id.to_string(),
torrent: (*instance.summary).clone(),
config: instance.config.clone(),
stats,
created_at: instance.created_at,
source: match instance.source {
InstanceSource::Manual => "manual".to_string(),
InstanceSource::WatchFolder => "watch_folder".to_string(),
},
tags: instance.tags.clone(),
});
}

instances.sort_by_key(|i| i.id);
instances.sort_by_key(|i| i.id.parse::<u32>().unwrap_or(0));
Ok(instances)
}

Expand Down
15 changes: 4 additions & 11 deletions rustatio-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ mod commands;
mod logging;
mod persistence;
mod state;
#[cfg(test)]
mod state_tests;
mod watch;
#[cfg(test)]
mod watch_tests;
Expand Down Expand Up @@ -46,17 +48,8 @@ impl PeerLookup for DesktopPeerLookup {

/// Synchronous save for the exit handler (tokio runtime may be winding down)
fn save_state_sync(state: &AppState) {
let Ok(handle) = tokio::runtime::Handle::try_current() else {
log::warn!("No tokio runtime available for exit save");
return;
};

let result = tokio::task::block_in_place(|| {
handle.block_on(async move {
let persisted = state.build_persisted_state().await;
persistence::save_state(&persisted)
})
});
let persisted = state.build_persisted_state_blocking();
let result = persistence::save_state(&persisted);

match result {
Ok(()) => log::info!("Final state saved successfully"),
Expand Down
33 changes: 33 additions & 0 deletions rustatio-desktop/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(test)]
use std::sync::{Mutex, OnceLock};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedInstance {
Expand Down Expand Up @@ -74,12 +76,43 @@ impl Default for WatchSettings {
}

fn state_file_path() -> PathBuf {
if let Some(override_path) = test_state_file_path() {
return override_path;
}

std::env::var("HOME").map_or_else(
|_| PathBuf::from("desktop-state.json"),
|home| PathBuf::from(home).join(".config").join("rustatio").join("desktop-state.json"),
)
}

#[allow(clippy::missing_const_for_fn)]
fn test_state_file_path() -> Option<PathBuf> {
#[cfg(test)]
{
let guard = test_state_path_store().lock().ok()?;
guard.clone()
}

#[cfg(not(test))]
{
None
}
}

#[cfg(test)]
fn test_state_path_store() -> &'static Mutex<Option<PathBuf>> {
static TEST_PATH: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
TEST_PATH.get_or_init(|| Mutex::new(None))
}

#[cfg(test)]
pub fn set_test_state_file_path(path: Option<PathBuf>) {
if let Ok(mut guard) = test_state_path_store().lock() {
*guard = path;
}
}

pub fn load_state() -> PersistedState {
let path = state_file_path();

Expand Down
105 changes: 100 additions & 5 deletions rustatio-desktop/src/state.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use rustatio_core::{
FakerConfig, FakerState, PeerListenerService, PeerListenerStatus, RatioFakerHandle,
FakerConfig, FakerState, FakerStats, PeerListenerService, PeerListenerStatus, RatioFakerHandle,
TorrentInfo, TorrentSummary,
};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -28,10 +28,13 @@ pub struct FakerInstance {

#[derive(Clone, Serialize, Deserialize)]
pub struct InstanceInfo {
pub id: u32,
pub torrent_name: Option<String>,
pub is_running: bool,
pub is_paused: bool,
pub id: String,
pub torrent: TorrentSummary,
pub config: FakerConfig,
pub stats: FakerStats,
pub created_at: u64,
pub source: String,
pub tags: Vec<String>,
}

#[derive(Clone)]
Expand Down Expand Up @@ -152,10 +155,102 @@ impl AppState {
}
}

pub fn build_persisted_state_blocking(&self) -> PersistedState {
let fakers = self.fakers.blocking_read();
let next_id = *self.next_instance_id.blocking_read();
let now = persistence::now_timestamp();

let mut instances = HashMap::new();
for (id, instance) in fakers.iter() {
let stats = instance.faker.stats_snapshot();
let mut config = instance.config.clone();
config.completion_percent = stats.torrent_completion;
instances.insert(
*id,
PersistedInstance {
id: *id,
torrent: (*instance.summary).clone(),
config,
cumulative_uploaded: stats.uploaded,
cumulative_downloaded: stats.downloaded,
state: stats.state,
created_at: instance.created_at,
updated_at: now,
tags: instance.tags.clone(),
from_watch_folder: matches!(
instance.source,
rustatio_watch::InstanceSource::WatchFolder
),
},
);
}

let default_config = self.default_config.blocking_read().clone();
let watch_settings = self.watch_settings.blocking_read().clone();

PersistedState {
instances,
next_instance_id: next_id,
default_config,
watch_settings,
version: 1,
}
}

pub async fn save_state(&self) -> Result<(), String> {
let persisted = self.build_persisted_state().await;
persistence::save_state(&persisted)
}

pub async fn apply_instance_config(
&self,
instance_id: u32,
config: FakerConfig,
) -> Result<(), String> {
let old_config = {
let mut fakers = self.fakers.write().await;
let instance = fakers
.get_mut(&instance_id)
.ok_or_else(|| format!("Instance {instance_id} not found"))?;

let stats = instance.faker.stats_snapshot();
if matches!(
stats.state,
FakerState::Running | FakerState::Starting | FakerState::Paused
) {
return Err("Cannot update config while faker is running".to_string());
}

let old_config = instance.config.clone();
instance
.faker
.update_config(config.clone(), Some(self.http_client.clone()))
.await
.map_err(|e| format!("Failed to update faker config: {e}"))?;
instance.config = config.clone();
old_config
};

if let Err(err) = self.save_state().await {
let mut fakers = self.fakers.write().await;
if let Some(instance) = fakers.get_mut(&instance_id) {
instance.config = old_config.clone();
instance
.faker
.update_config(old_config, Some(self.http_client.clone()))
.await
.map_err(|e| {
format!("{err}; rollback failed while restoring previous config: {e}")
})?;
}

return Err(err);
}

self.refresh_peer_listener_port().await;

Ok(())
}
}

pub fn now_secs() -> u64 {
Expand Down
Loading
Loading