Darktide Mod Manager #39

Merged
lucas merged 91 commits from feat/dtmm into master 2023-03-01 22:27:42 +01:00
12 changed files with 247 additions and 104 deletions
Showing only changes of commit de072fd0c4 - Show all commits

3
Cargo.lock generated
View file

@ -680,6 +680,7 @@ dependencies = [
"serde", "serde",
"serde_sjson", "serde_sjson",
"tokio", "tokio",
"tokio-stream",
"tracing", "tracing",
"tracing-error", "tracing-error",
"tracing-subscriber", "tracing-subscriber",
@ -1261,6 +1262,7 @@ dependencies = [
"bitmaps", "bitmaps",
"rand_core", "rand_core",
"rand_xoshiro", "rand_xoshiro",
"serde",
"sized-chunks", "sized-chunks",
"typenum", "typenum",
"version_check", "version_check",
@ -1375,6 +1377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e119590a03caff1f7a582e8ee8c2164ddcc975791701188132fd1d1b518d3871" checksum = "e119590a03caff1f7a582e8ee8c2164ddcc975791701188132fd1d1b518d3871"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"serde",
] ]
[[package]] [[package]]

View file

@ -10,15 +10,16 @@ bitflags = "1.3.2"
clap = { version = "4.0.15", features = ["color", "derive", "std", "cargo", "string", "unicode"] } clap = { version = "4.0.15", features = ["color", "derive", "std", "cargo", "string", "unicode"] }
color-eyre = "0.6.2" color-eyre = "0.6.2"
confy = "0.5.1" confy = "0.5.1"
druid = { git = "https://github.com/linebender/druid.git", features = ["im"] } druid = { git = "https://github.com/linebender/druid.git", features = ["im", "serde"] }
dtmt-shared = { path = "../../lib/dtmt-shared", version = "*" } dtmt-shared = { path = "../../lib/dtmt-shared", version = "*" }
futures = "0.3.25" futures = "0.3.25"
oodle-sys = { path = "../../lib/oodle-sys", version = "*" } oodle-sys = { path = "../../lib/oodle-sys", version = "*" }
sdk = { path = "../../lib/sdk", version = "0.2.0" } sdk = { path = "../../lib/sdk", version = "0.2.0" }
serde_sjson = { path = "../../lib/serde_sjson", version = "*" } serde_sjson = { path = "../../lib/serde_sjson", version = "*" }
serde = { version = "1.0.152", features = ["derive"] } serde = { version = "1.0.152", features = ["derive", "rc"] }
tokio = { version = "1.23.0", features = ["rt", "fs", "tracing", "sync"] } tokio = { version = "1.23.0", features = ["rt", "fs", "tracing", "sync"] }
tracing = "0.1.37" tracing = "0.1.37"
tracing-error = "0.2.0" tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
zip = "0.6.4" zip = "0.6.4"
tokio-stream = { version = "0.1.12", features = ["fs"] }

View file

