From 9428b076f06e21603857cc48aa6f8cf1b57193c2 Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Thu, 16 Mar 2023 14:14:27 +0100 Subject: [PATCH] feat(dtmm): Delay initial load Delays the loading of the configuration file and mod data, so that any error can be shown in the UI. Closes #72. --- crates/dtmm/src/controller/app.rs | 103 ++++++++++++++---------- crates/dtmm/src/controller/worker.rs | 24 ++++++ crates/dtmm/src/main.rs | 79 ++++++++++-------- crates/dtmm/src/state/data.rs | 22 ++--- crates/dtmm/src/state/delegate.rs | 24 +++++- crates/dtmm/src/ui/widget/controller.rs | 13 +-- crates/dtmm/src/util/config.rs | 31 ++++--- 7 files changed, 183 insertions(+), 113 deletions(-) diff --git a/crates/dtmm/src/controller/app.rs b/crates/dtmm/src/controller/app.rs index b4b69ca..3073b9b 100644 --- a/crates/dtmm/src/controller/app.rs +++ b/crates/dtmm/src/controller/app.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::io::{Cursor, ErrorKind, Read}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use color_eyre::eyre::{self, Context}; @@ -10,12 +10,12 @@ use druid::{FileInfo, ImageBuf}; use dtmt_shared::ModConfig; use nexusmods::Api as NexusApi; use tokio::fs::{self, DirEntry}; -use tokio::runtime::Runtime; use tokio_stream::wrappers::ReadDirStream; use tokio_stream::StreamExt; use zip::ZipArchive; -use crate::state::{ActionState, ModInfo, ModOrder, NexusInfo, PackageInfo}; +use crate::state::{ActionState, InitialLoadResult, ModInfo, ModOrder, NexusInfo, PackageInfo}; +use crate::util; use crate::util::config::{ConfigSerialize, LoadOrderEntry}; use super::read_sjson_file; @@ -198,7 +198,7 @@ pub(crate) async fn save_settings(state: ActionState) -> Result<()> { .await .wrap_err_with(|| { format!( - "failed to write config to '{}'", + "Failed to write config to '{}'", state.config_path.display() ) }) @@ -269,51 +269,47 @@ async fn read_mod_dir_entry(res: Result) -> Result { } #[tracing::instrument(skip(mod_order))] -pub(crate) fn load_mods<'a, P, S>(mod_dir: P, mod_order: S) -> Result>> +pub(crate) async fn load_mods<'a, P, S>(mod_dir: P, mod_order: S) -> Result>> where S: Iterator, P: AsRef + std::fmt::Debug, { - let rt = Runtime::new()?; - - rt.block_on(async move { - let mod_dir = mod_dir.as_ref(); - let read_dir = match fs::read_dir(mod_dir).await { - Ok(read_dir) => read_dir, - Err(err) if err.kind() == ErrorKind::NotFound => { - return Ok(Vector::new()); - } - Err(err) => { - return Err(err) - .wrap_err_with(|| format!("Failed to open directory '{}'", mod_dir.display())); - } - }; - - let stream = ReadDirStream::new(read_dir) - .map(|res| res.wrap_err("Failed to read dir entry")) - .then(read_mod_dir_entry); - tokio::pin!(stream); - - let mut mods: HashMap = HashMap::new(); - - while let Some(res) = stream.next().await { - let info = res?; - mods.insert(info.id.clone(), info); + let mod_dir = mod_dir.as_ref(); + let read_dir = match fs::read_dir(mod_dir).await { + Ok(read_dir) => read_dir, + Err(err) if err.kind() == ErrorKind::NotFound => { + return Ok(Vector::new()); } + Err(err) => { + return Err(err) + .wrap_err_with(|| format!("Failed to open directory '{}'", mod_dir.display())); + } + }; - let mods = mod_order - .filter_map(|entry| { - if let Some(mut info) = mods.remove(&entry.id) { - info.enabled = entry.enabled; - Some(Arc::new(info)) - } else { - None - } - }) - .collect(); + let stream = ReadDirStream::new(read_dir) + .map(|res| res.wrap_err("Failed to read dir entry")) + .then(read_mod_dir_entry); + tokio::pin!(stream); - Ok::<_, color_eyre::Report>(mods) - }) + let mut mods: HashMap = HashMap::new(); + + while let Some(res) = stream.next().await { + let info = res?; + mods.insert(info.id.clone(), info); + } + + let mods = mod_order + .filter_map(|entry| { + if let Some(mut info) = mods.remove(&entry.id) { + info.enabled = entry.enabled; + Some(Arc::new(info)) + } else { + None + } + }) + .collect(); + + Ok(mods) } pub(crate) fn check_mod_order(state: &ActionState) -> Result<()> { @@ -422,3 +418,26 @@ pub(crate) async fn check_updates(state: ActionState) -> Result> { .collect(); Ok(updates) } + +pub(crate) async fn load_initial(path: PathBuf, is_default: bool) -> Result { + let config = util::config::read_config(path, is_default) + .await + .wrap_err("Failed to read config file")?; + + let game_info = tokio::task::spawn_blocking(dtmt_shared::collect_game_info) + .await + .wrap_err("Failed to collect Steam game info")?; + + { + if config.game_dir.is_none() && game_info.is_none() { + tracing::error!("No Game Directory set. Head to the 'Settings' tab to set it manually",); + } + } + + let mod_dir = config.data_dir.join("mods"); + let mods = load_mods(mod_dir, config.mod_order.iter()) + .await + .wrap_err("Failed to load mods")?; + + Ok((config, mods)) +} diff --git a/crates/dtmm/src/controller/worker.rs b/crates/dtmm/src/controller/worker.rs index fafeebe..b30ca9c 100644 --- a/crates/dtmm/src/controller/worker.rs +++ b/crates/dtmm/src/controller/worker.rs @@ -13,6 +13,7 @@ use crate::controller::app::*; use crate::controller::game::*; use crate::state::AsyncAction; use crate::state::ACTION_FINISH_CHECK_UPDATE; +use crate::state::ACTION_FINISH_LOAD_INITIAL; use crate::state::ACTION_FINISH_SAVE_SETTINGS; use crate::state::ACTION_SHOW_ERROR_DIALOG; use crate::state::{ @@ -144,6 +145,29 @@ async fn handle_action( ) .expect("failed to send command"); }), + AsyncAction::LoadInitial((path, is_default)) => tokio::spawn(async move { + let data = match load_initial(path, is_default) + .await + .wrap_err("Failed to load initial application data") + { + Ok(data) => Some(data), + Err(err) => { + tracing::error!("{:?}", err); + send_error(event_sink.clone(), err).await; + None + } + }; + + event_sink + .write() + .await + .submit_command( + ACTION_FINISH_LOAD_INITIAL, + SingleUse::new(data), + Target::Auto, + ) + .expect("failed to send command"); + }), }; } } diff --git a/crates/dtmm/src/main.rs b/crates/dtmm/src/main.rs index ea7bacb..0f43fdd 100644 --- a/crates/dtmm/src/main.rs +++ b/crates/dtmm/src/main.rs @@ -7,19 +7,16 @@ use std::path::PathBuf; use std::sync::Arc; use clap::command; +use clap::parser::ValueSource; use clap::value_parser; use clap::Arg; use color_eyre::eyre; -use color_eyre::eyre::Context; use color_eyre::{Report, Result}; use druid::AppLauncher; -use druid::SingleUse; -use druid::Target; use tokio::sync::RwLock; -use crate::controller::app::load_mods; use crate::controller::worker::work_thread; -use crate::state::ACTION_SHOW_ERROR_DIALOG; +use crate::state::AsyncAction; use crate::state::{Delegate, State}; use crate::ui::theme; @@ -64,41 +61,53 @@ fn main() -> Result<()> { } let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel(); - let delegate = Delegate::new(action_tx); + + // let config = util::config::read_config(&default_config_path, &matches) + // .wrap_err("Failed to read config file")?; + // let game_info = dtmt_shared::collect_game_info(); + + // tracing::debug!(?config, ?game_info); + + // let game_dir = config.game_dir.or_else(|| game_info.map(|i| i.path)); + // if game_dir.is_none() { + // let err = + // eyre::eyre!("No Game Directory set. Head to the 'Settings' tab to set it manually",); + // event_sink + // .submit_command(ACTION_SHOW_ERROR_DIALOG, SingleUse::new(err), Target::Auto) + // .expect("failed to send command"); + // } + + // let initial_state = { + // let mut state = State::new( + // config.path, + // game_dir.unwrap_or_default(), + // config.data_dir.unwrap_or_default(), + // config.nexus_api_key.unwrap_or_default(), + // ); + // state.mods = load_mods(state.get_mod_dir(), config.mod_order.iter()) + // .wrap_err("Failed to load mods")?; + // state + // }; + + let config_path = matches + .get_one::("config") + .cloned() + .expect("argument has default value"); + let is_config_default = matches.value_source("config") != Some(ValueSource::DefaultValue); + if action_tx + .send(AsyncAction::LoadInitial((config_path, is_config_default))) + .is_err() + { + let err = eyre::eyre!("Failed to send action"); + return Err(err); + } let launcher = AppLauncher::with_window(ui::window::main::new()) - .delegate(delegate) + .delegate(Delegate::new(action_tx)) .configure_env(theme::set_theme_env); let event_sink = launcher.get_external_handle(); - let config = util::config::read_config(&default_config_path, &matches) - .wrap_err("Failed to read config file")?; - let game_info = dtmt_shared::collect_game_info(); - - tracing::debug!(?config, ?game_info); - - let game_dir = config.game_dir.or_else(|| game_info.map(|i| i.path)); - if game_dir.is_none() { - let err = - eyre::eyre!("No Game Directory set. Head to the 'Settings' tab to set it manually",); - event_sink - .submit_command(ACTION_SHOW_ERROR_DIALOG, SingleUse::new(err), Target::Auto) - .expect("failed to send command"); - } - - let initial_state = { - let mut state = State::new( - config.path, - game_dir.unwrap_or_default(), - config.data_dir.unwrap_or_default(), - config.nexus_api_key.unwrap_or_default(), - ); - state.mods = load_mods(state.get_mod_dir(), config.mod_order.iter()) - .wrap_err("Failed to load mods")?; - state - }; - std::thread::spawn(move || { let event_sink = Arc::new(RwLock::new(event_sink)); let action_rx = Arc::new(RwLock::new(action_rx)); @@ -110,5 +119,5 @@ fn main() -> Result<()> { } }); - launcher.launch(initial_state).map_err(Report::new) + launcher.launch(State::new()).map_err(Report::new) } diff --git a/crates/dtmm/src/state/data.rs b/crates/dtmm/src/state/data.rs index 3f63a2a..8bdcd92 100644 --- a/crates/dtmm/src/state/data.rs +++ b/crates/dtmm/src/state/data.rs @@ -158,6 +158,8 @@ pub(crate) struct State { #[data(ignore)] pub log: Arc, + // True, when the initial loading of configuration and mods is still in progress + pub loading: bool, #[lens(ignore)] #[data(ignore)] @@ -174,12 +176,7 @@ impl State { #[allow(non_upper_case_globals)] pub const selected_mod: SelectedModLens = SelectedModLens; - pub fn new( - config_path: PathBuf, - game_dir: PathBuf, - data_dir: PathBuf, - nexus_api_key: String, - ) -> Self { + pub fn new() -> Self { let ctx = sdk::Context::new(); Self { @@ -193,12 +190,13 @@ impl State { is_save_in_progress: false, is_next_save_pending: false, is_update_in_progress: false, - config_path: Arc::new(config_path), - game_dir: Arc::new(game_dir), - data_dir: Arc::new(data_dir), - nexus_api_key: Arc::new(nexus_api_key), + config_path: Arc::new(PathBuf::new()), + game_dir: Arc::new(PathBuf::new()), + data_dir: Arc::new(PathBuf::new()), + nexus_api_key: Arc::new(String::new()), log: Arc::new(String::new()), windows: HashMap::new(), + loading: true, } } @@ -226,10 +224,6 @@ impl State { self.selected_mod_index.map(|i| i > 0).unwrap_or(false) } - pub(crate) fn get_mod_dir(&self) -> PathBuf { - self.data_dir.join("mods") - } - pub(crate) fn add_log_line(&mut self, line: String) { let log = Arc::make_mut(&mut self.log); log.push_str(&line); diff --git a/crates/dtmm/src/state/delegate.rs b/crates/dtmm/src/state/delegate.rs index 33a2136..02c93d4 100644 --- a/crates/dtmm/src/state/delegate.rs +++ b/crates/dtmm/src/state/delegate.rs @@ -7,7 +7,7 @@ use druid::{ }; use tokio::sync::mpsc::UnboundedSender; -use crate::ui::window; +use crate::{ui::window, util::config::Config}; use super::{ModInfo, State}; @@ -52,6 +52,10 @@ pub(crate) const ACTION_SHOW_ERROR_DIALOG: Selector> = pub(crate) const ACTION_SET_WINDOW_HANDLE: Selector> = Selector::new("dtmm.action.set-window-handle"); +pub(crate) type InitialLoadResult = (Config, Vector>); +pub(crate) const ACTION_FINISH_LOAD_INITIAL: Selector>> = + Selector::new("dtmm.action.finish-load-initial"); + // A sub-selection of `State`'s fields that are required in `AsyncAction`s and that are // `Send + Sync` pub(crate) struct ActionState { @@ -85,6 +89,7 @@ pub(crate) enum AsyncAction { DeleteMod(ActionState, Arc), SaveSettings(ActionState), CheckUpdates(ActionState), + LoadInitial((PathBuf, bool)), } pub(crate) struct Delegate { @@ -354,6 +359,23 @@ impl AppDelegate for Delegate { state.is_update_in_progress = false; Handled::Yes } + cmd if cmd.is(ACTION_FINISH_LOAD_INITIAL) => { + let data = cmd + .get(ACTION_FINISH_LOAD_INITIAL) + .and_then(SingleUse::take) + .expect("command type matched but didn't contain the expected value"); + + if let Some((config, mods)) = data { + state.mods = mods; + state.config_path = Arc::new(config.path); + state.data_dir = Arc::new(config.data_dir); + state.game_dir = Arc::new(config.game_dir.unwrap_or_default()); + } + + state.loading = false; + + Handled::Yes + } _ => Handled::No, } } diff --git a/crates/dtmm/src/ui/widget/controller.rs b/crates/dtmm/src/ui/widget/controller.rs index 45170d4..b6d3806 100644 --- a/crates/dtmm/src/ui/widget/controller.rs +++ b/crates/dtmm/src/ui/widget/controller.rs @@ -78,12 +78,15 @@ impl> Controller for DirtyStateController { data: &State, env: &Env, ) { - if compare_state_fields!(old_data, data, mods, game_dir, data_dir, nexus_api_key) { - ctx.submit_command(ACTION_START_SAVE_SETTINGS); - } + // Only start tracking changes after the initial load has finished + if old_data.loading == data.loading { + if compare_state_fields!(old_data, data, mods, game_dir, data_dir, nexus_api_key) { + ctx.submit_command(ACTION_START_SAVE_SETTINGS); + } - if compare_state_fields!(old_data, data, mods, game_dir) { - ctx.submit_command(ACTION_SET_DIRTY); + if compare_state_fields!(old_data, data, mods, game_dir) { + ctx.submit_command(ACTION_SET_DIRTY); + } } child.update(ctx, old_data, data, env) diff --git a/crates/dtmm/src/util/config.rs b/crates/dtmm/src/util/config.rs index c2f2045..e0fde55 100644 --- a/crates/dtmm/src/util/config.rs +++ b/crates/dtmm/src/util/config.rs @@ -1,11 +1,11 @@ use std::io::ErrorKind; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; -use std::{fs, path::Path}; -use clap::{parser::ValueSource, ArgMatches}; use color_eyre::{eyre::Context, Result}; use serde::{Deserialize, Serialize}; +use tokio::fs; use crate::state::{ActionState, ModInfo}; @@ -58,7 +58,8 @@ pub(crate) struct LoadOrderEntry { pub(crate) struct Config { #[serde(skip)] pub path: PathBuf, - pub data_dir: Option, + #[serde(default = "get_default_data_dir")] + pub data_dir: PathBuf, pub game_dir: Option, pub nexus_api_key: Option, #[serde(default)] @@ -99,21 +100,19 @@ pub fn get_default_data_dir() -> PathBuf { #[cfg(target_os = "windows")] pub fn get_default_data_dir() -> PathBuf { - let data_dir = std::env::var("APPDATA").expect("appdata env var not set"); + let data_dir = std::env::var("LOCALAPPDATA").expect("appdata env var not set"); PathBuf::from(data_dir).join("dtmm") } -#[tracing::instrument(skip(matches),fields(path = ?matches.get_one::("config")))] -pub(crate) fn read_config

