Handle NXM URIs #150

Merged
lucas merged 3 commits from feat/nexus-uri-handler into master 2023-12-01 09:22:09 +01:00
11 changed files with 296 additions and 43 deletions

View file

@ -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

30
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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;

View file

@ -405,11 +405,10 @@ fn extract_legacy_mod<R: Read + Seek>(
}
#[tracing::instrument(skip(state))]
pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result<ModInfo> {
pub(crate) async fn import_from_file(state: ActionState, 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 nexus = if let Some((_, id, version, timestamp)) = info
.path
@ -450,6 +449,32 @@ pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result<Mod
tracing::trace!(?nexus);
import_mod(state, nexus, data).await
}
#[tracing::instrument(skip(state))]
pub(crate) async fn import_from_nxm(state: ActionState, uri: String) -> Result<ModInfo> {
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<u8>,
) -> Result<ModInfo> {
let data = Cursor::new(data);
let mut archive = ZipArchive::new(data).wrap_err("Failed to open ZIP archive")?;
if tracing::enabled!(tracing::Level::DEBUG) {

View file

@ -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;
}
}
}),
};
}
}

View file

@ -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<str> + std::fmt::Debug,
level: Option<LogLevel>,
) -> 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::<LogLevel>("log-level").cloned()
};
util::log::create_tracing_subscriber(log_tx, level);
if let Some(uri) = matches.get_one::<String>("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)
}

View file

@ -95,7 +95,7 @@ impl From<NexusMod> 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<NexusInfo>,
}
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,

View file

@ -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<String> = Selector::new("dtmm.action.handle-nxm");
pub(crate) const ACTION_ADD_MOD: Selector<FileInfo> = Selector::new("dtmm.action.add-mod");
pub(crate) const ACTION_FINISH_ADD_MOD: Selector<SingleUse<Arc<ModInfo>>> =
Selector::new("dtmm.action.finish-add-mod");
@ -97,6 +98,7 @@ pub(crate) enum AsyncAction {
CheckUpdates(ActionState),
LoadInitial((PathBuf, bool)),
Log((ActionState, Vec<u8>)),
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<State> 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)

View file

@ -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<Vec<u8>>, level: Option<LogLevel>) {
pub fn create_tracing_subscriber(level: Option<LogLevel>, tx: Option<UnboundedSender<Vec<u8>>>) {
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<Vec<u8>>, level: Option<Log
let stdout_layer = fmt::layer().pretty();
let channel_layer = fmt::layer()
.event_format(dtmt_shared::Formatter)
.fmt_fields(debug_fn(dtmt_shared::format_fields))
.with_writer(move || ChannelWriter::new(tx.clone()))
.with_filter(FilterFn::new(dtmt_shared::filter_fields));
let channel_layer = tx.map(|tx| {
fmt::layer()
.event_format(dtmt_shared::Formatter)
.fmt_fields(debug_fn(dtmt_shared::format_fields))
.with_writer(move || ChannelWriter::new(tx.clone()))
.with_filter(FilterFn::new(dtmt_shared::filter_fields))
});
tracing_subscriber::registry()
.with(env_layer)

View file

@ -136,6 +136,13 @@ impl Api {
.map_err(From::from)
}
#[tracing::instrument(skip(self))]
pub async fn get_file_by_id(&self, mod_id: u64, file_id: u64) -> Result<File> {
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<S: AsRef<str>>(
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<u8>)> {
pub async fn handle_nxm(&self, url: Url) -> Result<(Mod, File, Vec<u8>)> {
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<Nxm> {
@ -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 {