diff --git a/src/gui/mod.rs b/src/gui/mod.rs index ad59fd83..b73e6e85 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -8,11 +8,8 @@ mod toggle_switch; use std::collections::{BTreeMap, BTreeSet}; use std::time::{Duration, SystemTime}; -use std::{ - collections::{HashMap, HashSet}, - ops::DerefMut, - path::PathBuf, -}; +use std::{collections::{HashMap, HashSet}, fs, ops::DerefMut, path::PathBuf}; +use std::path::Path; use anyhow::{anyhow, Context, Result}; use eframe::egui::{Button, CollapsingHeader, RichText, Visuals}; @@ -34,6 +31,9 @@ use crate::Dirs; use crate::{ integrate::uninstall, is_drg_pak, + is_valid_directory, + copy_directory_contents, + clear_directory, providers::{ ApprovalStatus, FetchProgress, ModInfo, ModSpecification, ModStore, ModioTags, ProviderFactory, RequiredStatus, @@ -928,26 +928,28 @@ impl App { }); ui.end_row(); - let config_dir = &self.state.dirs.config_dir; - ui.label("Config directory:"); - if ui.link(config_dir.display().to_string()).clicked() { - opener::open(config_dir).ok(); - } - ui.end_row(); + let edit_directory_field = |ui: &mut egui::Ui, label: &str, path: &mut String, err: &mut Option| { + ui.horizontal(|ui| { + ui.label(label); + ui.text_edit_singleline(path); - let cache_dir = &self.state.dirs.cache_dir; - ui.label("Cache directory:"); - if ui.link(cache_dir.display().to_string()).clicked() { - opener::open(cache_dir).ok(); - } - ui.end_row(); + if ui.button("Browse").clicked() { + if let Some(selected_path) = rfd::FileDialog::new().pick_folder() { + *path = selected_path.to_string_lossy().to_string(); + *err = None; + } + } - let data_dir = &self.state.dirs.data_dir; - ui.label("Data directory:"); - if ui.link(data_dir.display().to_string()).clicked() { - opener::open(data_dir).ok(); - } - ui.end_row(); + if let Some(err_msg) = err { + ui.label(&*err_msg); + } + ui.end_row(); + }); + }; + + edit_directory_field(ui, "Config Directory:", &mut window.config_dir, &mut window.config_dir_err); + edit_directory_field(ui, "Cache Directory:", &mut window.cache_dir, &mut window.cache_dir_err); + edit_directory_field(ui, "Data Directory:", &mut window.data_dir, &mut window.data_dir_err); ui.label("GUI theme:"); ui.horizontal(|ui| { @@ -980,24 +982,91 @@ impl App { } }); - ui.with_layout(egui::Layout::right_to_left(Align::TOP), |ui| { - if ui.add_enabled(window.drg_pak_path_err.is_none(), egui::Button::new("save")).clicked() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { + let can_save = window.drg_pak_path_err.is_none() + && window.config_dir_err.is_none() + && window.cache_dir_err.is_none() + && window.data_dir_err.is_none(); + + if ui.add_enabled(can_save, egui::Button::new("save")).clicked() { try_save = true; } + if let Some(error) = &window.drg_pak_path_err { ui.colored_label(ui.visuals().error_fg_color, error); } + if let Some(error) = &window.config_dir_err { + ui.colored_label(ui.visuals().error_fg_color, error); + } + if let Some(error) = &window.cache_dir_err { + ui.colored_label(ui.visuals().error_fg_color, error); + } + if let Some(error) = &window.data_dir_err { + ui.colored_label(ui.visuals().error_fg_color, error); + } }); }); if try_save { - if let Err(e) = is_drg_pak(&window.drg_pak_path).context("Is not valid DRG pak") { + let mut has_error = false; + + if let Err(e) = is_drg_pak(&window.drg_pak_path) { window.drg_pak_path_err = Some(e.to_string()); - } else { - self.state.config.drg_pak_path = Some(PathBuf::from( - self.settings_window.take().unwrap().drg_pak_path, - )); - self.state.config.save().unwrap(); + has_error = true; + } + if let Err(e) = is_valid_directory(&window.config_dir) { + window.config_dir_err = Some(e); + has_error = true; + } + if let Err(e) = is_valid_directory(&window.cache_dir) { + window.cache_dir_err = Some(e); + has_error = true; + } + if let Err(e) = is_valid_directory(&window.data_dir) { + window.data_dir_err = Some(e); + has_error = true; + } + + if !has_error { + if let Some(old_config_dir) = &self.state.config.config_dir { + if old_config_dir.to_string_lossy() != window.config_dir { + if let Err(e) = copy_directory_contents(old_config_dir, Path::new(&window.config_dir)) { + window.config_dir_err = Some(format!("Failed to copy config directory: {}", e)); + has_error = true; + } + } + } + + if let Some(old_data_dir) = &self.state.config.data_dir { + if old_data_dir.to_string_lossy() != window.data_dir { + if let Err(e) = copy_directory_contents(old_data_dir, Path::new(&window.data_dir)) { + window.data_dir_err = Some(format!("Failed to copy data directory: {}", e)); + has_error = true; + } + } + } + + if let Some(old_cache_dir) = &self.state.config.cache_dir { + if old_cache_dir.to_string_lossy() != window.cache_dir { + if let Err(e) = fs::create_dir_all(&window.cache_dir) { + window.cache_dir_err = Some(format!("Failed to create new cache directory: {}", e)); + has_error = true; + } + else if let Err(e) = clear_directory(old_cache_dir) { + window.cache_dir_err = Some(format!("Failed to clear cache directory: {}", e)); + has_error = true; + } + } + } + + if !has_error { + self.state.config.drg_pak_path = Some(PathBuf::from(&window.drg_pak_path)); + self.state.config.config_dir = Some(PathBuf::from(&window.config_dir)); + self.state.config.cache_dir = Some(PathBuf::from(&window.cache_dir)); + self.state.config.data_dir = Some(PathBuf::from(&window.data_dir)); + + self.state.config.save().unwrap(); + } } } else if !open { self.settings_window = None; @@ -1483,19 +1552,33 @@ impl WindowProviderParameters { struct WindowSettings { drg_pak_path: String, drg_pak_path_err: Option, + config_dir: String, + config_dir_err: Option, + cache_dir: String, + cache_dir_err: Option, + data_dir: String, + data_dir_err: Option, } impl WindowSettings { fn new(state: &State) -> Self { - let path = state - .config - .drg_pak_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); Self { - drg_pak_path: path, + drg_pak_path: state.config.drg_pak_path.as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(), drg_pak_path_err: None, + config_dir: state.config.config_dir.as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(), + config_dir_err: None, + cache_dir: state.config.cache_dir.as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(), + cache_dir_err: None, + data_dir: state.config.data_dir.as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(), + data_dir_err: None, } } } diff --git a/src/lib.rs b/src/lib.rs index 3b7307d1..2d0e8ece 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,11 +7,13 @@ pub mod mod_lints; pub mod providers; pub mod state; -use std::io::{Cursor, Read}; +use std::io::{self, Cursor, Read}; use std::str::FromStr; -use std::{ - collections::HashSet, - path::{Path, PathBuf}, +use std::{collections::HashSet, path::{Path, PathBuf}}; +use std::fs::{ + self, + copy, + create_dir_all, }; use anyhow::{Context, Result}; @@ -87,6 +89,63 @@ pub fn write_file, C: AsRef<[u8]>>(path: P, data: C) -> Result<() .with_context(|| format!("Could not write to file {}", path.as_ref().display())) } +pub fn is_valid_directory(path: &str) -> Result<(), String> { + let path = Path::new(path); + + if !path.exists() { + return Err("Path does not exist.".to_string()); + } + if !path.is_dir() { + return Err("Path is not a directory.".to_string()); + } + + match fs::metadata(path) { + Ok(metadata) => { + if !metadata.permissions().readonly() { + Ok(()) + } else { + Err("Directory is not writable.".to_string()) + } + } + Err(_) => Err("Unable to access directory metadata.".to_string()), + } +} + +pub fn copy_directory_contents(src: &Path, dest: &Path) -> io::Result<()> { + if src.is_dir() { + create_dir_all(dest)?; + + for entry in fs::read_dir(src)? { + let entry = entry?; + let path = entry.path(); + let dest_path = dest.join(entry.file_name()); + + if path.is_dir() { + copy_directory_contents(&path, &dest_path)?; + } else { + copy(&path, &dest_path)?; + } + } + } + Ok(()) +} + +pub fn clear_directory(path: &Path) -> io::Result<()> { + if path.is_dir() { + for entry in fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + fs::remove_dir_all(&path)?; + } else { + fs::remove_file(&path)?; + } + } + } + Ok(()) +} + pub fn is_drg_pak>(path: P) -> Result<()> { let mut reader = std::io::BufReader::new(open_file(path)?); let pak = repak::PakBuilder::new().reader(&mut reader)?; diff --git a/src/state/mod.rs b/src/state/mod.rs index e6a641b5..87813a98 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -326,11 +326,25 @@ impl ModData!["0.1.0"] { #[obake::versioned] #[obake(version("0.0.0"))] +#[obake(version("0.1.0"))] #[derive(Debug, Serialize, Deserialize)] pub struct Config { + #[obake(cfg("0.0.0"))] + #[obake(cfg("0.1.0"))] pub provider_parameters: HashMap>, + #[obake(cfg("0.0.0"))] + #[obake(cfg("0.1.0"))] pub drg_pak_path: Option, + #[obake(cfg("0.0.0"))] + #[obake(cfg("0.1.0"))] pub gui_theme: Option, + + #[obake(cfg("0.1.0"))] + pub config_dir: Option, + #[obake(cfg("0.1.0"))] + pub cache_dir: Option, + #[obake(cfg("0.1.0"))] + pub data_dir: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -338,6 +352,8 @@ pub struct Config { pub enum VersionAnnotatedConfig { #[serde(rename = "0.0.0")] V0_0_0(Config!["0.0.0"]), + #[serde(rename = "0.1.0")] + V0_1_0(Config!["0.1.0"]), #[serde(other)] Unsupported, } @@ -357,16 +373,17 @@ impl Default for MaybeVersionedConfig { impl Default for VersionAnnotatedConfig { fn default() -> Self { - VersionAnnotatedConfig::V0_0_0(Default::default()) + VersionAnnotatedConfig::V0_1_0(Default::default()) } } impl Deref for VersionAnnotatedConfig { - type Target = Config!["0.0.0"]; + type Target = Config!["0.1.0"]; fn deref(&self) -> &Self::Target { match self { VersionAnnotatedConfig::V0_0_0(cfg) => cfg, + VersionAnnotatedConfig::V0_1_0(cfg) => cfg, VersionAnnotatedConfig::Unsupported => unreachable!(), } } @@ -375,20 +392,41 @@ impl Deref for VersionAnnotatedConfig { impl DerefMut for VersionAnnotatedConfig { fn deref_mut(&mut self) -> &mut Self::Target { match self { - VersionAnnotatedConfig::V0_0_0(cfg) => cfg, + VersionAnnotatedConfig::V0_0_0(_) => unreachable!(), + VersionAnnotatedConfig::V0_1_0(cfg) => cfg, VersionAnnotatedConfig::Unsupported => unreachable!(), } } } -impl Default for Config!["0.0.0"] { +impl Default for Config!["0.1.0"] { fn default() -> Self { + let default_dirs = Dirs::default_xdg().expect("Failed to get default directories"); + Self { provider_parameters: Default::default(), drg_pak_path: DRGInstallation::find() .as_ref() .map(DRGInstallation::main_pak), gui_theme: None, + cache_dir: Some(default_dirs.cache_dir), + config_dir: Some(default_dirs.config_dir), + data_dir: Some(default_dirs.data_dir), + } + } +} + +impl From for Config!["0.1.0"] { + fn from(legacy: Config!("0.0.0")) -> Self { + let default_dirs = Dirs::default_xdg().expect("Failed to get default directories"); + + Self { + provider_parameters: legacy.provider_parameters, + drg_pak_path: legacy.drg_pak_path, + gui_theme: legacy.gui_theme, + cache_dir: Some(default_dirs.cache_dir), + config_dir: Some(default_dirs.config_dir), + data_dir: Some(default_dirs.data_dir), } } } @@ -432,6 +470,7 @@ fn read_config_or_default(config_path: &PathBuf) -> Result match v { + VersionAnnotatedConfig::V0_1_0(v) => VersionAnnotatedConfig::V0_1_0(v), VersionAnnotatedConfig::V0_0_0(v) => VersionAnnotatedConfig::V0_0_0(v), VersionAnnotatedConfig::Unsupported => bail!("unsupported config version"), },