diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 1fd318f..358bc33 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -18,6 +18,7 @@ - sdk: implement decompiling Lua files - dtmm: fetch cover image for Nexus mods - dtmm: fetch file version for Nexus mods +- dtmm: handle `nxm://` URIs via IPC and import the corresponding mod === Fixed diff --git a/Cargo.lock b/Cargo.lock index 8385e67..1817853 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,15 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.64.0" @@ -895,6 +904,7 @@ version = "0.1.0" dependencies = [ "ansi-parser", "async-recursion", + "bincode", "bitflags 1.3.2", "clap", "color-eyre", @@ -904,6 +914,7 @@ dependencies = [ "druid-widget-nursery", "dtmt-shared", "futures", + "interprocess", "lazy_static", "luajit2-sys", "minijinja", @@ -1833,6 +1844,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "interprocess" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f2533f3be42fffe3b5e63b71aeca416c1c3bc33e4e27be018521e76b1f38fb" +dependencies = [ + "cfg-if", + "libc", + "rustc_version", + "to_method", + "winapi", +] + [[package]] name = "intl-memoizer" version = "0.5.1" @@ -3517,6 +3541,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "to_method" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8" + [[package]] name = "tokio" version = "1.33.0" diff --git a/crates/dtmm/Cargo.toml b/crates/dtmm/Cargo.toml index 5f60220..c159295 100644 --- a/crates/dtmm/Cargo.toml +++ b/crates/dtmm/Cargo.toml @@ -6,33 +6,35 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +ansi-parser = "0.9.0" +async-recursion = "1.0.5" +bincode = "1.3.3" bitflags = "1.3.2" clap = { version = "4.0.15", features = ["color", "derive", "std", "cargo", "string", "unicode"] } color-eyre = "0.6.2" +colors-transform = "0.2.11" confy = "0.5.1" druid = { version = "0.8", features = ["im", "serde", "image", "png", "jpeg", "bmp", "webp", "svg"] } +druid-widget-nursery = "0.1" dtmt-shared = { path = "../../lib/dtmt-shared", version = "*" } futures = "0.3.25" -oodle = { path = "../../lib/oodle", version = "*" } -sdk = { path = "../../lib/sdk", version = "*" } +interprocess = { version = "1.2.1", default-features = false } +lazy_static = "1.4.0" +luajit2-sys = { path = "../../lib/luajit2-sys", version = "*" } +minijinja = "1.0.10" nexusmods = { path = "../../lib/nexusmods", version = "*" } -serde_sjson = { path = "../../lib/serde_sjson", version = "*" } +oodle = { path = "../../lib/oodle", version = "*" } +path-slash = "0.2.1" +sdk = { path = "../../lib/sdk", version = "*" } serde = { version = "1.0.152", features = ["derive", "rc"] } +serde_sjson = { path = "../../lib/serde_sjson", version = "*" } +string_template = "0.2.1" +strip-ansi-escapes = "0.1.1" +time = { version = "0.3.20", features = ["serde", "serde-well-known", "local-offset"] } tokio = { version = "1.23.0", features = ["rt", "fs", "tracing", "sync"] } +tokio-stream = { version = "0.1.12", features = ["fs"] } tracing = "0.1.37" tracing-error = "0.2.0" tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } -zip = "0.6.4" -tokio-stream = { version = "0.1.12", features = ["fs"] } -path-slash = "0.2.1" -time = { version = "0.3.20", features = ["serde", "serde-well-known", "local-offset"] } -strip-ansi-escapes = "0.1.1" -lazy_static = "1.4.0" -colors-transform = "0.2.11" usvg = "0.25.0" -druid-widget-nursery = "0.1" -ansi-parser = "0.9.0" -string_template = "0.2.1" -luajit2-sys = { path = "../../lib/luajit2-sys", version = "*" } -async-recursion = "1.0.5" -minijinja = "1.0.10" +zip = "0.6.4" diff --git a/crates/dtmm/assets/dtmm.desktop b/crates/dtmm/assets/dtmm.desktop new file mode 100644 index 0000000..4c2e0a9 --- /dev/null +++ b/crates/dtmm/assets/dtmm.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=DTMM +GenericName=Mod Manager +Comment=A graphical mod manager for Warhammer 40,000: Darktide +Exec=dtmm %u +Type=Application +Keywords=Mod; +StartupNotify=true +Categories=Utility; +MimeType=x-scheme-handler/nxm; diff --git a/crates/dtmm/src/controller/import.rs b/crates/dtmm/src/controller/import.rs index cef7ee4..27aebdf 100644 --- a/crates/dtmm/src/controller/import.rs +++ b/crates/dtmm/src/controller/import.rs @@ -405,11 +405,10 @@ fn extract_legacy_mod( } #[tracing::instrument(skip(state))] -pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result { +pub(crate) async fn import_from_file(state: ActionState, info: FileInfo) -> Result { let data = fs::read(&info.path) .await .wrap_err_with(|| format!("Failed to read file {}", info.path.display()))?; - let data = Cursor::new(data); let nexus = if let Some((_, id, version, timestamp)) = info .path @@ -450,6 +449,32 @@ pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result Result { + let url = uri + .parse() + .wrap_err_with(|| format!("Invalid Uri '{}'", uri))?; + + let api = NexusApi::new(state.nexus_api_key.to_string())?; + let (mod_info, file_info, data) = api + .handle_nxm(url) + .await + .wrap_err_with(|| format!("Failed to download mod from NXM uri '{}'", uri))?; + + let nexus = NexusInfo::from(mod_info); + import_mod(state, Some((nexus, file_info.version)), data).await +} + +#[tracing::instrument(skip(state, data), fields(data = data.len()))] +pub(crate) async fn import_mod( + state: ActionState, + nexus: Option<(NexusInfo, String)>, + data: Vec, +) -> Result { + let data = Cursor::new(data); let mut archive = ZipArchive::new(data).wrap_err("Failed to open ZIP archive")?; if tracing::enabled!(tracing::Level::DEBUG) { diff --git a/crates/dtmm/src/controller/worker.rs b/crates/dtmm/src/controller/worker.rs index 152030f..6ee498f 100644 --- a/crates/dtmm/src/controller/worker.rs +++ b/crates/dtmm/src/controller/worker.rs @@ -15,7 +15,7 @@ use tokio::sync::RwLock; use crate::controller::app::*; use crate::controller::deploy::deploy_mods; use crate::controller::game::*; -use crate::controller::import::import_mod; +use crate::controller::import::*; use crate::state::AsyncAction; use crate::state::ACTION_FINISH_CHECK_UPDATE; use crate::state::ACTION_FINISH_LOAD_INITIAL; @@ -57,7 +57,7 @@ async fn handle_action( .expect("failed to send command"); }), AsyncAction::AddMod(state, info) => tokio::spawn(async move { - match import_mod(state, info) + match import_from_file(state, info) .await .wrap_err("Failed to import mod") { @@ -186,6 +186,28 @@ async fn handle_action( let _ = f.write_all(&line).await; } }), + AsyncAction::NxmDownload(state, uri) => tokio::spawn(async move { + match import_from_nxm(state, uri) + .await + .wrap_err("Failed to handle NXM URI") + { + Ok(mod_info) => { + event_sink + .write() + .await + .submit_command( + ACTION_FINISH_ADD_MOD, + SingleUse::new(Arc::new(mod_info)), + Target::Auto, + ) + .expect("failed to send command"); + } + Err(err) => { + tracing::error!("{:?}", err); + send_error(event_sink.clone(), err).await; + } + } + }), }; } } diff --git a/crates/dtmm/src/main.rs b/crates/dtmm/src/main.rs index 8069e6d..b9c1daf 100644 --- a/crates/dtmm/src/main.rs +++ b/crates/dtmm/src/main.rs @@ -10,12 +10,13 @@ use std::sync::Arc; use clap::parser::ValueSource; use clap::{command, value_parser, Arg}; use color_eyre::eyre::{self, Context}; -use color_eyre::{Report, Result}; +use color_eyre::{Report, Result, Section}; use druid::AppLauncher; +use interprocess::local_socket::{LocalSocketListener, LocalSocketStream}; use tokio::sync::RwLock; use crate::controller::worker::work_thread; -use crate::state::AsyncAction; +use crate::state::{AsyncAction, ACTION_HANDLE_NXM}; use crate::state::{Delegate, State}; use crate::ui::theme; use crate::util::log::LogLevel; @@ -29,6 +30,37 @@ mod util { } mod ui; +// As explained in https://docs.rs/interprocess/latest/interprocess/local_socket/enum.NameTypeSupport.html +// namespaces are supported on both platforms we care about: Windows and Linux. +const IPC_ADDRESS: &str = "@dtmm.sock"; + +#[tracing::instrument] +fn notify_nxm_download( + uri: impl AsRef + std::fmt::Debug, + level: Option, +) -> Result<()> { + util::log::create_tracing_subscriber(level, None); + + tracing::debug!("Received Uri '{}', sending to main process.", uri.as_ref()); + + let mut stream = LocalSocketStream::connect(IPC_ADDRESS) + .wrap_err_with(|| format!("Failed to connect to '{}'", IPC_ADDRESS)) + .suggestion("Make sure the main window is open.")?; + + tracing::debug!("Connected to main process at '{}'", IPC_ADDRESS); + + bincode::serialize_into(&mut stream, uri.as_ref()).wrap_err("Failed to send URI")?; + + // We don't really care what the message is, we just need an acknowledgement. + let _: String = bincode::deserialize_from(&mut stream).wrap_err("Failed to receive reply")?; + + tracing::info!( + "Notified DTMM with uri '{}'. Check the main window.", + uri.as_ref() + ); + Ok(()) +} + #[tracing::instrument] fn main() -> Result<()> { color_eyre::install()?; @@ -53,15 +85,25 @@ fn main() -> Result<()> { .value_parser(value_parser!(LogLevel)) .default_value("info"), ) + .arg( + Arg::new("nxm") + .help("An `nxm://` URI to download") + .required(false), + ) .get_matches(); - let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel(); let level = if matches.value_source("log-level") == Some(ValueSource::DefaultValue) { None } else { matches.get_one::("log-level").cloned() }; - util::log::create_tracing_subscriber(log_tx, level); + + if let Some(uri) = matches.get_one::("nxm") { + return notify_nxm_download(uri, level).wrap_err("Failed to send NXM Uri to main window."); + } + + let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel(); + util::log::create_tracing_subscriber(level, Some(log_tx)); let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -84,6 +126,59 @@ fn main() -> Result<()> { let event_sink = launcher.get_external_handle(); + { + let span = tracing::info_span!(IPC_ADDRESS, "nxm-socket"); + let _guard = span.enter(); + + let event_sink = event_sink.clone(); + let server = + LocalSocketListener::bind(IPC_ADDRESS).wrap_err("Failed to create IPC listener")?; + + tracing::debug!("IPC server listening on '{}'", IPC_ADDRESS); + + // Drop the guard here, so that we can re-enter the same span in the thread. + drop(_guard); + + std::thread::Builder::new() + .name("nxm-socket".into()) + .spawn(move || { + let _guard = span.enter(); + + loop { + let res = server.accept().wrap_err_with(|| { + format!("IPC server failed to listen on '{}'", IPC_ADDRESS) + }); + + match res { + Ok(mut stream) => { + let res = bincode::deserialize_from(&mut stream) + .wrap_err("Failed to read message") + .and_then(|uri: String| { + tracing::trace!(uri, "Received NXM uri"); + + event_sink + .submit_command(ACTION_HANDLE_NXM, uri, druid::Target::Auto) + .wrap_err("Failed to start NXM download") + }); + match res { + Ok(()) => { + let _ = bincode::serialize_into(&mut stream, "Ok"); + } + Err(err) => { + tracing::error!("{:?}", err); + let _ = bincode::serialize_into(&mut stream, "Error"); + } + } + } + Err(err) => { + tracing::error!("Failed to receive client connection: {:?}", err) + } + } + } + }) + .wrap_err("Failed to create thread")?; + } + std::thread::Builder::new() .name("work-thread".into()) .spawn(move || { @@ -97,7 +192,7 @@ fn main() -> Result<()> { } } }) - .wrap_err("Work thread panicked")?; + .wrap_err("Failed to create thread")?; 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 e5b70c4..6b5a706 100644 --- a/crates/dtmm/src/state/data.rs +++ b/crates/dtmm/src/state/data.rs @@ -95,7 +95,7 @@ impl From for NexusInfo { } } -#[derive(Clone, Data, Debug, Lens)] +#[derive(Clone, Data, Lens)] pub(crate) struct ModInfo { pub id: String, pub name: String, @@ -118,6 +118,39 @@ pub(crate) struct ModInfo { pub nexus: Option, } +impl std::fmt::Debug for ModInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ModInfo") + .field("id", &self.id) + .field("name", &self.name) + .field("summary", &self.summary) + .field( + "description", + &(match &self.description { + Some(desc) => format!("Some(String[0..{}])", desc.len()), + None => "None".to_string(), + }), + ) + .field("categories", &self.categories) + .field("author", &self.author) + .field( + "image", + &(match &self.image { + Some(image) => format!("Some(ImageBuf[{}x{}])", image.width(), image.height()), + None => "None".to_string(), + }), + ) + .field("version", &self.version) + .field("enabled", &self.enabled) + .field("packages", &format!("Vec[0..{}]", self.packages.len())) + .field("resources", &self.resources) + .field("depends", &self.depends) + .field("bundled", &self.bundled) + .field("nexus", &self.nexus) + .finish() + } +} + impl ModInfo { pub fn new( cfg: ModConfig, diff --git a/crates/dtmm/src/state/delegate.rs b/crates/dtmm/src/state/delegate.rs index 47d56e8..e30b878 100644 --- a/crates/dtmm/src/state/delegate.rs +++ b/crates/dtmm/src/state/delegate.rs @@ -32,6 +32,7 @@ pub(crate) const ACTION_START_RESET_DEPLOYMENT: Selector = pub(crate) const ACTION_FINISH_RESET_DEPLOYMENT: Selector = Selector::new("dtmm.action.finish-reset-deployment"); +pub(crate) const ACTION_HANDLE_NXM: Selector = Selector::new("dtmm.action.handle-nxm"); pub(crate) const ACTION_ADD_MOD: Selector = Selector::new("dtmm.action.add-mod"); pub(crate) const ACTION_FINISH_ADD_MOD: Selector>> = Selector::new("dtmm.action.finish-add-mod"); @@ -97,6 +98,7 @@ pub(crate) enum AsyncAction { CheckUpdates(ActionState), LoadInitial((PathBuf, bool)), Log((ActionState, Vec)), + NxmDownload(ActionState, String), } impl std::fmt::Debug for AsyncAction { @@ -116,6 +118,9 @@ impl std::fmt::Debug for AsyncAction { path, is_default ), AsyncAction::Log(_) => write!(f, "AsyncAction::Log(_)"), + AsyncAction::NxmDownload(_, uri) => { + write!(f, "AsyncAction::NxmDownload(_state, {})", uri) + } } } } @@ -250,6 +255,20 @@ impl AppDelegate for Delegate { Handled::Yes } + cmd if cmd.is(ACTION_HANDLE_NXM) => { + let uri = cmd + .get(ACTION_HANDLE_NXM) + .expect("command type match but didn't contain the expected value"); + + if self + .sender + .send(AsyncAction::NxmDownload(state.clone().into(), uri.clone())) + .is_err() + { + tracing::error!("Failed to queue action to download NXM mod"); + } + Handled::Yes + } cmd if cmd.is(ACTION_ADD_MOD) => { let info = cmd .get(ACTION_ADD_MOD) diff --git a/crates/dtmm/src/util/log.rs b/crates/dtmm/src/util/log.rs index fa4a643..4b7c15a 100644 --- a/crates/dtmm/src/util/log.rs +++ b/crates/dtmm/src/util/log.rs @@ -8,7 +8,7 @@ use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::prelude::*; use tracing_subscriber::EnvFilter; -#[derive(Clone, Copy, ValueEnum)] +#[derive(Clone, Copy, Debug, ValueEnum)] pub enum LogLevel { Trace, Debug, @@ -55,7 +55,7 @@ impl std::io::Write for ChannelWriter { } } -pub fn create_tracing_subscriber(tx: UnboundedSender>, level: Option) { +pub fn create_tracing_subscriber(level: Option, tx: Option>>) { let mut env_layer = if let Some(level) = level { EnvFilter::from(level) } else if cfg!(debug_assertions) { @@ -78,11 +78,13 @@ pub fn create_tracing_subscriber(tx: UnboundedSender>, level: Option Result { + let url = BASE_URL_GAME.join(&format!("mods/{mod_id}/files/{file_id}.json"))?; + let req = self.client.get(url); + self.send(req).await + } + pub fn parse_file_name>( name: S, ) -> Option<(String, u64, String, OffsetDateTime)> { @@ -174,7 +181,7 @@ impl Api { self.send(req).await } - pub async fn handle_nxm(&self, url: Url) -> Result<(Mod, Vec)> { + pub async fn handle_nxm(&self, url: Url) -> Result<(Mod, File, Vec)> { let nxm = Self::parse_nxm(url.clone())?; let user = self.user_validate().await?; @@ -183,8 +190,9 @@ impl Api { return Err(Error::InvalidNXM("user_id mismtach", url)); } - let (mod_data, download_info) = futures::try_join!( + let (mod_data, file_info, download_info) = futures::try_join!( self.mods_id(nxm.mod_id), + self.get_file_by_id(nxm.mod_id, nxm.file_id), self.mods_download_link(nxm.mod_id, nxm.file_id, nxm.key, nxm.expires) )?; @@ -195,7 +203,7 @@ impl Api { let req = self.client.get(download_url); let data = req.send().await?.bytes().await?; - Ok((mod_data, data.to_vec())) + Ok((mod_data, file_info, data.to_vec())) } pub fn parse_nxm(nxm: Url) -> Result { @@ -204,17 +212,20 @@ impl Api { } // Now it makes sense, why Nexus calls this field `game_domain_name`, when it's just - // another path segmentin the regular API calls. + // another path segment in the regular API calls. if nxm.host_str() != Some(GAME_ID) { return Err(Error::InvalidNXM("Invalid game domain name", nxm)); } let Some(mut segments) = nxm.path_segments() else { - return Err(Error::InvalidNXM("Cannot be a base", nxm)); + return Err(Error::InvalidNXM("Missing path segments", nxm)); }; if segments.next() != Some("mods") { - return Err(Error::InvalidNXM("Unexpected path segment", nxm)); + return Err(Error::InvalidNXM( + "Unexpected path segment, expected 'mods'", + nxm, + )); } let Some(mod_id) = segments.next().and_then(|id| id.parse().ok()) else { @@ -222,7 +233,10 @@ impl Api { }; if segments.next() != Some("files") { - return Err(Error::InvalidNXM("Unexpected path segment", nxm)); + return Err(Error::InvalidNXM( + "Unexpected path segment, expected 'files'", + nxm, + )); } let Some(file_id) = segments.next().and_then(|id| id.parse().ok()) else { @@ -237,7 +251,7 @@ impl Api { } let Some(key) = query.get("key") else { - return Err(Error::InvalidNXM("Missing 'key'", nxm)); + return Err(Error::InvalidNXM("Missing query field 'key'", nxm)); }; let expires = query @@ -245,12 +259,12 @@ impl Api { .and_then(|expires| expires.parse().ok()) .and_then(|expires| OffsetDateTime::from_unix_timestamp(expires).ok()); let Some(expires) = expires else { - return Err(Error::InvalidNXM("Missing 'expires'", nxm)); + return Err(Error::InvalidNXM("Missing query field 'expires'", nxm)); }; let user_id = query.get("user_id").and_then(|id| id.parse().ok()); let Some(user_id) = user_id else { - return Err(Error::InvalidNXM("Missing 'user_id'", nxm)); + return Err(Error::InvalidNXM("Missing query field 'user_id'", nxm)); }; Ok(Nxm {