Implement Nexus integration #54

Merged
lucas merged 15 commits from feat/nexus into master 2023-03-15 21:43:37 +01:00
18 changed files with 1225 additions and 23 deletions

View file

@ -12,6 +12,7 @@
- dtmm: show dialog for critical errors
- dtmm: check mod order before deployment
- dtmt: add mod dependencies to config
- dtmm: match mods to Nexus and check for updates
=== Fixed

429
Cargo.lock generated
View file

@ -130,6 +130,12 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a"
[[package]]
name = "base64ct"
version = "1.6.0"
@ -756,6 +762,7 @@ dependencies = [
"dtmt-shared",
"futures",
"lazy_static",
"nexusmods",
"oodle-sys",
"path-slash",
"sdk",
@ -768,6 +775,7 @@ dependencies = [
"tracing",
"tracing-error",
"tracing-subscriber",
"usvg",
"zip",
]
@ -831,6 +839,15 @@ dependencies = [
"wio",
]
[[package]]
name = "encoding_rs"
version = "0.8.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071a31f4ee85403370b58aca746f01041ede6f0da2730960ad001edc2b71b394"
dependencies = [
"cfg-if",
]
[[package]]
name = "endian-type"
version = "0.1.2"
@ -1021,6 +1038,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8"
dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@ -1373,6 +1399,25 @@ dependencies = [
"syn",
]
[[package]]
name = "h2"
version = "0.3.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be7b54589b581f624f566bf5d8eb2bab1db736c51528720b6bd36b96b55924d"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@ -1409,6 +1454,87 @@ dependencies = [
"digest",
]
[[package]]
name = "http"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482"
dependencies = [
"bytes",
"fnv",
"itoa",
]
[[package]]
name = "http-body"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1"
dependencies = [
"bytes",
"http",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
[[package]]
name = "httpdate"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
[[package]]
name = "hyper"
version = "0.14.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc5e554ff619822309ffd57d8734d77cd5ce6238bc956f037ea06c58238c9899"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper",
"native-tls",
"tokio",
"tokio-native-tls",
]
[[package]]
name = "idna"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "im"
version = "15.1.0"
@ -1516,6 +1642,12 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "ipnet"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30e22bd8629359895450b59ea7a776c850561b96a3b1d31321c1949d9e6c9146"
[[package]]
name = "is-terminal"
version = "0.4.4"
@ -1722,6 +1854,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@ -1755,6 +1893,41 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3"
[[package]]
name = "native-tls"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nexusmods"
version = "0.1.0"
dependencies = [
"futures",
"lazy_static",
"regex",
"reqwest",
"serde",
"serde_json",
"thiserror",
"time",
"tokio",
"tracing",
"url",
]
[[package]]
name = "nibble_vec"
version = "0.1.0"
@ -1986,6 +2159,51 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "openssl"
version = "0.10.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd2523381e46256e40930512c7fd25562b9eae4812cb52078f155e87217c9d1e"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "176be2629957c157240f68f61f2d0053ad3a4ecfdd9ebf1e6521d18d9635cf67"
dependencies = [
"autocfg",
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "os_str_bytes"
version = "6.4.1"
@ -2092,6 +2310,12 @@ dependencies = [
"sha2",
]
[[package]]
name = "percent-encoding"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e"
[[package]]
name = "pest"
version = "2.5.6"
@ -2397,6 +2621,43 @@ version = "0.6.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848"
[[package]]
name = "reqwest"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21eed90ec8570952d53b772ecf8f206aa1ec9a3d76b2521c56c42973f2d91ee9"
dependencies = [
"base64 0.21.0",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"hyper",
"hyper-tls",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"serde",
"serde_json",
"serde_urlencoded",
"tokio",
"tokio-native-tls",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winreg",
]
[[package]]
name = "resvg"
version = "0.25.0"
@ -2532,6 +2793,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "713cfb06c7059f3588fb8044c0fad1d09e3c01d225e25b9220dbfdcf16dbb1b3"
dependencies = [
"windows-sys 0.42.0",
]
[[package]]
name = "scopeguard"
version = "1.1.0"
@ -2565,6 +2835,29 @@ dependencies = [
"tracing-error",
]
[[package]]
name = "security-framework"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a332be01508d814fed64bf28f798a146d73792121129962fdf335bb3c49a4254"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31c9bb296072e961fcbd8853511dd39c2d8be2deb1e17c6860b1d30732b323b4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "self_cell"
version = "0.10.2"
@ -2597,6 +2890,17 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c533a59c9d8a93a09c6ab31f0fd5e5f4dd1b8fc9434804029839884765d04ea"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_sjson"
version = "1.0.0"
@ -2606,6 +2910,18 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sha1"
version = "0.10.5"
@ -2686,6 +3002,16 @@ version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
[[package]]
name = "socket2"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "steamid-ng"
version = "1.0.0"
@ -2925,6 +3251,21 @@ dependencies = [
"displaydoc",
]
[[package]]
name = "tinyvec"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.26.0"
@ -2939,6 +3280,7 @@ dependencies = [
"num_cpus",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.45.0",
@ -2955,6 +3297,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.12"
@ -2966,6 +3318,20 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
"tracing",
]
[[package]]
name = "toml"
version = "0.5.11"
@ -2992,6 +3358,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "tower-service"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
[[package]]
name = "tracing"
version = "0.1.37"
@ -3075,6 +3447,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "try-lock"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
[[package]]
name = "ttf-parser"
version = "0.17.1"
@ -3210,6 +3588,15 @@ version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4"
[[package]]
name = "unicode-normalization"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-script"
version = "0.5.5"
@ -3234,13 +3621,25 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
[[package]]
name = "url"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "usvg"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585bb2d87c8fd6041a479dea01479dcf9094e61b5f9af221606927e61a2bd939"
dependencies = [
"base64",
"base64 0.13.1",
"data-url",
"flate2",
"fontdb",
@ -3278,6 +3677,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.1.1"
@ -3322,6 +3727,16 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0"
dependencies = [
"log",
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
@ -3353,6 +3768,18 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f219e0d211ba40266969f6dbdd90636da12f75bee4fc9d6c23d1260dadb51454"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.84"

View file

@ -15,6 +15,7 @@ dtmt-shared = { path = "../../lib/dtmt-shared", version = "*" }
futures = "0.3.25"
oodle-sys = { path = "../../lib/oodle-sys", version = "*" }
sdk = { path = "../../lib/sdk", version = "*" }
nexusmods = { path = "../../lib/nexusmods", version = "*" }
serde_sjson = { path = "../../lib/serde_sjson", version = "*" }
serde = { version = "1.0.152", features = ["derive", "rc"] }
tokio = { version = "1.23.0", features = ["rt", "fs", "tracing", "sync"] }
@ -28,3 +29,4 @@ time = { version = "0.3.20", features = ["serde", "serde-well-known", "local-off
strip-ansi-escapes = "0.1.1"
lazy_static = "1.4.0"
colors-transform = "0.2.11"
usvg = "0.25.0"

View file

@ -8,13 +8,14 @@ use color_eyre::{Help, Report, Result};
use druid::im::Vector;
use druid::{FileInfo, ImageBuf};
use dtmt_shared::ModConfig;
use nexusmods::Api as NexusApi;
use tokio::fs::{self, DirEntry};
use tokio::runtime::Runtime;
use tokio_stream::wrappers::ReadDirStream;
use tokio_stream::StreamExt;
use zip::ZipArchive;
use crate::state::{ActionState, ModInfo, ModOrder, PackageInfo};
use crate::state::{ActionState, ModInfo, ModOrder, NexusInfo, PackageInfo};
use crate::util::config::{ConfigSerialize, LoadOrderEntry};
use super::read_sjson_file;
@ -26,6 +27,26 @@ pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result<Mod
.wrap_err_with(|| format!("Failed to read file {}", info.path.display()))?;
let data = Cursor::new(data);
let nexus = if let Some((_, id, _, _)) = info
.path
.file_name()
.and_then(|s| s.to_str())
.and_then(NexusApi::parse_file_name)
{
if !state.nexus_api_key.is_empty() {
let api = NexusApi::new(state.nexus_api_key.to_string())?;
let mod_info = api
.mods_id(id)
.await
.wrap_err_with(|| format!("Failed to query mod {} from Nexus", id))?;
Some(NexusInfo::from(mod_info))
} else {
None
}
} else {
None
};
let mut archive = ZipArchive::new(data).wrap_err("Failed to open ZIP archive")?;
if tracing::enabled!(tracing::Level::DEBUG) {
@ -137,11 +158,19 @@ pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result<Mod
.extract(Arc::as_ref(&mod_dir))
.wrap_err_with(|| format!("Failed to extract archive to {}", mod_dir.display()))?;
if let Some(nexus) = &nexus {
let data = serde_sjson::to_string(nexus).wrap_err("Failed to serialize Nexus info")?;
let path = mod_dir.join(&mod_cfg.id).join("nexus.sjson");
fs::write(&path, data.as_bytes())
.await
.wrap_err_with(|| format!("Failed to write Nexus info to '{}'", path.display()))?;
}
let packages = files
.into_iter()
.map(|(name, files)| Arc::new(PackageInfo::new(name, files.into_iter().collect())))
.collect();
let info = ModInfo::new(mod_cfg, packages, image);
let info = ModInfo::new(mod_cfg, packages, image, nexus);
Ok(info)
}
@ -181,12 +210,25 @@ pub(crate) async fn save_settings(state: ActionState) -> Result<()> {
async fn read_mod_dir_entry(res: Result<DirEntry>) -> Result<ModInfo> {
let entry = res?;
let config_path = entry.path().join("dtmt.cfg");
let nexus_path = entry.path().join("nexus.sjson");
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 nexus: Option<NexusInfo> = match read_sjson_file(&nexus_path)
.await
.wrap_err_with(|| format!("Failed to read Nexus info '{}'", nexus_path.display()))
{
Ok(nexus) => Some(nexus),
Err(err) if err.is::<std::io::Error>() => match err.downcast_ref::<std::io::Error>() {
Some(err) if err.kind() == std::io::ErrorKind::NotFound => None,
_ => return Err(err),
},
Err(err) => return Err(err),
};
let files: HashMap<String, Vec<String>> = read_sjson_file(&index_path)
.await
.wrap_err_with(|| format!("Failed to read file index '{}'", index_path.display()))?;
@ -222,7 +264,7 @@ async fn read_mod_dir_entry(res: Result<DirEntry>) -> Result<ModInfo> {
.into_iter()
.map(|(name, files)| Arc::new(PackageInfo::new(name, files.into_iter().collect())))
.collect();
let info = ModInfo::new(cfg, packages, image);
let info = ModInfo::new(cfg, packages, image, nexus);
Ok(info)
}
@ -334,3 +376,49 @@ pub(crate) fn check_mod_order(state: &ActionState) -> Result<()> {
Ok(())
}
#[tracing::instrument(skip(info, api), fields(id = info.id, name = info.name, version = info.version))]
async fn check_mod_update(info: Arc<ModInfo>, api: Arc<NexusApi>) -> Result<Option<ModInfo>> {
let Some(nexus) = &info.nexus else {
return Ok(None);
};
let updated_info = api
.mods_id(nexus.id)
.await
.wrap_err_with(|| format!("Failed to query mod {} from Nexus", nexus.id))?;
let mut info = Arc::unwrap_or_clone(info);
info.nexus = Some(NexusInfo::from(updated_info));
Ok(Some(info))
}
#[tracing::instrument(skip(state))]
pub(crate) async fn check_updates(state: ActionState) -> Result<Vec<ModInfo>> {
if state.nexus_api_key.is_empty() {
eyre::bail!("Nexus API key not set. Cannot check for updates.");
}
let api = NexusApi::new(state.nexus_api_key.to_string())
.wrap_err("Failed to initialize Nexus API")?;
let api = Arc::new(api);
let tasks = state
.mods
.iter()
.map(|info| check_mod_update(info.clone(), api.clone()));
let results = futures::future::join_all(tasks).await;
let updates = results
.into_iter()
.filter_map(|res| match res {
Ok(info) => info,
Err(err) => {
tracing::error!("{:?}", err);
None
}
})
.collect();
Ok(updates)
}

View file

@ -12,6 +12,7 @@ use tokio::sync::RwLock;
use crate::controller::app::*;
use crate::controller::game::*;
use crate::state::AsyncAction;
use crate::state::ACTION_FINISH_CHECK_UPDATE;
use crate::state::ACTION_FINISH_SAVE_SETTINGS;
use crate::state::ACTION_SHOW_ERROR_DIALOG;
use crate::state::{
@ -120,6 +121,29 @@ async fn handle_action(
.submit_command(ACTION_FINISH_SAVE_SETTINGS, (), Target::Auto)
.expect("failed to send command");
}),
AsyncAction::CheckUpdates(state) => tokio::spawn(async move {
let updates = match check_updates(state)
.await
.wrap_err("Failed to check for updates")
{
Ok(updates) => updates,
Err(err) => {
tracing::error!("{:?}", err);
send_error(event_sink.clone(), err).await;
vec![]
}
};
event_sink
.write()
.await
.submit_command(
ACTION_FINISH_CHECK_UPDATE,
SingleUse::new(updates),
Target::Auto,
)
.expect("failed to send command");
}),
};
}
}

View file

@ -1,5 +1,6 @@
#![recursion_limit = "256"]
#![feature(let_chains)]
#![feature(arc_unwrap_or_clone)]
#![windows_subsystem = "windows"]
use std::path::PathBuf;
@ -91,6 +92,7 @@ fn main() -> Result<()> {
config.path,
game_dir.unwrap_or_default(),
config.data_dir.unwrap_or_default(),
config.nexus_api_key.unwrap_or_default(),
);
state.mods = load_mods(state.get_mod_dir(), config.mod_order.iter())
.wrap_err("Failed to load mods")?;

View file

@ -1,10 +1,10 @@
use std::{path::PathBuf, sync::Arc};
use std::path::PathBuf;
use std::sync::Arc;
use druid::{
im::{HashMap, Vector},
Data, ImageBuf, Lens, WindowHandle, WindowId,
};
use druid::im::{HashMap, Vector};
use druid::{Data, ImageBuf, Lens, WindowHandle, WindowId};
use dtmt_shared::ModConfig;
use nexusmods::Mod as NexusMod;
use super::SelectedModLens;
@ -69,6 +69,27 @@ impl From<dtmt_shared::ModDependency> for ModDependency {
}
}
#[derive(Clone, Data, Debug, Lens, serde::Serialize, serde::Deserialize)]
pub(crate) struct NexusInfo {
pub id: u64,
pub version: String,
pub author: String,
pub summary: Arc<String>,
pub description: Arc<String>,
}
impl From<NexusMod> for NexusInfo {
fn from(value: NexusMod) -> Self {
Self {
id: value.mod_id,
version: value.version,
author: value.author,
summary: Arc::new(value.summary),
description: Arc::new(value.description),
}
}
}
#[derive(Clone, Data, Debug, Lens)]
pub(crate) struct ModInfo {
pub id: String,
@ -87,6 +108,8 @@ pub(crate) struct ModInfo {
#[data(ignore)]
pub resources: ModResourceInfo,
pub depends: Vector<ModDependency>,
#[data(ignore)]
pub nexus: Option<NexusInfo>,
}
impl ModInfo {
@ -94,6 +117,7 @@ impl ModInfo {
cfg: ModConfig,
packages: Vector<Arc<PackageInfo>>,
image: Option<ImageBuf>,
nexus: Option<NexusInfo>,
) -> Self {
Self {
id: cfg.id,
@ -112,6 +136,7 @@ impl ModInfo {
localization: cfg.resources.localization,
},
depends: cfg.depends.into_iter().map(ModDependency::from).collect(),
nexus,
}
}
}
@ -126,8 +151,10 @@ pub(crate) struct State {
pub is_reset_in_progress: bool,
pub is_save_in_progress: bool,
pub is_next_save_pending: bool,
pub is_update_in_progress: bool,
pub game_dir: Arc<PathBuf>,
pub data_dir: Arc<PathBuf>,
pub nexus_api_key: Arc<String>,
pub log: Arc<String>,
#[lens(ignore)]
@ -145,7 +172,12 @@ impl State {
#[allow(non_upper_case_globals)]
pub const selected_mod: SelectedModLens = SelectedModLens;
pub fn new(config_path: PathBuf, game_dir: PathBuf, data_dir: PathBuf) -> Self {
pub fn new(
config_path: PathBuf,
game_dir: PathBuf,
data_dir: PathBuf,
nexus_api_key: String,
) -> Self {
let ctx = sdk::Context::new();
Self {
@ -158,9 +190,11 @@ impl State {
is_reset_in_progress: false,
is_save_in_progress: false,
is_next_save_pending: false,
is_update_in_progress: false,
config_path: Arc::new(config_path),
game_dir: Arc::new(game_dir),
data_dir: Arc::new(data_dir),
nexus_api_key: Arc::new(nexus_api_key),
log: Arc::new(String::new()),
windows: HashMap::new(),
}

View file

@ -39,6 +39,11 @@ pub(crate) const ACTION_START_SAVE_SETTINGS: Selector =
pub(crate) const ACTION_FINISH_SAVE_SETTINGS: Selector =
Selector::new("dtmm.action.finish-save-settings");
pub(crate) const ACTION_START_CHECK_UPDATE: Selector =
Selector::new("dtmm.action.start-check-update");
pub(crate) const ACTION_FINISH_CHECK_UPDATE: Selector<SingleUse<Vec<ModInfo>>> =
Selector::new("dtmm.action.finish-check-update");
pub(crate) const ACTION_SET_DIRTY: Selector = Selector::new("dtmm.action.set-dirty");
pub(crate) const ACTION_SHOW_ERROR_DIALOG: Selector<SingleUse<Report>> =
@ -56,6 +61,7 @@ pub(crate) struct ActionState {
pub mod_dir: Arc<PathBuf>,
pub config_path: Arc<PathBuf>,
pub ctx: Arc<sdk::Context>,
pub nexus_api_key: Arc<String>,
}
impl From<State> for ActionState {
@ -67,6 +73,7 @@ impl From<State> for ActionState {
data_dir: state.data_dir,
config_path: state.config_path,
ctx: state.ctx,
nexus_api_key: state.nexus_api_key,
}
}
}
@ -77,6 +84,7 @@ pub(crate) enum AsyncAction {
AddMod(ActionState, FileInfo),
DeleteMod(ActionState, Arc<ModInfo>),
SaveSettings(ActionState),
CheckUpdates(ActionState),
}
pub(crate) struct Delegate {
@ -302,6 +310,50 @@ impl AppDelegate<State> for Delegate {
state.windows.insert(id, handle);
Handled::Yes
}
cmd if cmd.is(ACTION_START_CHECK_UPDATE) => {
if self
.sender
.send(AsyncAction::CheckUpdates(state.clone().into()))
.is_ok()
{
state.is_update_in_progress = true;
} else {
tracing::error!("Failed to queue action to check updates");
}
Handled::Yes
}
cmd if cmd.is(ACTION_FINISH_CHECK_UPDATE) => {
let mut updates = cmd
.get(ACTION_FINISH_CHECK_UPDATE)
.and_then(SingleUse::take)
.expect("command type matched but didn't contain the expected value");
if tracing::enabled!(tracing::Level::DEBUG) {
let mods: Vec<_> = updates
.iter()
.map(|info| {
format!(
"{}: {} -> {:?}",
info.name,
info.version,
info.nexus.as_ref().map(|n| &n.version)
)
})
.collect();
tracing::info!("Mod updates:\n{}", mods.join("\n"));
}
for mod_info in state.mods.iter_mut() {
if let Some(index) = updates.iter().position(|i2| i2.id == mod_info.id) {
let update = updates.swap_remove(index);
*mod_info = Arc::new(update);
}
}
state.is_update_in_progress = false;
Handled::Yes
}
cmd => {
if cfg!(debug_assertions) {
tracing::warn!("Unknown command: {:?}", cmd);

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use druid::im::Vector;
use druid::{Data, Lens};
use super::{ModInfo, State};
use super::{ModInfo, NexusInfo, State};
pub(crate) struct SelectedModLens;
@ -73,3 +73,51 @@ impl<T: Data> Lens<Vector<T>, Vector<(usize, T)>> for IndexedVectorLens {
ret
}
}
/// A Lens that first checks a key in a mod's `NexusInfo`, then falls back to
/// the regular one.
pub(crate) struct NexusInfoLens<T, L, R>
where
L: Lens<NexusInfo, T>,
R: Lens<ModInfo, T>,
{
value: L,
fallback: R,
_marker: std::marker::PhantomData<T>,
}
impl<T: Data, L, R> NexusInfoLens<T, L, R>
where
L: Lens<NexusInfo, T>,
R: Lens<ModInfo, T>,
{
pub fn new(value: L, fallback: R) -> Self {
Self {
value,
fallback,
_marker: std::marker::PhantomData,
}
}
}
impl<T: Data, L, R> Lens<ModInfo, T> for NexusInfoLens<T, L, R>
where
L: Lens<NexusInfo, T>,
R: Lens<ModInfo, T>,
{
fn with<V, F: FnOnce(&T) -> V>(&self, data: &ModInfo, f: F) -> V {
if let Some(nexus) = &data.nexus {
self.value.with(nexus, f)
} else {
self.fallback.with(data, f)
}
}
fn with_mut<V, F: FnOnce(&mut T) -> V>(&self, data: &mut ModInfo, f: F) -> V {
if let Some(nexus) = &mut data.nexus {
self.value.with_mut(nexus, f)
} else {
self.fallback.with_mut(data, f)
}
}
}

View file

@ -63,7 +63,7 @@ pub mod gruvbox_dark {
make_color!(COLOR_ORANGE_DARK, 0xd6, 0x5d, 0x0e);
make_color!(COLOR_ORANGE_LIGHT, 0xfe, 0x80, 0x19);
make_color!(COLOR_ACCENT, COLOR_RED_LIGHT);
make_color!(COLOR_ACCENT, COLOR_BLUE_LIGHT);
make_color!(COLOR_ACCENT_FG, COLOR_BG0_H);
}

View file

@ -1 +1,41 @@
use druid::Color;
use usvg::{
Error, Fill, LineCap, LineJoin, NodeKind, NonZeroPositiveF64, Options, Paint, Stroke, Tree,
};
pub static ALERT_CIRCLE: &str = include_str!("../../../assets/icons/icons/alert-circle.svg");
pub static ALERT_TRIANGLE: &str = include_str!("../../../assets/icons/icons/alert-triangle.svg");
pub fn parse_svg(svg: &str) -> Result<Tree, Error> {
let opt = Options::default();
Tree::from_str(svg, &opt.to_ref())
}
pub fn recolor_icon(tree: Tree, stroke: bool, color: Color) -> Tree {
let (red, green, blue, _) = color.as_rgba8();
let mut children = tree.root.children();
// The first element is always some kind of background placeholder
children.next();
for node in children {
if let NodeKind::Path(ref mut path) = *node.borrow_mut() {
if stroke {
path.stroke = Some(Stroke {
paint: Paint::Color(usvg::Color { red, green, blue }),
width: NonZeroPositiveF64::new(2.).expect("the value is not zero"),
linecap: LineCap::Round,
linejoin: LineJoin::Round,
..Default::default()
});
} else {
path.fill = Some(Fill {
paint: Paint::Color(usvg::Color { red, green, blue }),
..Default::default()
});
}
}
}
tree
}

View file

@ -78,7 +78,7 @@ impl<W: Widget<State>> Controller<State, W> for DirtyStateController {
data: &State,
env: &Env,
) {
if compare_state_fields!(old_data, data, mods, game_dir, data_dir) {
if compare_state_fields!(old_data, data, mods, game_dir, data_dir, nexus_api_key) {
ctx.submit_command(ACTION_START_SAVE_SETTINGS);
}

View file

@ -2,23 +2,24 @@ use std::str::FromStr;
use std::sync::Arc;
use druid::im::Vector;
use druid::lens;
use druid::widget::{
Checkbox, CrossAxisAlignment, Either, Flex, Image, Label, LineBreaking, List,
MainAxisAlignment, Maybe, Scroll, SizedBox, Split, Svg, SvgData, TextBox, ViewSwitcher,
};
use druid::{lens, Data, ImageBuf, LifeCycleCtx};
use druid::{
Color, FileDialogOptions, FileSpec, FontDescriptor, FontFamily, LensExt, SingleUse, Widget,
WidgetExt, WindowDesc, WindowId,
};
use druid::{Data, ImageBuf, LifeCycleCtx};
use lazy_static::lazy_static;
use crate::state::{
ModInfo, State, View, ACTION_ADD_MOD, ACTION_SELECTED_MOD_DOWN, ACTION_SELECTED_MOD_UP,
ACTION_SELECT_MOD, ACTION_SET_WINDOW_HANDLE, ACTION_START_DELETE_SELECTED_MOD,
ACTION_START_DEPLOY, ACTION_START_RESET_DEPLOYMENT,
ModInfo, NexusInfo, NexusInfoLens, State, View, ACTION_ADD_MOD, ACTION_SELECTED_MOD_DOWN,
ACTION_SELECTED_MOD_UP, ACTION_SELECT_MOD, ACTION_SET_WINDOW_HANDLE, ACTION_START_CHECK_UPDATE,
ACTION_START_DELETE_SELECTED_MOD, ACTION_START_DEPLOY, ACTION_START_RESET_DEPLOYMENT,
};
use crate::ui::theme::{self, ColorExt};
use crate::ui::theme::{self, ColorExt, COLOR_YELLOW_LIGHT};
use crate::ui::widget::border::Border;
use crate::ui::widget::button::Button;
use crate::ui::widget::controller::{
@ -49,6 +50,12 @@ fn build_top_bar() -> impl Widget<State> {
state.current_view = View::Settings;
});
let check_update_button = Button::with_label("Check for updates")
.on_click(|ctx, _: &mut State, _| {
ctx.submit_command(ACTION_START_CHECK_UPDATE);
})
.disabled_if(|data, _| data.is_update_in_progress);
let deploy_button = {
let icon = Svg::new(SvgData::from_str(theme::icons::ALERT_CIRCLE).expect("invalid SVG"))
.fix_height(druid::theme::TEXT_SIZE_NORMAL);
@ -85,6 +92,8 @@ fn build_top_bar() -> impl Widget<State> {
)
.with_child(
Flex::row()
.with_child(check_update_button)
.with_default_spacer()
.with_child(deploy_button)
.with_default_spacer()
.with_child(reset_button),
@ -118,10 +127,42 @@ fn build_mod_list() -> impl Widget<State> {
let name =
Label::raw().lens(lens!((usize, Arc<ModInfo>, bool), 1).then(ModInfo::name.in_arc()));
let version = {
let icon = {
let tree =
theme::icons::parse_svg(theme::icons::ALERT_TRIANGLE).expect("invalid SVG");
let tree = theme::icons::recolor_icon(tree, true, COLOR_YELLOW_LIGHT);
Svg::new(Arc::new(tree)).fix_height(druid::theme::TEXT_SIZE_NORMAL)
};
Either::new(
|info, _| {
info.nexus
.as_ref()
.map(|n| info.version != n.version)
.unwrap_or(false)
},
Flex::row()
.with_child(icon)
.with_spacer(3.)
.with_child(Label::raw().lens(ModInfo::version.in_arc())),
Label::raw().lens(ModInfo::version.in_arc()),
)
.lens(lens!((usize, Arc<ModInfo>, bool), 1))
};
let fields = Flex::row()
.must_fill_main_axis(true)
.main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(name)
.with_child(version);
Flex::row()
.must_fill_main_axis(true)
.with_child(checkbox)
.with_child(name)
.with_flex_child(fields, 1.)
.padding((5.0, 4.0))
.background(theme::keys::KEY_MOD_LIST_ITEM_BG_COLOR)
.on_click(|ctx, (i, _, _), _env| ctx.submit_command(ACTION_SELECT_MOD.with(*i)))
@ -253,12 +294,18 @@ fn build_mod_details_info() -> impl Widget<State> {
.lens(ModInfo::name.in_arc());
let summary = Label::raw()
.with_line_break_mode(LineBreaking::WordWrap)
.lens(ModInfo::summary.in_arc());
.lens(NexusInfoLens::new(NexusInfo::summary, ModInfo::summary).in_arc());
// TODO: Image/icon?
let version_line = Label::dynamic(|info: &Arc<ModInfo>, _| {
if let Some(author) = &info.author {
let author = info
.nexus
.as_ref()
.map(|n| &n.author)
.or(info.author.as_ref());
if let Some(author) = &author {
format!("Version: {}, by {author}", info.version)
} else {
format!("Version: {}", info.version)
@ -359,12 +406,22 @@ fn build_view_settings() -> impl Widget<State> {
)
.expand_width();
let nexus_apy_key_setting = Flex::row()
.must_fill_main_axis(true)
.main_axis_alignment(MainAxisAlignment::Start)
.with_child(Label::new("Nexus API Key:"))
.with_default_spacer()
.with_flex_child(TextBox::new().expand_width().lens(State::nexus_api_key), 1.)
.expand_width();
let content = Flex::column()
.must_fill_main_axis(true)
.cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(data_dir_setting)
.with_default_spacer()
.with_child(game_dir_setting);
.with_child(game_dir_setting)
.with_default_spacer()
.with_child(nexus_apy_key_setting);
SizedBox::new(content)
.width(800.)

View file

@ -28,6 +28,7 @@ impl<'a> From<&'a ModInfo> for LoadOrderEntrySerialize<'a> {
pub(crate) struct ConfigSerialize<'a> {
game_dir: &'a Path,
data_dir: &'a Path,
nexus_api_key: &'a String,
mod_order: Vec<LoadOrderEntrySerialize<'a>>,
}
@ -36,6 +37,7 @@ impl<'a> From<&'a ActionState> for ConfigSerialize<'a> {
Self {
game_dir: &state.game_dir,
data_dir: &state.data_dir,
nexus_api_key: &state.nexus_api_key,
mod_order: state
.mods
.iter()
@ -58,6 +60,7 @@ pub(crate) struct Config {
pub path: PathBuf,
pub data_dir: Option<PathBuf>,
pub game_dir: Option<PathBuf>,
pub nexus_api_key: Option<String>,
#[serde(default)]
pub mod_order: Vec<LoadOrderEntry>,
}
@ -140,6 +143,7 @@ where
path: default_path,
data_dir: Some(get_default_data_dir()),
game_dir: None,
nexus_api_key: None,
mod_order: Vec::new(),
};

21
lib/nexusmods/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "nexusmods"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
futures = "0.3.26"
lazy_static = "1.4.0"
regex = "1.7.1"
reqwest = { version = "0.11.14" }
serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.94"
thiserror = "1.0.39"
time = { version = "0.3.20", features = ["serde"] }
tracing = "0.1.37"
url = { version = "2.3.1", features = ["serde"] }
[dev-dependencies]
tokio = { version = "1.26.0", features = ["rt", "macros"] }

291
lib/nexusmods/src/lib.rs Normal file
View file

@ -0,0 +1,291 @@
use std::collections::HashMap;
use std::convert::Infallible;
use lazy_static::lazy_static;
use regex::Regex;
use reqwest::header::{HeaderMap, HeaderValue, InvalidHeaderValue};
use reqwest::{Client, RequestBuilder, Url};
use serde::Deserialize;
use thiserror::Error;
mod types;
use time::OffsetDateTime;
pub use types::*;
// TODO: Add OS information
const USER_AGENT: &str = concat!("DTMM/", env!("CARGO_PKG_VERSION"));
const GAME_ID: &str = "warhammer40kdarktide";
lazy_static! {
static ref BASE_URL: Url = Url::parse("https://api.nexusmods.com/v1/").unwrap();
static ref BASE_URL_GAME: Url =
Url::parse("https://api.nexusmods.com/v1/games/warhammer40kdarktide/").unwrap();
}
#[derive(Error, Debug)]
pub enum Error {
#[error("HTTP error: {0:?}")]
HTTP(#[from] reqwest::Error),
#[error("invalid URL: {0:?}")]
URLParseError(#[from] url::ParseError),
#[error("failed to deserialize '{error}': {json}")]
Deserialize {
json: String,
error: serde_json::Error,
},
#[error(transparent)]
InvalidHeaderValue(#[from] InvalidHeaderValue),
#[error("this error cannot happen")]
Infallible(#[from] Infallible),
#[error("invalid NXM URL '{}': {0}", .1.as_str())]
InvalidNXM(&'static str, Url),
}
pub type Result<T> = std::result::Result<T, Error>;
pub struct Nxm {
pub mod_id: u64,
pub file_id: u64,
pub user_id: u64,
pub key: String,
pub expires: OffsetDateTime,
}
pub struct Api {
client: Client,
}
impl Api {
pub fn new(key: String) -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert("accept", HeaderValue::from_static("application/json"));
headers.insert("apikey", HeaderValue::from_str(&key)?);
let client = Client::builder()
.user_agent(USER_AGENT)
.default_headers(headers)
.build()?;
Ok(Self { client })
}
#[tracing::instrument(skip(self))]
async fn send<T>(&self, req: RequestBuilder) -> Result<T>
where
T: for<'a> Deserialize<'a>,
{
let res = req.send().await?.error_for_status()?;
tracing::trace!(?res);
let json = res.text().await?;
serde_json::from_str(&json).map_err(|error| Error::Deserialize { json, error })
}
#[tracing::instrument(skip(self))]
pub async fn user_validate(&self) -> Result<User> {
let url = BASE_URL.join("users/validate.json")?;
let req = self.client.get(url);
self.send(req).await
}
#[tracing::instrument(skip(self))]
pub async fn mods_updated(&self, period: UpdatePeriod) -> Result<Vec<UpdateInfo>> {
let url = BASE_URL_GAME.join("mods/updated.json")?;
let req = self.client.get(url).query(&[period]);
self.send(req).await
}
#[tracing::instrument(skip(self))]
pub async fn mods_id(&self, id: u64) -> Result<Mod> {
let url = BASE_URL_GAME.join(&format!("mods/{}.json", id))?;
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)> {
lazy_static! {
static ref RE: Regex = Regex::new(r#"^(?P<name>.+?)-(?P<mod_id>[1-9]\d*)-(?P<version>.+?)-(?P<updated>[1-9]\d*)(?:\.\w+)?$"#).unwrap();
}
RE.captures(name.as_ref()).and_then(|cap| {
let name = cap.name("name").map(|s| s.as_str().to_string())?;
let mod_id = cap.name("mod_id").and_then(|s| s.as_str().parse().ok())?;
let version = cap.name("version").map(|s| s.as_str().to_string())?;
let updated = cap
.name("updated")
.and_then(|s| s.as_str().parse().ok())
.and_then(|s| OffsetDateTime::from_unix_timestamp(s).ok())?;
Some((name, mod_id, version, updated))
})
}
#[tracing::instrument(skip(self))]
pub async fn mods_download_link(
&self,
mod_id: u64,
file_id: u64,
key: String,
expires: OffsetDateTime,
) -> Result<Vec<DownloadLink>> {
let url =
BASE_URL_GAME.join(&format!("mods/{mod_id}/files/{file_id}/download_link.json"))?;
let req = self
.client
.get(url)
.query(&[("key", key)])
.query(&[("expires", expires.unix_timestamp())]);
self.send(req).await
}
pub async fn handle_nxm(&self, url: Url) -> Result<(Mod, Vec<u8>)> {
let nxm = Self::parse_nxm(url.clone())?;
let user = self.user_validate().await?;
if nxm.user_id != user.user_id {
return Err(Error::InvalidNXM("user_id mismtach", url));
}
let (mod_data, download_info) = futures::try_join!(
self.mods_id(nxm.mod_id),
self.mods_download_link(nxm.mod_id, nxm.file_id, nxm.key, nxm.expires)
)?;
let Some(download_url) = download_info.get(0).map(|i| i.uri.clone()) else {
return Err(Error::InvalidNXM("no download link", url));
};
let req = self.client.get(download_url);
let data = req.send().await?.bytes().await?;
Ok((mod_data, data.to_vec()))
}
pub fn parse_nxm(nxm: Url) -> Result<Nxm> {
if nxm.scheme() != "nxm" {
return Err(Error::InvalidNXM("Invalid scheme", nxm));
}
// Now it makes sense, why Nexus calls this field `game_domain_name`, when it's just
// another path segmentin 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));
};
if segments.next() != Some("mods") {
return Err(Error::InvalidNXM("Unexpected path segment", nxm));
}
let Some(mod_id) = segments.next().and_then(|id| id.parse().ok()) else {
return Err(Error::InvalidNXM("Invalid mod ID", nxm));
};
if segments.next() != Some("files") {
return Err(Error::InvalidNXM("Unexpected path segment", nxm));
}
let Some(file_id) = segments.next().and_then(|id| id.parse().ok()) else {
return Err(Error::InvalidNXM("Invalid file ID", nxm));
};
let mut query = HashMap::new();
let pairs = nxm.query_pairs();
for (key, val) in pairs {
query.insert(key, val);
}
let Some(key) = query.get("key") else {
return Err(Error::InvalidNXM("Missing 'key'", nxm));
};
let expires = query
.get("expires")
.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));
};
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));
};
Ok(Nxm {
mod_id,
file_id,
key: key.to_string(),
expires,
user_id,
})
}
}
#[cfg(test)]
mod test {
use reqwest::Url;
use time::OffsetDateTime;
use crate::Api;
fn make_api() -> Api {
let key = std::env::var("NEXUSMODS_API_KEY").expect("'NEXUSMODS_API_KEY' env var missing");
Api::new(key).expect("failed to build API client")
}
#[tokio::test]
async fn mods_updated() {
let client = make_api();
client
.mods_updated(Default::default())
.await
.expect("failed to query 'mods_updated'");
}
#[tokio::test]
async fn user_validate() {
let client = make_api();
client
.user_validate()
.await
.expect("failed to query 'user_validate'");
}
#[tokio::test]
async fn mods_id() {
let client = make_api();
let dmf_id = 8;
client
.mods_id(dmf_id)
.await
.expect("failed to query 'mods_id'");
}
#[test]
fn parse_file_name() {
let file = "Darktide Mod Framework-8-23-3-04-1677966575.zip";
let (name, mod_id, version, updated) = Api::parse_file_name(file).unwrap();
assert_eq!(name, String::from("Darktide Mod Framework"));
assert_eq!(mod_id, 8);
assert_eq!(version, String::from("23-3-04"));
assert_eq!(
updated,
OffsetDateTime::from_unix_timestamp(1677966575).unwrap()
);
}
#[test]
fn parse_nxm() {
let nxm = Url::parse("nxm://warhammer40kdarktide/mods/8/files/1000172397?key=VZ86Guj_LosPvtkD90-ZQg&expires=1678359882&user_id=1234567").expect("invalid NXM example");
Api::parse_nxm(nxm).expect("failed to parse nxm link");
}
}

111
lib/nexusmods/src/types.rs Normal file
View file

@ -0,0 +1,111 @@
use reqwest::Url;
use serde::ser::SerializeTuple;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
#[derive(Debug, Deserialize)]
pub struct User {
pub user_id: u64,
pub name: String,
pub profile_url: Url,
// pub is_premium: bool,
// pub is_supporter: bool,
// pub email: String,
}
#[derive(Copy, Clone, Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModStatus {
Published,
}
#[derive(Copy, Clone, Debug, Deserialize)]
pub enum EndorseStatus {
Endorsed,
Undecided,
}
#[derive(Debug, Deserialize)]
pub struct ModEndorsement {
pub endorse_status: EndorseStatus,
#[serde(with = "time::serde::timestamp::option")]
pub timestamp: Option<OffsetDateTime>,
pub version: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Mod {
pub name: String,
pub description: String,
pub summary: String,
pub picture_url: Url,
pub uid: u64,
pub mod_id: u64,
pub category_id: u64,
pub version: String,
#[serde(with = "time::serde::timestamp")]
pub created_timestamp: OffsetDateTime,
// created_time: OffsetDateTime,
#[serde(with = "time::serde::timestamp")]
pub updated_timestamp: OffsetDateTime,
// updated_time: OffsetDateTime,
pub author: String,
pub uploaded_by: String,
pub uploaded_users_profile_url: Url,
pub status: ModStatus,
pub available: bool,
pub endorsement: ModEndorsement,
// pub mod_downloads: u64,
// pub mod_unique_downloads: u64,
// pub game_id: u64,
// pub allow_rating: bool,
// pub domain_name: String,
// pub endorsement_count: u64,
// pub contains_adult_content: bool,
}
#[derive(Debug, Deserialize)]
pub struct DownloadLink {
pub name: String,
pub short_name: String,
#[serde(alias = "URI")]
pub uri: Url,
}
#[derive(Debug, Deserialize)]
pub struct UpdateInfo {
pub mod_id: u64,
#[serde(with = "time::serde::timestamp")]
pub latest_file_update: OffsetDateTime,
#[serde(with = "time::serde::timestamp")]
pub latest_mod_activity: OffsetDateTime,
}
#[derive(Copy, Clone, Debug)]
pub enum UpdatePeriod {
Day,
Week,
Month,
}
impl Default for UpdatePeriod {
fn default() -> Self {
Self::Week
}
}
impl Serialize for UpdatePeriod {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut tup = serializer.serialize_tuple(2)?;
tup.serialize_element("period")?;
tup.serialize_element(match self {
Self::Day => "1d",
Self::Week => "1w",
Self::Month => "1m",
})?;
tup.end()
}
}

@ -1 +1 @@
Subproject commit 6e413b7bf5fde09ca66bfcee1394443264c99ab1
Subproject commit 73d2b23ce50e75b184f5092ad515e97a0adbe6da