Darktide Mod Manager #39
8 changed files with 177 additions and 22 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -671,6 +671,7 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-error",
|
"tracing-error",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
@ -20,3 +20,4 @@ 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"
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::ffi::CString;
|
use std::ffi::CString;
|
||||||
use std::io::{Cursor, ErrorKind};
|
use std::io::{Cursor, ErrorKind, Read};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use color_eyre::eyre::Context;
|
use color_eyre::eyre::Context;
|
||||||
use color_eyre::{eyre, Result};
|
use color_eyre::{eyre, Result};
|
||||||
|
use druid::FileInfo;
|
||||||
use futures::stream;
|
use futures::stream;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use sdk::filetype::lua;
|
use sdk::filetype::lua;
|
||||||
|
@ -14,11 +16,13 @@ use sdk::murmur::Murmur64;
|
||||||
use sdk::{
|
use sdk::{
|
||||||
Bundle, BundleDatabase, BundleFile, BundleFileType, BundleFileVariant, FromBinary, ToBinary,
|
Bundle, BundleDatabase, BundleFile, BundleFileType, BundleFileVariant, FromBinary, ToBinary,
|
||||||
};
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::{fs, try_join};
|
use tokio::{fs, try_join};
|
||||||
use tracing::Instrument;
|
use tracing::Instrument;
|
||||||
|
use zip::ZipArchive;
|
||||||
|
|
||||||
use crate::state::{PackageInfo, State};
|
use crate::state::{ModInfo, PackageInfo, State};
|
||||||
|
|
||||||
const MOD_BUNDLE_NAME: &str = "packages/mods";
|
const MOD_BUNDLE_NAME: &str = "packages/mods";
|
||||||
const BOOT_BUNDLE_NAME: &str = "packages/boot";
|
const BOOT_BUNDLE_NAME: &str = "packages/boot";
|
||||||
|
@ -371,3 +375,76 @@ pub(crate) async fn deploy_mods(state: State) -> Result<()> {
|
||||||
tracing::info!("Finished deploying mods");
|
tracing::info!("Finished deploying mods");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct ModConfig {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state))]
|
||||||
|
pub(crate) async fn import_mod(state: State, info: FileInfo) -> Result<ModInfo> {
|
||||||
|
let data = fs::read(&info.path)
|
||||||
|
.await
|
||||||
|
.wrap_err_with(|| format!("failed to read file {}", info.path.display()))?;
|
||||||
|
let data = Cursor::new(data);
|
||||||
|
|
||||||
|
let mut archive = ZipArchive::new(data).wrap_err("failed to open ZIP archive")?;
|
||||||
|
|
||||||
|
for f in archive.file_names() {
|
||||||
|
tracing::debug!("{}", f);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir_name = {
|
||||||
|
let f = archive.by_index(0).wrap_err("archive is empty")?;
|
||||||
|
|
||||||
|
if !f.is_dir() {
|
||||||
|
eyre::bail!("archive does not have a top-level directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = f.name();
|
||||||
|
// The directory name is returned with a trailing slash, which we don't want
|
||||||
|
name[..(name.len().saturating_sub(1))].to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mod_cfg: ModConfig = {
|
||||||
|
let mut f = archive
|
||||||
|
.by_name(&format!("{}/{}", dir_name, "dtmt.cfg"))
|
||||||
|
.wrap_err("failed to read mod config from archive")?;
|
||||||
|
let mut buf = Vec::with_capacity(f.size() as usize);
|
||||||
|
f.read_to_end(&mut buf)
|
||||||
|
.wrap_err("failed to read mod config from archive")?;
|
||||||
|
|
||||||
|
let data = String::from_utf8(buf).wrap_err("mod config is not valid UTF-8")?;
|
||||||
|
|
||||||
|
serde_sjson::from_str(&data).wrap_err("failed to deserialize mod config")?
|
||||||
|
};
|
||||||
|
|
||||||
|
let files: HashMap<String, Vec<String>> = {
|
||||||
|
let mut f = archive
|
||||||
|
.by_name(&format!("{}/{}", dir_name, "files.sjson"))
|
||||||
|
.wrap_err("failed to read file index from archive")?;
|
||||||
|
let mut buf = Vec::with_capacity(f.size() as usize);
|
||||||
|
f.read_to_end(&mut buf)
|
||||||
|
.wrap_err("failed to read file index from archive")?;
|
||||||
|
|
||||||
|
let data = String::from_utf8(buf).wrap_err("file index is not valid UTF-8")?;
|
||||||
|
|
||||||
|
serde_sjson::from_str(&data).wrap_err("failed to deserialize file index")?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mod_dir = state.get_game_dir().join(&mod_cfg.name);
|
||||||
|
|
||||||
|
archive
|
||||||
|
.extract(&mod_dir)
|
||||||
|
.wrap_err_with(|| format!("failed to extract archive to {}", mod_dir.display()))?;
|
||||||
|
|
||||||
|
let packages = files
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, files)| PackageInfo::new(name, files.into_iter().collect()))
|
||||||
|
.collect();
|
||||||
|
let info = ModInfo::new(mod_cfg.name, mod_cfg.description, packages);
|
||||||
|
|
||||||
|
Ok(info)
|
||||||
|
}
|
||||||
|
|
|
@ -9,7 +9,10 @@ use color_eyre::Report;
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use druid::AppLauncher;
|
use druid::AppLauncher;
|
||||||
use druid::ExtEventSink;
|
use druid::ExtEventSink;
|
||||||
|
use druid::SingleUse;
|
||||||
use druid::Target;
|
use druid::Target;
|
||||||
|
use engine::import_mod;
|
||||||
|
use state::ACTION_FINISH_ADD_MOD;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
use tokio::sync::mpsc::UnboundedReceiver;
|
use tokio::sync::mpsc::UnboundedReceiver;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
@ -48,6 +51,24 @@ fn work_thread(
|
||||||
.submit_command(ACTION_FINISH_DEPLOY, (), Target::Auto)
|
.submit_command(ACTION_FINISH_DEPLOY, (), Target::Auto)
|
||||||
.expect("failed to send command");
|
.expect("failed to send command");
|
||||||
}),
|
}),
|
||||||
|
AsyncAction::AddMod((state, info)) => tokio::spawn(async move {
|
||||||
|
match import_mod(state, info).await {
|
||||||
|
Ok(mod_info) => {
|
||||||
|
event_sink
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.submit_command(
|
||||||
|
ACTION_FINISH_ADD_MOD,
|
||||||
|
SingleUse::new(mod_info),
|
||||||
|
Target::Auto,
|
||||||
|
)
|
||||||
|
.expect("failed to send command");
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Failed to import mod: {:?}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,7 +3,7 @@ use druid::widget::{
|
||||||
Align, Button, CrossAxisAlignment, Flex, Label, List, MainAxisAlignment, Maybe, Scroll, Split,
|
Align, Button, CrossAxisAlignment, Flex, Label, List, MainAxisAlignment, Maybe, Scroll, Split,
|
||||||
TextBox, ViewSwitcher,
|
TextBox, ViewSwitcher,
|
||||||
};
|
};
|
||||||
use druid::{lens, Insets, LensExt, Widget, WidgetExt, WindowDesc};
|
use druid::{lens, FileDialogOptions, FileSpec, Insets, LensExt, Widget, WidgetExt, WindowDesc};
|
||||||
|
|
||||||
use crate::state::{ModInfo, PathBufFormatter, State, View, ACTION_ADD_MOD};
|
use crate::state::{ModInfo, PathBufFormatter, State, View, ACTION_ADD_MOD};
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
|
@ -151,8 +151,16 @@ fn build_mod_details() -> impl Widget<State> {
|
||||||
.disabled_if(|info: &Option<ModInfo>, _env: &druid::Env| info.is_none())
|
.disabled_if(|info: &Option<ModInfo>, _env: &druid::Env| info.is_none())
|
||||||
.lens(State::selected_mod);
|
.lens(State::selected_mod);
|
||||||
|
|
||||||
let button_add_mod = Button::new("Add Mod")
|
let button_add_mod = Button::new("Add Mod").on_click(|ctx, _state: &mut State, _env| {
|
||||||
.on_click(|ctx, _state: &mut State, _env| ctx.submit_command(ACTION_ADD_MOD));
|
let zip = FileSpec::new("Zip file", &["zip"]);
|
||||||
|
let opts = FileDialogOptions::new()
|
||||||
|
.allowed_types(vec![zip])
|
||||||
|
.default_type(zip)
|
||||||
|
.name_label("Mod Archive")
|
||||||
|
.title("Choose a mod to add")
|
||||||
|
.accept_command(ACTION_ADD_MOD);
|
||||||
|
ctx.submit_command(druid::commands::SHOW_OPEN_PANEL.with(opts))
|
||||||
|
});
|
||||||
|
|
||||||
let button_delete_mod = Button::new("Delete Mod")
|
let button_delete_mod = Button::new("Delete Mod")
|
||||||
.on_click(|ctx, _state, _env| ctx.submit_command(ACTION_DELETE_SELECTED_MOD))
|
.on_click(|ctx, _state, _env| ctx.submit_command(ACTION_DELETE_SELECTED_MOD))
|
||||||
|
|
|
@ -3,18 +3,25 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use druid::im::Vector;
|
use druid::im::Vector;
|
||||||
use druid::text::Formatter;
|
use druid::text::Formatter;
|
||||||
use druid::{AppDelegate, Command, Data, DelegateCtx, Env, Handled, Lens, Selector, Target};
|
use druid::{
|
||||||
|
AppDelegate, Command, Data, DelegateCtx, Env, FileInfo, Handled, Lens, Selector, SingleUse,
|
||||||
|
Target,
|
||||||
|
};
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
pub const ACTION_SELECT_MOD: Selector<usize> = Selector::new("dtmm.action.select-mod");
|
pub(crate) const ACTION_SELECT_MOD: Selector<usize> = Selector::new("dtmm.action.select-mod");
|
||||||
pub const ACTION_SELECTED_MOD_UP: Selector = Selector::new("dtmm.action.selected-mod-up");
|
pub(crate) const ACTION_SELECTED_MOD_UP: Selector = Selector::new("dtmm.action.selected-mod-up");
|
||||||
pub const ACTION_SELECTED_MOD_DOWN: Selector = Selector::new("dtmm.action.selected-mod-down");
|
pub(crate) const ACTION_SELECTED_MOD_DOWN: Selector =
|
||||||
pub const ACTION_DELETE_SELECTED_MOD: Selector = Selector::new("dtmm.action.delete-selected-mod");
|
Selector::new("dtmm.action.selected-mod-down");
|
||||||
|
pub(crate) const ACTION_DELETE_SELECTED_MOD: Selector =
|
||||||
|
Selector::new("dtmm.action.delete-selected-mod");
|
||||||
|
|
||||||
pub const ACTION_START_DEPLOY: Selector = Selector::new("dtmm.action.start-deploy");
|
pub(crate) const ACTION_START_DEPLOY: Selector = Selector::new("dtmm.action.start-deploy");
|
||||||
pub const ACTION_FINISH_DEPLOY: Selector = Selector::new("dtmm.action.finish-deploy");
|
pub(crate) const ACTION_FINISH_DEPLOY: Selector = Selector::new("dtmm.action.finish-deploy");
|
||||||
|
|
||||||
pub const ACTION_ADD_MOD: Selector = Selector::new("dtmm.action.add-mod");
|
pub(crate) const ACTION_ADD_MOD: Selector<FileInfo> = Selector::new("dtmm.action.add-mod");
|
||||||
|
pub(crate) const ACTION_FINISH_ADD_MOD: Selector<SingleUse<ModInfo>> =
|
||||||
|
Selector::new("dtmm.action.finish-add-mod");
|
||||||
|
|
||||||
#[derive(Copy, Clone, Data, Debug, PartialEq)]
|
#[derive(Copy, Clone, Data, Debug, PartialEq)]
|
||||||
pub(crate) enum View {
|
pub(crate) enum View {
|
||||||
|
@ -36,6 +43,10 @@ pub struct PackageInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PackageInfo {
|
impl PackageInfo {
|
||||||
|
pub fn new(name: String, files: Vector<String>) -> Self {
|
||||||
|
Self { name, files }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_name(&self) -> &String {
|
pub fn get_name(&self) -> &String {
|
||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
|
@ -55,12 +66,12 @@ pub(crate) struct ModInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModInfo {
|
impl ModInfo {
|
||||||
pub fn new() -> Self {
|
pub fn new(name: String, description: String, packages: Vector<PackageInfo>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: format!("Test Mod: {:?}", std::time::SystemTime::now()),
|
name,
|
||||||
description: Arc::new(String::from("A test dummy")),
|
description: Arc::new(description),
|
||||||
|
packages,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
packages: Vector::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -236,6 +247,7 @@ impl<T: Data> Lens<Vector<T>, Vector<(usize, T)>> for IndexedVectorLens {
|
||||||
|
|
||||||
pub(crate) enum AsyncAction {
|
pub(crate) enum AsyncAction {
|
||||||
DeployMods(State),
|
DeployMods(State),
|
||||||
|
AddMod((State, FileInfo)),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct Delegate {
|
pub(crate) struct Delegate {
|
||||||
|
@ -321,13 +333,28 @@ impl AppDelegate<State> for Delegate {
|
||||||
Handled::Yes
|
Handled::Yes
|
||||||
}
|
}
|
||||||
cmd if cmd.is(ACTION_ADD_MOD) => {
|
cmd if cmd.is(ACTION_ADD_MOD) => {
|
||||||
// TODO: Implement properly
|
let info = cmd
|
||||||
let info = ModInfo::new();
|
.get(ACTION_ADD_MOD)
|
||||||
|
.expect("command type matched but didn't contain the expected value");
|
||||||
|
if let Err(err) = self
|
||||||
|
.sender
|
||||||
|
.send(AsyncAction::AddMod((state.clone(), info.clone())))
|
||||||
|
{
|
||||||
|
tracing::error!("Failed to add mod: {}", err);
|
||||||
|
}
|
||||||
|
Handled::Yes
|
||||||
|
}
|
||||||
|
cmd if cmd.is(ACTION_FINISH_ADD_MOD) => {
|
||||||
|
let info = cmd
|
||||||
|
.get(ACTION_FINISH_ADD_MOD)
|
||||||
|
.expect("command type matched but didn't contain the expected value");
|
||||||
|
if let Some(info) = info.take() {
|
||||||
state.add_mod(info);
|
state.add_mod(info);
|
||||||
|
}
|
||||||
Handled::Yes
|
Handled::Yes
|
||||||
}
|
}
|
||||||
cmd => {
|
cmd => {
|
||||||
tracing::debug!("Unknown command: {:?}", cmd);
|
tracing::warn!("Unknown command: {:?}", cmd);
|
||||||
Handled::No
|
Handled::No
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -218,6 +218,8 @@ pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()>
|
||||||
fs::read(path).await?
|
fs::read(path).await?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let config_file = fs::read(cfg.dir.join("dtmt.cfg")).await?;
|
||||||
|
|
||||||
{
|
{
|
||||||
let dest = dest.clone();
|
let dest = dest.clone();
|
||||||
let name = cfg.name.clone();
|
let name = cfg.name.clone();
|
||||||
|
@ -225,6 +227,7 @@ pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()>
|
||||||
let mut archive = Archive::new(name);
|
let mut archive = Archive::new(name);
|
||||||
|
|
||||||
archive.add_mod_file(mod_file);
|
archive.add_mod_file(mod_file);
|
||||||
|
archive.add_config(config_file);
|
||||||
|
|
||||||
for bundle in bundles {
|
for bundle in bundles {
|
||||||
archive.add_bundle(bundle);
|
archive.add_bundle(bundle);
|
||||||
|
|
|
@ -13,6 +13,7 @@ pub struct Archive {
|
||||||
name: String,
|
name: String,
|
||||||
bundles: Vec<Bundle>,
|
bundles: Vec<Bundle>,
|
||||||
mod_file: Option<Vec<u8>>,
|
mod_file: Option<Vec<u8>>,
|
||||||
|
config_file: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Archive {
|
impl Archive {
|
||||||
|
@ -21,6 +22,7 @@ impl Archive {
|
||||||
name,
|
name,
|
||||||
bundles: Vec::new(),
|
bundles: Vec::new(),
|
||||||
mod_file: None,
|
mod_file: None,
|
||||||
|
config_file: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,6 +34,10 @@ impl Archive {
|
||||||
self.mod_file = Some(content);
|
self.mod_file = Some(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn add_config(&mut self, content: Vec<u8>) {
|
||||||
|
self.config_file = Some(content);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn write<P>(&self, path: P) -> Result<()>
|
pub fn write<P>(&self, path: P) -> Result<()>
|
||||||
where
|
where
|
||||||
P: AsRef<Path>,
|
P: AsRef<Path>,
|
||||||
|
@ -39,7 +45,12 @@ impl Archive {
|
||||||
let mod_file = self
|
let mod_file = self
|
||||||
.mod_file
|
.mod_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| eyre::eyre!("Mod file is missing from mod archive"))?;
|
.ok_or_else(|| eyre::eyre!("Mod file is missing in mod archive"))?;
|
||||||
|
|
||||||
|
let config_file = self
|
||||||
|
.config_file
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| eyre::eyre!("Config file is missing in mod archive"))?;
|
||||||
|
|
||||||
let f = File::create(path.as_ref()).wrap_err_with(|| {
|
let f = File::create(path.as_ref()).wrap_err_with(|| {
|
||||||
format!(
|
format!(
|
||||||
|
@ -60,6 +71,12 @@ impl Archive {
|
||||||
zip.write_all(mod_file)?;
|
zip.write_all(mod_file)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let name = base_path.join("dtmt.cfg");
|
||||||
|
zip.start_file(name.to_string_lossy(), Default::default())?;
|
||||||
|
zip.write_all(config_file)?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut file_map = HashMap::new();
|
let mut file_map = HashMap::new();
|
||||||
|
|
||||||
for bundle in self.bundles.iter() {
|
for bundle in self.bundles.iter() {
|
||||||
|
|
Loading…
Add table
Reference in a new issue