@ -1,15 +1,21 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{Cursor, Read}; use std::io::{Cursor, ErrorKind, Read};
use std::path::Path; use std::path::Path;
use color_eyre::eyre::{self, Context}; use color_eyre::eyre::{self, Context};
use color_eyre::{Help, Result}; use color_eyre::{Help, Result};
use druid::im::Vector;
use druid::FileInfo; use druid::FileInfo;
use dtmt_shared::ModConfig; use dtmt_shared::ModConfig;
use tokio::fs; use serde::Deserialize;
use tokio::fs::{self, DirEntry};
use tokio::runtime::Runtime;
use tokio_stream::wrappers::ReadDirStream;
use tokio_stream::StreamExt;
use zip::ZipArchive; use zip::ZipArchive;
use crate::state::{ModInfo, PackageInfo, State}; use crate::state::{ModInfo, PackageInfo, State};
use crate::util::config::{ConfigSerialize, LoadOrderEntry};
#[tracing::instrument(skip(state))] #[tracing::instrument(skip(state))]
pub(crate) async fn import_mod(state: State, info: FileInfo) -> Result<ModInfo> { pub(crate) async fn import_mod(state: State, info: FileInfo) -> Result<ModInfo> {
@ -105,24 +111,9 @@ pub(crate) async fn delete_mod(state: State, info: &ModInfo) -> Result<()> {
Ok(()) Ok(())
} }
#[derive(Debug, serde::Serialize)]
struct Config<'a> {
game_dir: &'a Path,
data_dir: &'a Path,
}
impl<'a> From<&'a State> for Config<'a> {
fn from(value: &'a State) -> Self {
Self {
game_dir: &value.game_dir,
data_dir: &value.data_dir,
}
}
}
#[tracing::instrument(skip(state))] #[tracing::instrument(skip(state))]
pub(crate) async fn save_settings(state: State) -> Result<()> { pub(crate) async fn save_settings(state: State) -> Result<()> {
let cfg = Config::from(&state); let cfg = ConfigSerialize::from(&state);
tracing::info!("Saving settings to '{}'", state.config_path.display()); tracing::info!("Saving settings to '{}'", state.config_path.display());
tracing::debug!(?cfg); tracing::debug!(?cfg);
@ -138,3 +129,85 @@ pub(crate) async fn save_settings(state: State) -> Result<()> {
) )
}) })
} }
async fn read_sjson_file<P, T>(path: P) -> Result<T>
where
T: for<'a> Deserialize<'a>,
P: AsRef<Path> + std::fmt::Debug,
{
let buf = fs::read(path).await.wrap_err("failed to read file")?;
let data = String::from_utf8(buf).wrap_err("invalid UTF8")?;
serde_sjson::from_str(&data).wrap_err("failed to deserialize")
}
#[tracing::instrument(skip_all,fields(
name = ?res.as_ref().map(|entry| entry.file_name())
))]
async fn read_mod_dir_entry(res: Result<DirEntry>) -> Result<ModInfo> {
let entry = res?;
let config_path = entry.path().join("dtmt.cfg");
let index_path = entry.path().join("files.sjson");
let cfg: ModConfig = read_sjson_file(&config_path)
.await
.wrap_err_with(|| format!("failed to read mod config '{}'", config_path.display()))?;
let files: HashMap<String, Vec<String>> = read_sjson_file(&index_path)
.await
.wrap_err_with(|| format!("failed to read file index '{}'", index_path.display()))?;
let packages = files
.into_iter()
.map(|(name, files)| PackageInfo::new(name, files.into_iter().collect()))
.collect();
let info = ModInfo::new(cfg, packages);
Ok(info)
}
#[tracing::instrument(skip(mod_order))]
pub(crate) fn load_mods<'a, P, S>(mod_dir: P, mod_order: S) -> Result<Vector<ModInfo>>
where
S: Iterator<Item = &'a LoadOrderEntry>,
P: AsRef<Path> + 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<String, ModInfo> = 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(info)
} else {
None
}
})
.collect();
Ok::<_, color_eyre::Report>(mods)
})
}

View file

