From 13d36c4947358d8e613d4b05701bd2dd66cee367 Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Mon, 6 Mar 2023 13:44:07 +0100 Subject: [PATCH 01/15] feat(dtmm): Add Nexus API key setting --- crates/dtmm/src/main.rs | 1 + crates/dtmm/src/state/data.rs | 9 ++++++++- crates/dtmm/src/state/delegate.rs | 2 ++ crates/dtmm/src/ui/widget/controller.rs | 2 +- crates/dtmm/src/ui/window/main.rs | 12 +++++++++++- crates/dtmm/src/util/config.rs | 4 ++++ 6 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/dtmm/src/main.rs b/crates/dtmm/src/main.rs index ce8c67f..de8ca8d 100644 --- a/crates/dtmm/src/main.rs +++ b/crates/dtmm/src/main.rs @@ -91,6 +91,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")?; diff --git a/crates/dtmm/src/state/data.rs b/crates/dtmm/src/state/data.rs index 34ab92d..09e3a97 100644 --- a/crates/dtmm/src/state/data.rs +++ b/crates/dtmm/src/state/data.rs @@ -128,6 +128,7 @@ pub(crate) struct State { pub is_next_save_pending: bool, pub game_dir: Arc, pub data_dir: Arc, + pub nexus_api_key: Arc, pub log: Arc, #[lens(ignore)] @@ -145,7 +146,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 { @@ -161,6 +167,7 @@ impl State { 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(), } diff --git a/crates/dtmm/src/state/delegate.rs b/crates/dtmm/src/state/delegate.rs index 73aa42d..1dbe2a2 100644 --- a/crates/dtmm/src/state/delegate.rs +++ b/crates/dtmm/src/state/delegate.rs @@ -56,6 +56,7 @@ pub(crate) struct ActionState { pub mod_dir: Arc, pub config_path: Arc, pub ctx: Arc, + pub nexus_api_key: Arc, } impl From for ActionState { @@ -67,6 +68,7 @@ impl From for ActionState { data_dir: state.data_dir, config_path: state.config_path, ctx: state.ctx, + nexus_api_key: state.nexus_api_key, } } } diff --git a/crates/dtmm/src/ui/widget/controller.rs b/crates/dtmm/src/ui/widget/controller.rs index a7ff2f1..45170d4 100644 --- a/crates/dtmm/src/ui/widget/controller.rs +++ b/crates/dtmm/src/ui/widget/controller.rs @@ -78,7 +78,7 @@ impl> Controller 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); } diff --git a/crates/dtmm/src/ui/window/main.rs b/crates/dtmm/src/ui/window/main.rs index d4244fe..67ab0e5 100644 --- a/crates/dtmm/src/ui/window/main.rs +++ b/crates/dtmm/src/ui/window/main.rs @@ -359,12 +359,22 @@ fn build_view_settings() -> impl Widget { ) .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.) diff --git a/crates/dtmm/src/util/config.rs b/crates/dtmm/src/util/config.rs index bf2a72b..c2f2045 100644 --- a/crates/dtmm/src/util/config.rs +++ b/crates/dtmm/src/util/config.rs @@ -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>, } @@ -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, pub game_dir: Option, + pub nexus_api_key: Option, #[serde(default)] pub mod_order: Vec, } @@ -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(), }; -- 2.45.3 From 5ca1ca350627105581e562fad5fb5ea2f0492663 Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Tue, 7 Mar 2023 11:40:15 +0100 Subject: [PATCH 02/15] feat(nexusmods): Start API implementation --- lib/nexusmods/Cargo.toml | 19 ++++++ lib/nexusmods/src/lib.rs | 132 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 lib/nexusmods/Cargo.toml create mode 100644 lib/nexusmods/src/lib.rs diff --git a/lib/nexusmods/Cargo.toml b/lib/nexusmods/Cargo.toml new file mode 100644 index 0000000..93dbd78 --- /dev/null +++ b/lib/nexusmods/Cargo.toml @@ -0,0 +1,19 @@ +[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] +lazy_static = "1.4.0" +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 = "2.3.1" + +[dev-dependencies] +tokio = { version = "1.26.0", features = ["rt", "macros"] } diff --git a/lib/nexusmods/src/lib.rs b/lib/nexusmods/src/lib.rs new file mode 100644 index 0000000..c8ea2a6 --- /dev/null +++ b/lib/nexusmods/src/lib.rs @@ -0,0 +1,132 @@ +use std::convert::Infallible; + +use lazy_static::lazy_static; +use reqwest::header::{HeaderMap, HeaderValue, InvalidHeaderValue}; +use reqwest::{Client, Url}; +use serde::ser::SerializeTuple; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use time::OffsetDateTime; + +// 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), +} + +pub type Result = std::result::Result; + +#[derive(Clone, 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(&self, serializer: S) -> std::result::Result + 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() + } +} + +pub struct Api { + client: Client, +} + +impl Api { + pub fn new(key: String) -> Result { + 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))] + pub async fn mods_updated(&self, period: UpdatePeriod) -> Result> { + let url = BASE_URL_GAME.join("mods/updated.json")?; + + let res = self + .client + .get(url) + .query(&[period]) + .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 }) + } +} + +#[cfg(test)] +mod test { + 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'"); + } +} -- 2.45.3 From 2452f9b4aba2b1b8c4edca4d5b401d5fd727c634 Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Tue, 7 Mar 2023 14:44:41 +0100 Subject: [PATCH 03/15] feat(nexusmods): Implement additional endpoints --- lib/nexusmods/Cargo.toml | 2 +- lib/nexusmods/src/lib.rs | 106 ++++++++++++++++++------------------- lib/nexusmods/src/types.rs | 102 +++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 56 deletions(-) create mode 100644 lib/nexusmods/src/types.rs diff --git a/lib/nexusmods/Cargo.toml b/lib/nexusmods/Cargo.toml index 93dbd78..613aa7e 100644 --- a/lib/nexusmods/Cargo.toml +++ b/lib/nexusmods/Cargo.toml @@ -13,7 +13,7 @@ serde_json = "1.0.94" thiserror = "1.0.39" time = { version = "0.3.20", features = ["serde"] } tracing = "0.1.37" -url = "2.3.1" +url = { version = "2.3.1", features = ["serde"] } [dev-dependencies] tokio = { version = "1.26.0", features = ["rt", "macros"] } diff --git a/lib/nexusmods/src/lib.rs b/lib/nexusmods/src/lib.rs index c8ea2a6..3f3fb5e 100644 --- a/lib/nexusmods/src/lib.rs +++ b/lib/nexusmods/src/lib.rs @@ -2,15 +2,15 @@ use std::convert::Infallible; use lazy_static::lazy_static; use reqwest::header::{HeaderMap, HeaderValue, InvalidHeaderValue}; -use reqwest::{Client, Url}; -use serde::ser::SerializeTuple; -use serde::{Deserialize, Serialize}; +use reqwest::{Client, RequestBuilder, Url}; +use serde::Deserialize; use thiserror::Error; -use time::OffsetDateTime; + +mod types; +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(); @@ -37,44 +37,6 @@ pub enum Error { pub type Result = std::result::Result; -#[derive(Clone, 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(&self, serializer: S) -> std::result::Result - 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() - } -} - pub struct Api { client: Client, } @@ -93,22 +55,37 @@ impl Api { Ok(Self { client }) } + #[tracing::instrument(skip(self))] + async fn send(&self, req: RequestBuilder) -> Result + 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 { + 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> { let url = BASE_URL_GAME.join("mods/updated.json")?; + let req = self.client.get(url).query(&[period]); + self.send(req).await + } - let res = self - .client - .get(url) - .query(&[period]) - .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 mods_id(&self, id: u64) -> Result { + let url = BASE_URL_GAME.join(&format!("mods/{}.json", id))?; + let req = self.client.get(url); + self.send(req).await } } @@ -129,4 +106,23 @@ mod test { .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'"); + } } diff --git a/lib/nexusmods/src/types.rs b/lib/nexusmods/src/types.rs new file mode 100644 index 0000000..fade6c1 --- /dev/null +++ b/lib/nexusmods/src/types.rs @@ -0,0 +1,102 @@ +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 { + Undecided, +} + +#[derive(Debug, Deserialize)] +pub struct ModEndorsement { + pub endorse_status: EndorseStatus, + #[serde(with = "time::serde::timestamp::option")] + pub timestamp: Option, + pub version: Option, +} + +#[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(Clone, 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(&self, serializer: S) -> std::result::Result + 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() + } +} -- 2.45.3 From d4d1d52f456176681da2f532c35fe3c36c2cfa59 Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Mon, 13 Mar 2023 13:11:28 +0100 Subject: [PATCH 04/15] feat(nexusmods): Implement parsing download file names When downloading manually from Nexus, the file name encodes information needed to map the file to the mod object. --- lib/nexusmods/Cargo.toml | 1 + lib/nexusmods/src/lib.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/lib/nexusmods/Cargo.toml b/lib/nexusmods/Cargo.toml index 613aa7e..1c94289 100644 --- a/lib/nexusmods/Cargo.toml +++ b/lib/nexusmods/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] 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" diff --git a/lib/nexusmods/src/lib.rs b/lib/nexusmods/src/lib.rs index 3f3fb5e..e761a77 100644 --- a/lib/nexusmods/src/lib.rs +++ b/lib/nexusmods/src/lib.rs @@ -1,6 +1,7 @@ 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; @@ -87,6 +88,26 @@ impl Api { let req = self.client.get(url); self.send(req).await } + + pub fn parse_file_name>(name: S) -> Option<(String, u64, String, u64)> { + lazy_static! { + static ref RE: Regex = Regex::new(r#"^(?P.+?)-(?P[1-9]\d*)-(?P.+?)-(?P[1-9]\d*)(?:\.\w+)?$"#).unwrap(); + } + + RE.captures(name.as_ref()).and_then(|cap| { + let name = cap.name("name")?; + let mod_id = cap.name("mod_id")?; + let version = cap.name("version")?; + let updated = cap.name("updated")?; + + Some(( + name.as_str().to_string(), + mod_id.as_str().parse().ok()?, + version.as_str().to_string(), + updated.as_str().parse().ok()?, + )) + }) + } } #[cfg(test)] @@ -125,4 +146,15 @@ mod test { .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, 1677966575); + } } -- 2.45.3 From 2fb0d8fb721087020f97f15eda3b3a501310940c Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Wed, 8 Mar 2023 09:18:19 +0100 Subject: [PATCH 05/15] feat(nexusmods): Implement NXM URI parsing --- lib/nexusmods/Cargo.toml | 1 + lib/nexusmods/src/lib.rs | 155 ++++++++++++++++++++++++++++++++++--- lib/nexusmods/src/types.rs | 10 ++- 3 files changed, 153 insertions(+), 13 deletions(-) diff --git a/lib/nexusmods/Cargo.toml b/lib/nexusmods/Cargo.toml index 1c94289..d7967ef 100644 --- a/lib/nexusmods/Cargo.toml +++ b/lib/nexusmods/Cargo.toml @@ -6,6 +6,7 @@ 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" } diff --git a/lib/nexusmods/src/lib.rs b/lib/nexusmods/src/lib.rs index e761a77..a0700ae 100644 --- a/lib/nexusmods/src/lib.rs +++ b/lib/nexusmods/src/lib.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::convert::Infallible; use lazy_static::lazy_static; @@ -8,10 +9,12 @@ 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(); @@ -34,10 +37,20 @@ pub enum Error { 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 = std::result::Result; +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, } @@ -89,29 +102,138 @@ impl Api { self.send(req).await } - pub fn parse_file_name>(name: S) -> Option<(String, u64, String, u64)> { + pub fn parse_file_name>( + name: S, + ) -> Option<(String, u64, String, OffsetDateTime)> { lazy_static! { static ref RE: Regex = Regex::new(r#"^(?P.+?)-(?P[1-9]\d*)-(?P.+?)-(?P[1-9]\d*)(?:\.\w+)?$"#).unwrap(); } RE.captures(name.as_ref()).and_then(|cap| { - let name = cap.name("name")?; - let mod_id = cap.name("mod_id")?; - let version = cap.name("version")?; - let updated = cap.name("updated")?; + 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.as_str().to_string(), - mod_id.as_str().parse().ok()?, - version.as_str().to_string(), - updated.as_str().parse().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> { + 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)> { + 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 { + 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 { @@ -155,6 +277,15 @@ mod test { assert_eq!(name, String::from("Darktide Mod Framework")); assert_eq!(mod_id, 8); assert_eq!(version, String::from("23-3-04")); - assert_eq!(updated, 1677966575); + 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"); } } diff --git a/lib/nexusmods/src/types.rs b/lib/nexusmods/src/types.rs index fade6c1..e811d29 100644 --- a/lib/nexusmods/src/types.rs +++ b/lib/nexusmods/src/types.rs @@ -63,7 +63,15 @@ pub struct Mod { // pub contains_adult_content: bool, } -#[derive(Clone, Debug, Deserialize)] +#[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")] -- 2.45.3 From c7203127bbb22e8928c0426a669fb6f3228f1fb8 Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Tue, 14 Mar 2023 10:19:23 +0100 Subject: [PATCH 06/15] feat(dtmm): Implement importing Nexus downloads For now, this merely parses and retains the API information encoded in the archive's file name. --- Cargo.lock | 1 + crates/dtmm/Cargo.toml | 1 + crates/dtmm/src/controller/app.rs | 39 ++++++++++++++++++++++++++++--- crates/dtmm/src/state/data.rs | 13 +++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a029489..25c5e4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -756,6 +756,7 @@ dependencies = [ "dtmt-shared", "futures", "lazy_static", + "nexusmods", "oodle-sys", "path-slash", "sdk", diff --git a/crates/dtmm/Cargo.toml b/crates/dtmm/Cargo.toml index 2265229..11b71f7 100644 --- a/crates/dtmm/Cargo.toml +++ b/crates/dtmm/Cargo.toml @@ -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"] } diff --git a/crates/dtmm/src/controller/app.rs b/crates/dtmm/src/controller/app.rs index 9aa5a6d..64c4184 100644 --- a/crates/dtmm/src/controller/app.rs +++ b/crates/dtmm/src/controller/app.rs @@ -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,17 @@ pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result Result Result<()> { async fn read_mod_dir_entry(res: Result) -> Result { 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 = 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::() => match err.downcast_ref::() { + Some(err) if err.kind() == std::io::ErrorKind::NotFound => None, + _ => return Err(err), + }, + Err(err) => return Err(err), + }; + let files: HashMap> = read_sjson_file(&index_path) .await .wrap_err_with(|| format!("Failed to read file index '{}'", index_path.display()))?; @@ -222,7 +255,7 @@ async fn read_mod_dir_entry(res: Result) -> Result { .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) } diff --git a/crates/dtmm/src/state/data.rs b/crates/dtmm/src/state/data.rs index 09e3a97..848c6bb 100644 --- a/crates/dtmm/src/state/data.rs +++ b/crates/dtmm/src/state/data.rs @@ -5,6 +5,7 @@ use druid::{ Data, ImageBuf, Lens, WindowHandle, WindowId, }; use dtmt_shared::ModConfig; +use time::OffsetDateTime; use super::SelectedModLens; @@ -69,6 +70,15 @@ impl From for ModDependency { } } +#[derive(Clone, Data, Debug, Lens, serde::Serialize, serde::Deserialize)] +pub(crate) struct NexusInfo { + pub id: u64, + pub version: Option, + #[data(ignore)] + #[serde(with = "time::serde::timestamp")] + pub updated: OffsetDateTime, +} + #[derive(Clone, Data, Debug, Lens)] pub(crate) struct ModInfo { pub id: String, @@ -87,6 +97,7 @@ pub(crate) struct ModInfo { #[data(ignore)] pub resources: ModResourceInfo, pub depends: Vector, + pub nexus: Option, } impl ModInfo { @@ -94,6 +105,7 @@ impl ModInfo { cfg: ModConfig, packages: Vector>, image: Option, + nexus: Option, ) -> Self { Self { id: cfg.id, @@ -112,6 +124,7 @@ impl ModInfo { localization: cfg.resources.localization, }, depends: cfg.depends.into_iter().map(ModDependency::from).collect(), + nexus, } } } -- 2.45.3 From 8edb8b357eaeb66f6a59fc95c2c7803028f50788 Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Tue, 14 Mar 2023 13:59:19 +0100 Subject: [PATCH 07/15] feat(dtmm): Implement rudimentary update check The UI for it is rather ugly, still, but it works. --- Cargo.lock | 427 ++++++++++++++++++++++++++- crates/dtmm/src/controller/app.rs | 54 +++- crates/dtmm/src/controller/worker.rs | 24 ++ crates/dtmm/src/main.rs | 1 + crates/dtmm/src/state/data.rs | 5 +- crates/dtmm/src/state/delegate.rs | 50 ++++ crates/dtmm/src/ui/window/main.rs | 34 ++- 7 files changed, 589 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 25c5e4e..0ab730a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -832,6 +838,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" @@ -1022,6 +1037,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" @@ -1374,6 +1398,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" @@ -1410,6 +1453,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" @@ -1517,6 +1641,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" @@ -1723,6 +1853,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" @@ -1756,6 +1892,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" @@ -1987,6 +2158,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" @@ -2093,6 +2309,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" @@ -2398,6 +2620,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" @@ -2533,6 +2792,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" @@ -2566,6 +2834,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" @@ -2598,6 +2889,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" @@ -2607,6 +2909,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" @@ -2687,6 +3001,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" @@ -2926,6 +3250,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" @@ -2940,6 +3279,7 @@ dependencies = [ "num_cpus", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "tracing", "windows-sys 0.45.0", @@ -2956,6 +3296,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" @@ -2967,6 +3317,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" @@ -2993,6 +3357,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" @@ -3076,6 +3446,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" @@ -3211,6 +3587,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" @@ -3235,13 +3620,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", @@ -3279,6 +3676,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" @@ -3323,6 +3726,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" @@ -3354,6 +3767,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" diff --git a/crates/dtmm/src/controller/app.rs b/crates/dtmm/src/controller/app.rs index 64c4184..9e46305 100644 --- a/crates/dtmm/src/controller/app.rs +++ b/crates/dtmm/src/controller/app.rs @@ -34,7 +34,7 @@ pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result Result<()> { Ok(()) } + +#[tracing::instrument(skip(info, api), fields(id = info.id, name = info.name, version = info.version))] +async fn check_mod_update(info: Arc, api: Arc) -> Result> { + 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 updated_nexus = NexusInfo { + id: nexus.id, + version: updated_info.version, + updated: updated_info.updated_timestamp, + }; + + let mut info = Arc::unwrap_or_clone(info); + info.nexus = Some(updated_nexus); + + Ok(Some(info)) +} + +#[tracing::instrument(skip(state))] +pub(crate) async fn check_updates(state: ActionState) -> Result> { + 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) +} diff --git a/crates/dtmm/src/controller/worker.rs b/crates/dtmm/src/controller/worker.rs index 48e9c1e..fafeebe 100644 --- a/crates/dtmm/src/controller/worker.rs +++ b/crates/dtmm/src/controller/worker.rs @@ -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"); + }), }; } } diff --git a/crates/dtmm/src/main.rs b/crates/dtmm/src/main.rs index de8ca8d..ea7bacb 100644 --- a/crates/dtmm/src/main.rs +++ b/crates/dtmm/src/main.rs @@ -1,5 +1,6 @@ #![recursion_limit = "256"] #![feature(let_chains)] +#![feature(arc_unwrap_or_clone)] #![windows_subsystem = "windows"] use std::path::PathBuf; diff --git a/crates/dtmm/src/state/data.rs b/crates/dtmm/src/state/data.rs index 848c6bb..a250660 100644 --- a/crates/dtmm/src/state/data.rs +++ b/crates/dtmm/src/state/data.rs @@ -73,7 +73,7 @@ impl From for ModDependency { #[derive(Clone, Data, Debug, Lens, serde::Serialize, serde::Deserialize)] pub(crate) struct NexusInfo { pub id: u64, - pub version: Option, + pub version: String, #[data(ignore)] #[serde(with = "time::serde::timestamp")] pub updated: OffsetDateTime, @@ -97,6 +97,7 @@ pub(crate) struct ModInfo { #[data(ignore)] pub resources: ModResourceInfo, pub depends: Vector, + #[data(ignore)] pub nexus: Option, } @@ -139,6 +140,7 @@ 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, pub data_dir: Arc, pub nexus_api_key: Arc, @@ -177,6 +179,7 @@ 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), diff --git a/crates/dtmm/src/state/delegate.rs b/crates/dtmm/src/state/delegate.rs index 1dbe2a2..4a0fc17 100644 --- a/crates/dtmm/src/state/delegate.rs +++ b/crates/dtmm/src/state/delegate.rs @@ -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>> = + 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> = @@ -79,6 +84,7 @@ pub(crate) enum AsyncAction { AddMod(ActionState, FileInfo), DeleteMod(ActionState, Arc), SaveSettings(ActionState), + CheckUpdates(ActionState), } pub(crate) struct Delegate { @@ -304,6 +310,50 @@ impl AppDelegate 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); diff --git a/crates/dtmm/src/ui/window/main.rs b/crates/dtmm/src/ui/window/main.rs index 67ab0e5..16be5f0 100644 --- a/crates/dtmm/src/ui/window/main.rs +++ b/crates/dtmm/src/ui/window/main.rs @@ -15,8 +15,8 @@ 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, + 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::widget::border::Border; @@ -49,6 +49,12 @@ fn build_top_bar() -> impl Widget { 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 +91,8 @@ fn build_top_bar() -> impl Widget { ) .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 +126,30 @@ fn build_mod_list() -> impl Widget { let name = Label::raw().lens(lens!((usize, Arc, bool), 1).then(ModInfo::name.in_arc())); + let version = Label::dynamic(|info: &Arc, _| { + let has_update = info + .nexus + .as_ref() + .map(|n| info.version != n.version) + .unwrap_or(false); + if has_update { + format!("! {}", info.version) + } else { + info.version.to_string() + } + }) + .lens(lens!((usize, Arc, 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))) -- 2.45.3 From ac44883199441de9a66ed17b8a6368b3c8676c6b Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Tue, 14 Mar 2023 20:54:06 +0100 Subject: [PATCH 08/15] refactor(dtmm): Remove unused field --- crates/dtmm/src/controller/app.rs | 7 +------ crates/dtmm/src/state/data.rs | 13 ++++--------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/crates/dtmm/src/controller/app.rs b/crates/dtmm/src/controller/app.rs index 9e46305..88dee2e 100644 --- a/crates/dtmm/src/controller/app.rs +++ b/crates/dtmm/src/controller/app.rs @@ -32,11 +32,7 @@ pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result, api: Arc) -> Result for ModDependency { pub(crate) struct NexusInfo { pub id: u64, pub version: String, - #[data(ignore)] - #[serde(with = "time::serde::timestamp")] - pub updated: OffsetDateTime, } #[derive(Clone, Data, Debug, Lens)] -- 2.45.3 From d43f9c46b5f6df1c145ba6c7f3b1a9da2d43a8b5 Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Tue, 14 Mar 2023 20:56:26 +0100 Subject: [PATCH 09/15] chore: Add changelog entry --- CHANGELOG.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index ecfed2b..2a19595 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -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 -- 2.45.3 From 1c470b51f84123d4ada2f7bee1f051f76506c22f Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Tue, 14 Mar 2023 21:11:37 +0100 Subject: [PATCH 10/15] fix(dtmm): Fix fetching endorsed mods --- lib/nexusmods/src/types.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/nexusmods/src/types.rs b/lib/nexusmods/src/types.rs index e811d29..b0dffd5 100644 --- a/lib/nexusmods/src/types.rs +++ b/lib/nexusmods/src/types.rs @@ -21,6 +21,7 @@ pub enum ModStatus { #[derive(Copy, Clone, Debug, Deserialize)] pub enum EndorseStatus { + Endorsed, Undecided, } -- 2.45.3 From e434535d9679db8a09ed31282a3b5137a252442c Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Tue, 14 Mar 2023 21:45:04 +0100 Subject: [PATCH 11/15] WIP more nexus mod details --- crates/dtmm/src/controller/app.rs | 24 ++++++++++----- crates/dtmm/src/state/data.rs | 16 ++++++++++ crates/dtmm/src/state/lens.rs | 50 ++++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/crates/dtmm/src/controller/app.rs b/crates/dtmm/src/controller/app.rs index 88dee2e..b4b69ca 100644 --- a/crates/dtmm/src/controller/app.rs +++ b/crates/dtmm/src/controller/app.rs @@ -27,12 +27,25 @@ pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result, api: Arc) -> Result for ModDependency { pub(crate) struct NexusInfo { pub id: u64, pub version: String, + pub author: String, + pub summary: String, + pub description: Arc, +} + +impl From for NexusInfo { + fn from(value: NexusMod) -> Self { + Self { + id: value.mod_id, + version: value.version, + author: value.author, + summary: value.summary, + description: Arc::new(value.description), + } + } } #[derive(Clone, Data, Debug, Lens)] diff --git a/crates/dtmm/src/state/lens.rs b/crates/dtmm/src/state/lens.rs index 1156f52..c26c298 100644 --- a/crates/dtmm/src/state/lens.rs +++ b/crates/dtmm/src/state/lens.rs @@ -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 Lens, 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 +where + L: Lens, + R: Lens, +{ + value: L, + fallback: R, + _marker: std::marker::PhantomData, +} + +impl NexusInfoLens +where + L: Lens, + R: Lens, +{ + pub fn new(value: L, fallback: R) -> Self { + Self { + value, + fallback, + _marker: std::marker::PhantomData, + } + } +} + +impl Lens for NexusInfoLens +where + L: Lens, + R: Lens, +{ + fn with 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>(&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) + } + } +} -- 2.45.3 From 6a52f3efc264aa28fe59e75489bf7b6c7cad392c Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Wed, 15 Mar 2023 19:37:47 +0100 Subject: [PATCH 12/15] feat(dtmm): Improve update icon --- Cargo.lock | 1 + crates/dtmm/Cargo.toml | 1 + crates/dtmm/src/ui/theme/icons.rs | 40 ++++++++++++++++++++++++++++ crates/dtmm/src/ui/window/main.rs | 43 ++++++++++++++++++++----------- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ab730a..03f77fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -775,6 +775,7 @@ dependencies = [ "tracing", "tracing-error", "tracing-subscriber", + "usvg", "zip", ] diff --git a/crates/dtmm/Cargo.toml b/crates/dtmm/Cargo.toml index 11b71f7..c98a1e9 100644 --- a/crates/dtmm/Cargo.toml +++ b/crates/dtmm/Cargo.toml @@ -29,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" diff --git a/crates/dtmm/src/ui/theme/icons.rs b/crates/dtmm/src/ui/theme/icons.rs index a947871..69156ad 100644 --- a/crates/dtmm/src/ui/theme/icons.rs +++ b/crates/dtmm/src/ui/theme/icons.rs @@ -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 { + 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 +} diff --git a/crates/dtmm/src/ui/window/main.rs b/crates/dtmm/src/ui/window/main.rs index 16be5f0..b251767 100644 --- a/crates/dtmm/src/ui/window/main.rs +++ b/crates/dtmm/src/ui/window/main.rs @@ -2,15 +2,16 @@ 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::{ @@ -18,7 +19,7 @@ use crate::state::{ 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::{ @@ -126,19 +127,31 @@ fn build_mod_list() -> impl Widget { let name = Label::raw().lens(lens!((usize, Arc, bool), 1).then(ModInfo::name.in_arc())); - let version = Label::dynamic(|info: &Arc, _| { - let has_update = info - .nexus - .as_ref() - .map(|n| info.version != n.version) - .unwrap_or(false); - if has_update { - format!("! {}", info.version) - } else { - info.version.to_string() - } - }) - .lens(lens!((usize, Arc, bool), 1)); + 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, bool), 1)) + }; let fields = Flex::row() .must_fill_main_axis(true) -- 2.45.3 From 146714d88267b638cf5bb50c16859885dd504447 Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Wed, 15 Mar 2023 19:46:53 +0100 Subject: [PATCH 13/15] feat(dtmm): Use Nexus mod details when available --- crates/dtmm/src/state/data.rs | 4 ++-- crates/dtmm/src/ui/window/main.rs | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/dtmm/src/state/data.rs b/crates/dtmm/src/state/data.rs index 83c979f..37d66e7 100644 --- a/crates/dtmm/src/state/data.rs +++ b/crates/dtmm/src/state/data.rs @@ -74,7 +74,7 @@ pub(crate) struct NexusInfo { pub id: u64, pub version: String, pub author: String, - pub summary: String, + pub summary: Arc, pub description: Arc, } @@ -84,7 +84,7 @@ impl From for NexusInfo { id: value.mod_id, version: value.version, author: value.author, - summary: value.summary, + summary: Arc::new(value.summary), description: Arc::new(value.description), } } diff --git a/crates/dtmm/src/ui/window/main.rs b/crates/dtmm/src/ui/window/main.rs index b251767..c8d5bf9 100644 --- a/crates/dtmm/src/ui/window/main.rs +++ b/crates/dtmm/src/ui/window/main.rs @@ -15,8 +15,8 @@ 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_CHECK_UPDATE, + 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, COLOR_YELLOW_LIGHT}; @@ -294,12 +294,18 @@ fn build_mod_details_info() -> impl Widget { .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, _| { - 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) -- 2.45.3 From b64aea684c75f726ece980d4fc8eb229b0893b1f Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Wed, 15 Mar 2023 21:31:38 +0100 Subject: [PATCH 14/15] chore: Update serde_sjson --- lib/serde_sjson | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/serde_sjson b/lib/serde_sjson index 6e413b7..73d2b23 160000 --- a/lib/serde_sjson +++ b/lib/serde_sjson @@ -1 +1 @@ -Subproject commit 6e413b7bf5fde09ca66bfcee1394443264c99ab1 +Subproject commit 73d2b23ce50e75b184f5092ad515e97a0adbe6da -- 2.45.3 From 81a9e068a67f47a6f47f9874b62b8fa445172eab Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Wed, 15 Mar 2023 21:42:42 +0100 Subject: [PATCH 15/15] feat(dtmm): Select accent color As voted, though with rather poor participation. --- crates/dtmm/src/ui/theme/colors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/dtmm/src/ui/theme/colors.rs b/crates/dtmm/src/ui/theme/colors.rs index cc86781..078dab0 100644 --- a/crates/dtmm/src/ui/theme/colors.rs +++ b/crates/dtmm/src/ui/theme/colors.rs @@ -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); } -- 2.45.3