(default: P, matches: &ArgMatches) -> Result +#[tracing::instrument] +pub(crate) async fn read_config

(path: P, is_default: bool) -> Result where P: Into + std::fmt::Debug, { - let path = matches - .get_one::("config") - .expect("argument missing despite default"); - let default_path = default.into(); + let path = path.into(); + let default_path = get_default_config_path(); - match fs::read(path) { + match fs::read(&path).await { Ok(data) => { let data = String::from_utf8(data).wrap_err_with(|| { format!("Config file '{}' contains invalid UTF-8", path.display()) @@ -121,11 +120,11 @@ where let mut cfg: Config = serde_sjson::from_str(&data) .wrap_err_with(|| format!("Invalid config file {}", path.display()))?; - cfg.path = path.clone(); + cfg.path = path; Ok(cfg) } Err(err) if err.kind() == ErrorKind::NotFound => { - if matches.value_source("config") != Some(ValueSource::DefaultValue) { + if !is_default { return Err(err) .wrap_err_with(|| format!("Failed to read config file {}", path.display()))?; } @@ -134,14 +133,14 @@ where let parent = default_path .parent() .expect("a file path always has a parent directory"); - fs::create_dir_all(parent).wrap_err_with(|| { + fs::create_dir_all(parent).await.wrap_err_with(|| { format!("Failed to create directories {}", parent.display()) })?; } let config = Config { path: default_path, - data_dir: Some(get_default_data_dir()), + data_dir: get_default_data_dir(), game_dir: None, nexus_api_key: None, mod_order: Vec::new(), @@ -150,7 +149,7 @@ where { let data = serde_sjson::to_string(&config) .wrap_err("Failed to serialize default config value")?; - fs::write(&config.path, data).wrap_err_with(|| { + fs::write(&config.path, data).await.wrap_err_with(|| { format!( "failed to write default config to {}", config.path.display()