@ -12,6 +12,7 @@ use color_eyre::{Report, Result};
use druid::AppLauncher; use druid::AppLauncher;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::controller::app::load_mods;
use crate::controller::worker::work_thread; use crate::controller::worker::work_thread;
use crate::state::{Delegate, State}; use crate::state::{Delegate, State};
@ -62,7 +63,16 @@ fn main() -> Result<()> {
let config = util::config::read_config(&default_config_path, &matches) let config = util::config::read_config(&default_config_path, &matches)
.wrap_err("failed to read config file")?; .wrap_err("failed to read config file")?;
let initial_state = State::new(config); let initial_state = {
let mut state = State::new(
config.path,
config.game_dir.unwrap_or_default(),
config.data_dir.unwrap_or_default(),
);
state.mods = load_mods(state.get_mod_dir(), config.mod_order.iter())
.wrap_err("failed to load mods")?;
state
};
let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel(); let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel();
let delegate = Delegate::new(action_tx); let delegate = Delegate::new(action_tx);

View file

@ -3,8 +3,6 @@ use std::{path::PathBuf, sync::Arc};
use druid::{im::Vector, Data, Lens}; use druid::{im::Vector, Data, Lens};
use dtmt_shared::ModConfig; use dtmt_shared::ModConfig;
use crate::util::config::Config;
use super::SelectedModLens; use super::SelectedModLens;
#[derive(Copy, Clone, Data, Debug, PartialEq)] #[derive(Copy, Clone, Data, Debug, PartialEq)]
@ -100,7 +98,7 @@ impl State {
#[allow(non_upper_case_globals)] #[allow(non_upper_case_globals)]
pub const selected_mod: SelectedModLens = SelectedModLens; pub const selected_mod: SelectedModLens = SelectedModLens;
pub fn new(config: Config) -> Self { pub fn new(config_path: PathBuf, game_dir: PathBuf, data_dir: PathBuf) -> Self {
let ctx = sdk::Context::new(); let ctx = sdk::Context::new();
Self { Self {
@ -112,9 +110,9 @@ impl State {
is_reset_in_progress: false, is_reset_in_progress: false,
is_save_in_progress: false, is_save_in_progress: false,
is_next_save_pending: false, is_next_save_pending: false,
config_path: Arc::new(config.path), config_path: Arc::new(config_path),
game_dir: Arc::new(config.game_dir.unwrap_or_default()), game_dir: Arc::new(game_dir),
data_dir: Arc::new(config.data_dir.unwrap_or_default()), data_dir: Arc::new(data_dir),
log: Arc::new(String::new()), log: Arc::new(String::new()),
} }
} }

View file

@ -106,6 +106,7 @@ impl AppDelegate<State> for Delegate {
.expect("command type matched but didn't contain the expected value"); .expect("command type matched but didn't contain the expected value");
state.select_mod(*index); state.select_mod(*index);
// ctx.submit_command(ACTION_START_SAVE_SETTINGS);
Handled::Yes Handled::Yes
} }
cmd if cmd.is(ACTION_SELECTED_MOD_UP) => { cmd if cmd.is(ACTION_SELECTED_MOD_UP) => {
@ -120,6 +121,7 @@ impl AppDelegate<State> for Delegate {
state.mods.swap(i, i - 1); state.mods.swap(i, i - 1);
state.selected_mod_index = Some(i - 1); state.selected_mod_index = Some(i - 1);
// ctx.submit_command(ACTION_START_SAVE_SETTINGS);
Handled::Yes Handled::Yes
} }
cmd if cmd.is(ACTION_SELECTED_MOD_DOWN) => { cmd if cmd.is(ACTION_SELECTED_MOD_DOWN) => {
@ -134,6 +136,7 @@ impl AppDelegate<State> for Delegate {
state.mods.swap(i, i + 1); state.mods.swap(i, i + 1);
state.selected_mod_index = Some(i + 1); state.selected_mod_index = Some(i + 1);
// ctx.submit_command(ACTION_START_SAVE_SETTINGS);
Handled::Yes Handled::Yes
} }
cmd if cmd.is(ACTION_START_DELETE_SELECTED_MOD) => { cmd if cmd.is(ACTION_START_DELETE_SELECTED_MOD) => {
@ -162,7 +165,7 @@ impl AppDelegate<State> for Delegate {
}; };
state.mods.remove(index); state.mods.remove(index);
ctx.submit_command(ACTION_START_SAVE_SETTINGS); // ctx.submit_command(ACTION_START_SAVE_SETTINGS);
Handled::Yes Handled::Yes
} }
@ -185,7 +188,7 @@ impl AppDelegate<State> for Delegate {
.expect("command type matched but didn't contain the expected value"); .expect("command type matched but didn't contain the expected value");
if let Some(info) = info.take() { if let Some(info) = info.take() {
state.add_mod(info); state.add_mod(info);
ctx.submit_command(ACTION_START_SAVE_SETTINGS); // ctx.submit_command(ACTION_START_SAVE_SETTINGS);
} }
Handled::Yes Handled::Yes
} }

View file

@ -1,9 +1,7 @@
mod data; mod data;
mod delegate; mod delegate;
mod lens; mod lens;
mod util;
pub(crate) use data::*; pub(crate) use data::*;
pub(crate) use delegate::*; pub(crate) use delegate::*;
pub(crate) use lens::*; pub(crate) use lens::*;
pub(crate) use util::*;

View file

@ -1,49 +0,0 @@
use std::path::PathBuf;
use std::sync::Arc;
use druid::text::Formatter;
use druid::widget::{TextBoxEvent, ValidationDelegate};
use druid::EventCtx;
pub(crate) struct PathBufFormatter;
impl PathBufFormatter {
pub fn new() -> Self {
Self {}
}
}
impl Formatter<Arc<PathBuf>> for PathBufFormatter {
fn format(&self, value: &Arc<PathBuf>) -> String {
value.display().to_string()
}
fn validate_partial_input(
&self,
_input: &str,
_sel: &druid::text::Selection,
) -> druid::text::Validation {
druid::text::Validation::success()
}
fn value(&self, input: &str) -> Result<Arc<PathBuf>, druid::text::ValidationError> {
let p = PathBuf::from(input);
Ok(Arc::new(p))
}
}
pub struct TextBoxOnChanged<F: Fn(&mut EventCtx, &str)>(F);
impl<F: Fn(&mut EventCtx, &str)> TextBoxOnChanged<F> {
pub fn new(f: F) -> Self {
Self(f)
}
}
impl<F: Fn(&mut EventCtx, &str)> ValidationDelegate for TextBoxOnChanged<F> {
fn event(&mut self, ctx: &mut EventCtx, event: TextBoxEvent, current_text: &str) {
if let TextBoxEvent::Complete = event {
(self.0)(ctx, current_text)
}
}
}

View file

@ -1,5 +1,7 @@
use druid::widget::{Button, Controller, Scroll}; use druid::widget::{Button, Controller, Scroll};
use druid::{Data, Env, Event, EventCtx, LifeCycle, LifeCycleCtx, Rect, UpdateCtx, Widget}; use druid::{Data, Env, Event, EventCtx, Rect, UpdateCtx, Widget};
use crate::state::{State, ACTION_START_SAVE_SETTINGS};
pub struct DisabledButtonController; pub struct DisabledButtonController;
@ -19,17 +21,6 @@ impl<T: Data> Controller<T, Button<T>> for DisabledButtonController {
child.event(ctx, event, data, env) child.event(ctx, event, data, env)
} }
fn lifecycle(
&mut self,
child: &mut Button<T>,
ctx: &mut LifeCycleCtx,
event: &LifeCycle,
data: &T,
env: &Env,
) {
child.lifecycle(ctx, event, data, env)
}
fn update( fn update(
&mut self, &mut self,
child: &mut Button<T>, child: &mut Button<T>,
@ -65,3 +56,27 @@ impl<T: Data, W: Widget<T>> Controller<T, Scroll<T, W>> for AutoScrollController
child.update(ctx, old_data, data, env) child.update(ctx, old_data, data, env)
} }
} }
/// A controller that submits the command to save settings every time its widget's
/// data changes.
pub struct SaveSettingsController;
impl<W: Widget<State>> Controller<State, W> for SaveSettingsController {
fn update(
&mut self,
child: &mut W,
ctx: &mut UpdateCtx,
old_data: &State,
data: &State,
env: &Env,
) {
// Only filter for the values that actually go into the settings file.
if old_data.mods != data.mods
|| old_data.game_dir != data.game_dir
|| old_data.data_dir != data.data_dir
{
ctx.submit_command(ACTION_START_SAVE_SETTINGS);
}
child.update(ctx, old_data, data, env)
}
}

View file

@ -1,3 +1,9 @@
use std::path::PathBuf;
use std::sync::Arc;
use druid::text::Formatter;
use druid::widget::{TextBoxEvent, ValidationDelegate};
use druid::EventCtx;
use druid::{Data, Widget}; use druid::{Data, Widget};
pub mod container; pub mod container;
@ -6,3 +12,46 @@ pub mod controller;
pub trait ExtraWidgetExt<T: Data>: Widget<T> + Sized + 'static {} pub trait ExtraWidgetExt<T: Data>: Widget<T> + Sized + 'static {}
impl<T: Data, W: Widget<T> + 'static> ExtraWidgetExt<T> for W {} impl<T: Data, W: Widget<T> + 'static> ExtraWidgetExt<T> for W {}
pub(crate) struct PathBufFormatter;
impl PathBufFormatter {
pub fn new() -> Self {
Self {}
}
}
impl Formatter<Arc<PathBuf>> for PathBufFormatter {
fn format(&self, value: &Arc<PathBuf>) -> String {
value.display().to_string()
}
fn validate_partial_input(
&self,
_input: &str,
_sel: &druid::text::Selection,
) -> druid::text::Validation {
druid::text::Validation::success()
}
fn value(&self, input: &str) -> Result<Arc<PathBuf>, druid::text::ValidationError> {
let p = PathBuf::from(input);
Ok(Arc::new(p))
}
}
pub struct TextBoxOnChanged<F: Fn(&mut EventCtx, &str)>(F);
impl<F: Fn(&mut EventCtx, &str)> TextBoxOnChanged<F> {
pub fn new(f: F) -> Self {
Self(f)
}
}
impl<F: Fn(&mut EventCtx, &str)> ValidationDelegate for TextBoxOnChanged<F> {
fn event(&mut self, ctx: &mut EventCtx, event: TextBoxEvent, current_text: &str) {
if let TextBoxEvent::Complete = event {
(self.0)(ctx, current_text)
}
}
}

View file

@ -10,13 +10,13 @@ use druid::{
}; };
use crate::state::{ use crate::state::{
ModInfo, PathBufFormatter, State, TextBoxOnChanged, View, ACTION_ADD_MOD, ModInfo, State, View, ACTION_ADD_MOD, ACTION_SELECTED_MOD_DOWN, ACTION_SELECTED_MOD_UP,
ACTION_SELECTED_MOD_DOWN, ACTION_SELECTED_MOD_UP, ACTION_SELECT_MOD, ACTION_SELECT_MOD, ACTION_START_DELETE_SELECTED_MOD, ACTION_START_DEPLOY,
ACTION_START_DELETE_SELECTED_MOD, ACTION_START_DEPLOY, ACTION_START_RESET_DEPLOYMENT, ACTION_START_RESET_DEPLOYMENT,
ACTION_START_SAVE_SETTINGS,
}; };
use crate::ui::theme; use crate::ui::theme;
use crate::ui::widget::controller::AutoScrollController; use crate::ui::widget::controller::{AutoScrollController, SaveSettingsController};
use crate::ui::widget::PathBufFormatter;
const TITLE: &str = "Darktide Mod Manager"; const TITLE: &str = "Darktide Mod Manager";
const WINDOW_SIZE: (f64, f64) = (1080., 720.); const WINDOW_SIZE: (f64, f64) = (1080., 720.);
@ -248,9 +248,6 @@ fn build_view_settings() -> impl Widget<State> {
.with_flex_child( .with_flex_child(
TextBox::new() TextBox::new()
.with_formatter(PathBufFormatter::new()) .with_formatter(PathBufFormatter::new())
.delegate(TextBoxOnChanged::new(|ctx, _| {
ctx.submit_command(ACTION_START_SAVE_SETTINGS)
}))
.expand_width() .expand_width()
.lens(State::data_dir), .lens(State::data_dir),
1., 1.,
@ -265,9 +262,6 @@ fn build_view_settings() -> impl Widget<State> {
.with_flex_child( .with_flex_child(
TextBox::new() TextBox::new()
.with_formatter(PathBufFormatter::new()) .with_formatter(PathBufFormatter::new())
.delegate(TextBoxOnChanged::new(|ctx, _| {
ctx.submit_command(ACTION_START_SAVE_SETTINGS)
}))
.expand_width() .expand_width()
.lens(State::game_dir), .lens(State::game_dir),
1., 1.,
@ -318,4 +312,5 @@ fn build_window() -> impl Widget<State> {
.with_child(build_top_bar()) .with_child(build_top_bar())
.with_flex_child(build_main(), 1.0) .with_flex_child(build_main(), 1.0)
.with_child(build_log_view()) .with_child(build_log_view())
.controller(SaveSettingsController)
} }

View file

@ -1,17 +1,63 @@
use std::fs;
use std::io::ErrorKind; use std::io::ErrorKind;
use std::path::PathBuf; use std::path::PathBuf;
use std::{fs, path::Path};
use clap::{parser::ValueSource, ArgMatches}; use clap::{parser::ValueSource, ArgMatches};
use color_eyre::{eyre::Context, Result}; use color_eyre::{eyre::Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::state::{ModInfo, State};
#[derive(Clone, Debug, Serialize)]
pub(crate) struct LoadOrderEntrySerialize<'a> {
pub id: &'a String,
pub enabled: bool,
}
impl<'a> From<&'a ModInfo> for LoadOrderEntrySerialize<'a> {
fn from(info: &'a ModInfo) -> Self {
Self {
id: &info.id,
enabled: info.enabled,
}
}
}
#[derive(Debug, Serialize)]
pub(crate) struct ConfigSerialize<'a> {
game_dir: &'a Path,
data_dir: &'a Path,
mod_order: Vec<LoadOrderEntrySerialize<'a>>,
}
impl<'a> From<&'a State> for ConfigSerialize<'a> {
fn from(state: &'a State) -> Self {
Self {
game_dir: &state.game_dir,
data_dir: &state.data_dir,
mod_order: state
.mods
.iter()
.map(LoadOrderEntrySerialize::from)
.collect(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct LoadOrderEntry {
pub id: String,
pub enabled: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct Config { pub(crate) struct Config {
#[serde(skip)] #[serde(skip)]
pub path: PathBuf, pub path: PathBuf,
pub data_dir: Option<PathBuf>, pub data_dir: Option<PathBuf>,
pub game_dir: Option<PathBuf>, pub game_dir: Option<PathBuf>,
#[serde(default)]
pub mod_order: Vec<LoadOrderEntry>,
} }
#[cfg(not(arget_os = "windows"))] #[cfg(not(arget_os = "windows"))]
@ -92,6 +138,7 @@ where
path: default_path, path: default_path,
data_dir: Some(get_default_data_dir()), data_dir: Some(get_default_data_dir()),
game_dir: None, game_dir: None,
mod_order: Vec::new(),
}; };
{ {