From 4c5066dd078a707e4fddad7c5b43a94913d9637a Mon Sep 17 00:00:00 2001 From: Schuyler Cebulskie Date: Thu, 28 Dec 2023 00:55:55 -0500 Subject: [PATCH 01/20] Add ModManager, ResoluteDatabase, initialization error window, and installed mod tracking --- Cargo.lock | 254 +++++++++++++----- crates/resolute/Cargo.toml | 6 + crates/resolute/src/db.rs | 55 ++++ crates/resolute/src/error.rs | 26 +- crates/resolute/src/lib.rs | 4 +- crates/resolute/src/{ => manager}/download.rs | 136 ++++------ crates/resolute/src/manager/mod.rs | 78 ++++++ crates/resolute/src/manager/paths.rs | 66 +++++ crates/resolute/src/mods.rs | 21 ++ crates/tauri-app/Cargo.toml | 4 +- crates/tauri-app/src/main.rs | 172 +++++++++--- crates/tauri-app/tauri.conf.json | 4 - package-lock.json | 6 + package.json | 1 + ui/error.html | 13 + ui/src/ErrorApp.vue | 86 ++++++ ui/src/composables/settings.js | 2 + ui/src/error.js | 21 ++ ui/src/stores/mods.js | 2 +- ui/src/styles/global.css | 20 +- 20 files changed, 763 insertions(+), 214 deletions(-) create mode 100644 crates/resolute/src/db.rs rename crates/resolute/src/{ => manager}/download.rs (67%) create mode 100644 crates/resolute/src/manager/mod.rs create mode 100644 crates/resolute/src/manager/paths.rs create mode 100644 ui/error.html create mode 100644 ui/src/ErrorApp.vue create mode 100644 ui/src/error.js diff --git a/Cargo.lock b/Cargo.lock index f9685ff..7802769 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -443,6 +443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "542f33a8835a0884b006a0c3df3dadd99c0c3f296ed26c2fdc8028e01ad6230c" dependencies = [ "memchr", + "regex-automata 0.4.3", "serde", ] @@ -485,6 +486,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytecount" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e5f035d16fc623ae5f74981db80a439803888314e3a555fd6f04acd51a3205" + [[package]] name = "bytemuck" version = "1.14.0" @@ -530,6 +537,37 @@ dependencies = [ "system-deps 6.2.0", ] +[[package]] +name = "camino" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59e92b5a388f549b863a7bea62612c09f24c8393560709a54558a9abdfb3b9c" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34637b3140142bdf929fb439e8aa4ebad7651ebf7b1080b3930aa16ac1459ff" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + [[package]] name = "cargo_toml" version = "0.15.3" @@ -1056,6 +1094,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -2232,6 +2279,57 @@ dependencies = [ "tempfile", ] +[[package]] +name = "native_db" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b642a1196e12074d28c6168f68a7f55cb9927349a18c7a5849776d08471a59" +dependencies = [ + "native_db_macro", + "native_model", + "redb", + "serde", + "skeptic", + "thiserror", +] + +[[package]] +name = "native_db_macro" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51c388abb10e7698ddbb45491dc7c17a90bede5fc75c111c0637e7444578c38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.43", +] + +[[package]] +name = "native_model" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cbed78bd94d18bd176272101d13b23b9ad4a9373f77dad05ec0f9782b2b0dc" +dependencies = [ + "anyhow", + "bincode", + "native_model_macro", + "serde", + "skeptic", + "thiserror", + "zerocopy", +] + +[[package]] +name = "native_model_macro" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14143a3784bde9bb219e6082876b7d2443b5a1ce51e7f61e4ff61c2d1aaa27d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.43", +] + [[package]] name = "ndk" version = "0.6.0" @@ -2284,6 +2382,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "normpath" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec60c60a693226186f5d6edf073232bfb6464ed97eb22cf3b01c1e8198fd97f5" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -2419,13 +2526,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] -name = "open" -version = "3.2.0" +name = "opener" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2078c0039e6a54a0c42c28faa984e115fb4c2d5bf2208f77d1961002df8576f8" +checksum = "6c62dcb6174f9cb326eac248f07e955d5d559c272730b6c03e396b443b562788" dependencies = [ - "pathdiff", - "windows-sys 0.42.0", + "bstr", + "normpath", + "winapi", ] [[package]] @@ -2554,12 +2662,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" -[[package]] -name = "pathdiff" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" - [[package]] name = "percent-encoding" version = "2.3.1" @@ -2927,6 +3029,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a1a2f1f0a7ecff9c31abbe177637be0e97a0aef46cf8738ece09327985d998" +dependencies = [ + "bitflags 1.3.2", + "memchr", + "unicase", +] + [[package]] name = "quick-xml" version = "0.31.0" @@ -3038,6 +3151,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" +[[package]] +name = "redb" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08837f9a129bde83c51953b8c96cbb3422b940166b730caa954836106eb1dfd2" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -3163,6 +3285,8 @@ version = "0.4.0" dependencies = [ "futures-util", "log", + "native_db", + "native_model", "path-clean", "reqwest", "serde", @@ -3180,6 +3304,9 @@ version = "0.5.0" dependencies = [ "anyhow", "log", + "native_db", + "once_cell", + "opener", "reqwest", "resolute", "serde", @@ -3648,6 +3775,21 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + [[package]] name = "slab" version = "0.4.9" @@ -3970,11 +4112,9 @@ dependencies = [ "minisign-verify", "objc", "once_cell", - "open", "percent-encoding", "rand 0.8.5", "raw-window-handle", - "regex", "reqwest", "rfd", "semver", @@ -4033,7 +4173,6 @@ dependencies = [ "png", "proc-macro2", "quote", - "regex", "semver", "serde", "serde_json", @@ -4549,6 +4688,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.14" @@ -4990,21 +5138,6 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ee5e275231f07c6e240d14f34e1b635bf1faa1c76c57cfd59a5cdb9848e4278" -[[package]] -name = "windows-sys" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -5068,12 +5201,6 @@ dependencies = [ "windows-targets 0.52.0", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5098,12 +5225,6 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7711666096bd4096ffa835238905bb33fb87267910e154b18b44eaabb340f2" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5128,12 +5249,6 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "763fc57100a5f7042e3057e7e8d9bdd7860d330070251a73d003563a3bb49e1b" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5158,12 +5273,6 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bc7cbfe58828921e10a9f446fcaaf649204dcfe6c1ddd712c5eebae6bda1106" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5188,12 +5297,6 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6868c165637d653ae1e8dc4d82c25d4f97dd6605eaa8d784b5c6e0ab2a252b65" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5206,12 +5309,6 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5236,12 +5333,6 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e4d40883ae9cae962787ca76ba76390ffa29214667a111db9e0a1ad8377e809" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -5438,6 +5529,27 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.43", +] + [[package]] name = "zip" version = "0.6.6" diff --git a/crates/resolute/Cargo.toml b/crates/resolute/Cargo.toml index 638e031..d7b5a06 100644 --- a/crates/resolute/Cargo.toml +++ b/crates/resolute/Cargo.toml @@ -19,3 +19,9 @@ futures-util = "0.3" path-clean = "1.0" sha2 = "0.10" steamlocate = "2.0.0-beta.2" +native_db = { version = "0.5", optional = true } +native_model = { version = "0.4", optional = true } + +[features] +default = ["db"] +db = ["native_db", "native_model"] diff --git a/crates/resolute/src/db.rs b/crates/resolute/src/db.rs new file mode 100644 index 0000000..04b4cd9 --- /dev/null +++ b/crates/resolute/src/db.rs @@ -0,0 +1,55 @@ +use std::path::Path; + +use log::info; +use native_db::{Database, DatabaseBuilder}; + +use crate::{mods::ResoluteMod, Error, Result}; + +/// Wrapper for interacting with a Resolute database +pub struct ResoluteDatabase<'a> { + db: Database<'a>, +} + +impl<'a> ResoluteDatabase<'a> { + /// Opens a database using a provided native_db builder + pub fn open(builder: &'a mut DatabaseBuilder, db_path: impl AsRef) -> Result { + builder.define::()?; + let db = builder.create(&db_path)?; + info!("Database initialized at {}", db_path.as_ref().display()); + Ok(Self { db }) + } + + /// Retrieves all mods stored in the database + pub fn get_mods(&self) -> Result> { + let read = self.db.r_transaction()?; + let mods = read.scan().primary()?.all().collect(); + Ok(mods) + } + + /// Retrieves a single mod from the database by its ID + pub fn get_mod(&self, id: String) -> Result> { + let read = self.db.r_transaction()?; + let rmod = read.get().primary(id)?; + Ok(rmod) + } + + /// Stores a mod in the database (overwrites any existing entry for the same mod) + pub fn store_mod(&self, rmod: ResoluteMod) -> Result<()> { + let rw = self.db.rw_transaction()?; + rw.insert(rmod)?; + rw.commit()?; + Ok(()) + } + + /// Removes a mod from the database by its ID + pub fn remove_mod(&self, id: String) -> Result<()> { + let read = self.db.r_transaction()?; + let rmod: ResoluteMod = read.get().primary(id.clone())?.ok_or_else(|| Error::ItemNotFound(id))?; + + let rw = self.db.rw_transaction()?; + rw.remove(rmod)?; + rw.commit()?; + + Ok(()) + } +} diff --git a/crates/resolute/src/error.rs b/crates/resolute/src/error.rs index cef20e7..2cbe67a 100644 --- a/crates/resolute/src/error.rs +++ b/crates/resolute/src/error.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use reqwest::StatusCode; /// Error returned from a Downloader @@ -18,12 +20,30 @@ pub enum Error { #[error("json error: {0}")] Json(#[from] serde_json::Error), - #[error("checksum error for {2}: calculated hash {1} doesn't match expected hash {0}")] - Checksum(String, String, String), + #[error("checksum error for {file}: calculated hash {checksum} doesn't match expected hash {expected}")] + Checksum { + checksum: String, + expected: String, + file: String, + }, + + #[error("unknown version \"{1}\" for mod \"{0}\"")] + UnknownVersion(String, String), + + #[error("unable to delete artifacts")] + ArtifactDeletion(Vec<(PathBuf, std::io::Error)>), #[error("resonite discovery error: {0}")] Discovery(#[from] steamlocate::Error), + + #[cfg(feature = "db")] + #[error("database error: {0}")] + Database(#[from] native_db::db_type::Error), + + #[cfg(feature = "db")] + #[error("item not found in database: {0}")] + ItemNotFound(String), } -/// Alias for a `Result` with the error type `download::Error`. +/// Alias for a `Result` with the error type `resolute::Error`. pub type Result = core::result::Result; diff --git a/crates/resolute/src/lib.rs b/crates/resolute/src/lib.rs index 65a6aa7..ca7bc4a 100644 --- a/crates/resolute/src/lib.rs +++ b/crates/resolute/src/lib.rs @@ -1,6 +1,8 @@ +#[cfg(feature = "db")] +pub mod db; pub mod discover; -pub mod download; mod error; +pub mod manager; pub mod manifest; pub mod mods; diff --git a/crates/resolute/src/download.rs b/crates/resolute/src/manager/download.rs similarity index 67% rename from crates/resolute/src/download.rs rename to crates/resolute/src/manager/download.rs index 8217b97..3b7b0f3 100644 --- a/crates/resolute/src/download.rs +++ b/crates/resolute/src/manager/download.rs @@ -1,11 +1,7 @@ -use std::{ - ffi::OsString, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; use futures_util::TryStreamExt; use log::{debug, error, info}; -use path_clean::PathClean; use reqwest::{Client, IntoUrl}; use sha2::{Digest, Sha256}; use tokio::{ @@ -16,23 +12,24 @@ use tokio::{ use crate::mods::{ModArtifact, ModVersion}; use crate::{Error, Result}; +use super::ArtifactPaths; + /// Handles mod downloads pub struct Downloader { - client: Client, + pub base_dest: PathBuf, + http_client: Client, } impl Downloader { - pub fn new(client: Client) -> Self { - Self { client } + pub fn new(base_dest: impl AsRef, http_client: Client) -> Self { + Self { + http_client, + base_dest: base_dest.as_ref().to_owned(), + } } /// Downloads all relevant artifacts for a specific mod version to their proper destinations in the given base path - pub async fn download_version

( - &self, - version: &ModVersion, - base_dest: impl AsRef, - progress: P, - ) -> Result<()> + pub async fn download_version

(&self, version: &ModVersion, progress: P) -> Result<()> where P: Fn(u64, u64), { @@ -41,7 +38,7 @@ impl Downloader { // Download all of the artifacts and track any successful ones - on an error, abort any further ones let mut downloaded = Vec::new(); for artifact in version.artifacts.iter() { - match self.download_artifact(artifact, &base_dest, &progress).await { + match self.download_artifact(artifact, &progress).await { Ok(paths) => downloaded.push(paths), Err(err) => { install_error = Some(err); @@ -94,21 +91,19 @@ impl Downloader { } /// Downloads a specific artifact to a temporary destination (filename.dll.new) within a given base path - pub async fn download_artifact

( - &self, - artifact: &ModArtifact, - base_dest: impl AsRef, - progress: P, - ) -> Result + pub async fn download_artifact

(&self, artifact: &ModArtifact, progress: P) -> Result where P: Fn(u64, u64), { - let paths = ArtifactPaths::try_new(artifact, base_dest)?; + let paths = ArtifactPaths::try_new(artifact, &self.base_dest)?; // Create any missing directories up to the destination - let result = fs::create_dir_all(paths.final_dest.parent().ok_or(Error::Path( - "unable to get parent of artifact's final destination".to_owned(), - ))?) + let result = fs::create_dir_all( + paths + .final_dest + .parent() + .ok_or_else(|| Error::Path("unable to get parent of artifact's final destination".to_owned()))?, + ) .await; // If the directory creation failed, ignore the error it if it's just because it already exists @@ -208,7 +203,7 @@ impl Downloader { let dest = dest.as_ref(); // Make the request - let request = self.client.get(url.clone()); + let request = self.http_client.get(url.clone()); let response = request.send().await?; // Ensure the request yielded a successful response @@ -236,7 +231,11 @@ impl Downloader { let actual = format!("{:x}", digest); if actual != checksum.to_lowercase() { let _ = fs::remove_file(dest).await; - return Err(Error::Checksum(checksum.to_owned(), actual, url.into())); + return Err(Error::Checksum { + expected: checksum.to_owned(), + checksum: actual, + file: url.into(), + }); } debug!("Downloaded artifact to {}", dest.display()); @@ -244,69 +243,32 @@ impl Downloader { } } -impl Default for Downloader { - fn default() -> Self { - Self::new(reqwest::Client::new()) - } +#[derive(Default)] +pub struct DownloaderBuilder { + base_dest: PathBuf, + http_client: Client, } -/// Contains full paths that an artifact may live in at various stages of installation -#[derive(Clone, Debug)] -pub struct ArtifactPaths { - /// Full path for an artifact file that has been installed - final_dest: PathBuf, - /// Full path for an artifact file that has just been downloaded - tmp_dest: PathBuf, - /// Full path for an artifact file that already existed and has been renamed - old_dest: PathBuf, -} +impl DownloaderBuilder { + /// Creates a new builder with defaults set + pub fn new() -> Self { + Self::default() + } -impl ArtifactPaths { - /// Builds a set of artifact destination paths for a given artifact and base destination path. - /// Fails if there's any issue building the paths or if the artifact's destination ends up outside of the base path. - pub fn try_new(artifact: &ModArtifact, base_dest: impl AsRef) -> Result { - let base_dest = base_dest.as_ref(); - - // Add the artifact's install location to the path - let mut dest = base_dest.join(match &artifact.install_location { - Some(install_location) => { - let path = Path::new(install_location); - path.strip_prefix("/").or::(Ok(path))? - } - None => Path::new("rml_mods"), - }); - - // Add the artifact's filename to the path - let filename = match &artifact.filename { - Some(filename) => OsString::from(filename), - None => Path::new(artifact.url.path()) - .file_name() - .ok_or(Error::Path(format!( - "unable to extract file name from url: {}", - artifact.url - )))? - .to_owned(), - }; - dest.push(&filename); - - // Ensure the final path is inside the base path - let final_dest = dest.clean(); - if !final_dest.starts_with(base_dest) { - return Err(Error::Path( - "artifact's final destination is not a subdirectory of the base destination".to_owned(), - )); - } + /// Sets the base destination of mod artifacts + pub fn base(mut self, base_dest: impl AsRef) -> Self { + self.base_dest = base_dest.as_ref().to_owned(); + self + } + + /// Sets the HTTP client to use + pub fn http_client(mut self, http_client: reqwest::Client) -> Self { + self.http_client = http_client; + self + } - // Build the temporary and old filenames - let mut tmp_filename = filename.clone(); - tmp_filename.push(".new"); - let mut old_filename = filename; - old_filename.push(".old"); - - Ok(Self { - tmp_dest: final_dest.with_file_name(tmp_filename), - old_dest: final_dest.with_file_name(old_filename), - final_dest, - }) + /// Creates a Client using this builder's configuration and HTTP client + pub fn build(self) -> Downloader { + Downloader::new(self.base_dest, self.http_client) } } diff --git a/crates/resolute/src/manager/mod.rs b/crates/resolute/src/manager/mod.rs new file mode 100644 index 0000000..fe58deb --- /dev/null +++ b/crates/resolute/src/manager/mod.rs @@ -0,0 +1,78 @@ +mod download; +mod paths; + +use std::path::Path; + +use reqwest::Client; + +#[cfg(feature = "db")] +use crate::db::ResoluteDatabase; +use crate::mods::ResoluteMod; +use crate::{Error, Result}; + +pub use self::download::Downloader; +pub use self::download::DownloaderBuilder; +pub use self::paths::ArtifactPaths; + +/// Main entry point for all mod-related operations that need to be persisted +pub struct ModManager<#[cfg(feature = "db")] 'a> { + #[cfg(feature = "db")] + pub db: ResoluteDatabase<'a>, + pub downloader: Downloader, +} + +macro_rules! impl_ModManager_with_without_db { + { impl ModManager $implementations:tt } => { + #[cfg(feature = "db")] + impl<'a> ModManager<'a> $implementations + + #[cfg(not(feature = "db"))] + impl ModManager $implementations + } +} + +impl_ModManager_with_without_db! { + impl ModManager { + /// Creates a new mod manager + pub fn new( + #[cfg(feature = "db")] db: ResoluteDatabase<'a>, + base_dest: impl AsRef, + http_client: &Client, + ) -> Self { + Self { + #[cfg(feature = "db")] db, + downloader: DownloaderBuilder::new() + .base(&base_dest) + .http_client(http_client.clone()) + .build(), + } + } + + /// Installs a mod and stores it as installed in the database + pub async fn install_mod

(&self, rmod: &ResoluteMod, version: impl AsRef, progress: P) -> Result<()> + where + P: Fn(u64, u64), + { + let version = rmod + .versions + .get(version.as_ref()) + .ok_or_else(|| Error::UnknownVersion(rmod.id.clone(), version.as_ref().to_owned()))?; + + self.downloader.download_version(version, progress).await?; + + #[cfg(feature = "db")] { + let mut rmod = rmod.clone(); + rmod.installed_version = Some(version.semver.clone()); + self.db.store_mod(rmod)?; + } + + Ok(()) + } + + /// Changes the base destination of mods for the manager + pub fn set_base_dest(&mut self, path: impl AsRef) { + let path = path.as_ref(); + self.downloader.base_dest = path.to_owned(); + } + } +} diff --git a/crates/resolute/src/manager/paths.rs b/crates/resolute/src/manager/paths.rs new file mode 100644 index 0000000..6790832 --- /dev/null +++ b/crates/resolute/src/manager/paths.rs @@ -0,0 +1,66 @@ +use std::{ + ffi::OsString, + path::{Path, PathBuf}, +}; + +use path_clean::PathClean; + +use crate::{mods::ModArtifact, Error, Result}; + +/// Contains full paths that an artifact may live in at various stages of installation +#[derive(Clone, Debug)] +pub struct ArtifactPaths { + /// Full path for an artifact file that has been installed + pub final_dest: PathBuf, + /// Full path for an artifact file that has just been downloaded + pub tmp_dest: PathBuf, + /// Full path for an artifact file that already existed and has been renamed + pub old_dest: PathBuf, +} + +impl ArtifactPaths { + /// Builds a set of artifact destination paths for a given artifact and base destination path. + /// Fails if there's any issue building the paths or if the artifact's destination ends up outside of the base path. + pub fn try_new(artifact: &ModArtifact, base_dest: impl AsRef) -> Result { + let base_dest = base_dest.as_ref(); + + // Add the artifact's install location to the path + let mut dest = base_dest.join(match &artifact.install_location { + Some(install_location) => { + let path = Path::new(install_location); + path.strip_prefix("/").or::(Ok(path))? + } + None => Path::new("rml_mods"), + }); + + // Add the artifact's filename to the path + let filename = match &artifact.filename { + Some(filename) => OsString::from(filename), + None => Path::new(artifact.url.path()) + .file_name() + .ok_or_else(|| Error::Path(format!("unable to extract file name from url: {}", artifact.url)))? + .to_owned(), + }; + dest.push(&filename); + + // Ensure the final path is inside the base path + let final_dest = dest.clean(); + if !final_dest.starts_with(base_dest) { + return Err(Error::Path( + "artifact's final destination is not a subdirectory of the base destination".to_owned(), + )); + } + + // Build the temporary and old filenames + let mut tmp_filename = filename.clone(); + tmp_filename.push(".new"); + let mut old_filename = filename; + old_filename.push(".old"); + + Ok(Self { + tmp_dest: final_dest.with_file_name(tmp_filename), + old_dest: final_dest.with_file_name(old_filename), + final_dest, + }) + } +} diff --git a/crates/resolute/src/mods.rs b/crates/resolute/src/mods.rs index 531daf7..4b65b82 100644 --- a/crates/resolute/src/mods.rs +++ b/crates/resolute/src/mods.rs @@ -3,6 +3,11 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use url::Url; +#[cfg(feature = "db")] +use native_db::*; +#[cfg(feature = "db")] +use native_model::{native_model, Model}; + use crate::manifest::{ ManifestAuthors, ManifestData, ManifestEntryArtifact, ManifestEntryDependencies, ManifestEntryVersions, }; @@ -36,6 +41,7 @@ pub fn load_manifest(manifest: ManifestData) -> ResoluteModMap { tags: entry.tags, flags: entry.flags, platforms: entry.platforms, + installed_version: None, } }) }) @@ -96,9 +102,23 @@ pub type ResoluteModMap = HashMap; /// A single Resonite mod with all information relevant to it #[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "db", native_model(id = 1, version = 1))] +#[cfg_attr(feature = "db", native_db)] pub struct ResoluteMod { + // The primary_key and secondary_key macros don't work with cfg_attr for whatever reason + #[cfg(feature = "db")] + #[primary_key] + pub id: String, + #[cfg(not(feature = "db"))] pub id: String, + + // The primary_key and secondary_key macros don't work with cfg_attr for whatever reason + #[cfg(feature = "db")] + #[secondary_key] pub name: String, + #[cfg(not(feature = "db"))] + pub name: String, + pub description: String, pub category: String, pub authors: Vec, @@ -109,6 +129,7 @@ pub struct ResoluteMod { pub flags: Option>, pub platforms: Option>, pub versions: HashMap, + pub installed_version: Option, } /// Details for an author of a mod diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index 612edbd..06df0b4 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -20,6 +20,9 @@ serde_json = "1.0" anyhow = "1.0" log = "0.4" sha2 = "0.10" +native_db = "0.5" +once_cell = "1.19" +opener = "0.6" reqwest = { version = "0.11", features = [ "rustls-tls", "stream", @@ -35,7 +38,6 @@ tauri = { version = "1.5", features = [ "fs-exists", "path-all", "process-relaunch", - "shell-open", ] } tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1", features = [ "colored", diff --git a/crates/tauri-app/src/main.rs b/crates/tauri-app/src/main.rs index add7151..5c158f9 100644 --- a/crates/tauri-app/src/main.rs +++ b/crates/tauri-app/src/main.rs @@ -1,13 +1,16 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::{io, path::PathBuf, time::Duration}; +use std::{io, time::Duration}; -use anyhow::Context; +use anyhow::{anyhow, Context}; use log::{debug, error, info, warn}; +use native_db::DatabaseBuilder; +use once_cell::sync::Lazy; use resolute::{ + db::ResoluteDatabase, discover::discover_resonite, - download::Downloader, + manager::ModManager, manifest, mods::{self, ModVersion, ResoluteMod, ResoluteModMap}, }; @@ -19,6 +22,8 @@ use tokio::{fs, join, sync::Mutex}; mod settings; +static mut DB_BUILDER: Lazy = Lazy::new(DatabaseBuilder::new); + fn main() -> anyhow::Result<()> { // Set up a shared HTTP client let http_client = reqwest::Client::builder() @@ -27,9 +32,6 @@ fn main() -> anyhow::Result<()> { .build() .context("Unable to build HTTP client")?; - // Set up a shared mod downloader - let downloader = Downloader::new(http_client.clone()); - // Set up and run the Tauri app tauri::Builder::default() .plugin( @@ -64,25 +66,17 @@ fn main() -> anyhow::Result<()> { .invoke_handler(tauri::generate_handler![ show_window, load_manifest, - install_version, + install_mod_version, discover_resonite_path, verify_resonite_path, - hash_file + hash_file, + open_log_dir, + resonite_path_changed, ]) .manage(http_client) - .manage(downloader) .manage(ResoluteState::default()) .setup(|app| { - info!( - "Resolute v{} initializing", - app.config() - .package - .version - .clone() - .unwrap_or_else(|| "Unknown".to_owned()) - ); - - let window = app.get_window("main").expect("unable to get main window"); + let window = app.get_window("main").ok_or("unable to get main window")?; // Workaround for poor resize performance on Windows window.on_window_event(|event| { @@ -93,23 +87,14 @@ fn main() -> anyhow::Result<()> { // Open the dev console automatically in development #[cfg(debug_assertions)] - { - window.open_devtools(); - } - - // Create any missing app directories - let handle = app.app_handle(); - tauri::async_runtime::spawn(async { - if let Err(err) = create_app_dirs(handle).await { - warn!("Unable to create some app directories: {}", err); - } - }); + window.open_devtools(); - // Discover the Resonite path if it isn't configured already + // Initialize the app let handle = app.app_handle(); tauri::async_runtime::spawn(async move { - if let Err(err) = autodiscover_resonite_path(handle).await { - warn!("Unable to autodiscover Resonite path: {}", err); + if let Err(err) = init(handle.clone()).await { + error!("Initialization failed: {}", err); + build_error_window(handle, err); } }); @@ -121,6 +106,62 @@ fn main() -> anyhow::Result<()> { Ok(()) } +/// Initializes the app +async fn init(app: AppHandle) -> Result<(), anyhow::Error> { + info!( + "Resolute v{} initializing", + app.config() + .package + .version + .clone() + .unwrap_or_else(|| "Unknown".to_owned()) + ); + + // Ensure all needed app directories are created + if let Err(err) = create_app_dirs(app.clone()).await { + warn!("Unable to create some app directories: {}", err); + } + + // Discover the Resonite path in the background if it isn't configured already + let handle = app.app_handle(); + tauri::async_runtime::spawn(async { + if let Err(err) = autodiscover_resonite_path(handle).await { + warn!("Unable to autodiscover Resonite path: {}", err); + } + }); + + tauri::async_runtime::spawn_blocking(move || { + // Open the database + let resolver = app.path_resolver(); + let db_path = resolver + .app_data_dir() + .ok_or_else(|| anyhow!("Unable to get data dir"))? + .join("resolute.db"); + info!("Opening database at {}", db_path.display()); + let db = unsafe { ResoluteDatabase::open(&mut DB_BUILDER, db_path) }.context("Unable to open database")?; + + // Get the Resonite path setting + info!("Retrieving Resonite path from settings store"); + let resonite_path = settings::get(&app, "resonitePath") + .context("Unable to get resonitePath setting")? + .unwrap_or_else(|| "".to_owned()); + info!("Resonite path: {}", &resonite_path); + + // Set up the shared mod manager + info!("Setting up mod manager"); + let http_client = app.state::(); + let manager = ModManager::new(db, resonite_path, &http_client); + app.manage(Mutex::new(manager)); + + Ok::<(), anyhow::Error>(()) + }) + .await + .context("Error running blocking task for initialization")??; + + info!("Resolute initialized"); + Ok(()) +} + /// Creates any missing app directories async fn create_app_dirs(app: AppHandle) -> Result<(), String> { // Create all of the directories @@ -168,7 +209,11 @@ async fn autodiscover_resonite_path(app: AppHandle) -> Result<(), anyhow::Error> match resonite_dir { Some(resonite_dir) => { info!("Discovered Resonite path: {}", resonite_dir.display()); - settings::set(&app, "resonitePath", resonite_dir)? + settings::set(&app, "resonitePath", &resonite_dir)?; + + if let Some(manager) = app.try_state::>() { + manager.lock().await.set_base_dest(resonite_dir); + } } None => { info!("Autodiscovery didn't find a Resonite path"); @@ -179,18 +224,35 @@ async fn autodiscover_resonite_path(app: AppHandle) -> Result<(), anyhow::Error> Ok(()) } +/// Builds the error window for a given error, then closes the main window +fn build_error_window(app: AppHandle, err: anyhow::Error) { + let init_script = format!("globalThis.error = `{:?}`;", err); + tauri::WindowBuilder::new(&app, "error", tauri::WindowUrl::App("error.html".into())) + .title("Resolute") + .center() + .resizable(false) + .visible(false) + .initialization_script(&init_script) + .build() + .expect("Error occurred while initializing and the error window couldn't be displayed"); + let _ = app.get_window("main").expect("unable to get main window").close(); +} + +/// Sets the requesting window's visibility to shown #[tauri::command] -fn show_window(window: Window) { - window.show().expect("unable to show main window"); +fn show_window(window: Window) -> Result<(), String> { + window.show().map_err(|err| format!("Unable to show window: {}", err))?; + Ok(()) } +/// Loads the manifest data and parses it into a mod map #[tauri::command] async fn load_manifest(app: AppHandle, bypass_cache: bool) -> Result { // Configure the manifest client let mut builder = manifest::Client::builder().cache( app.path_resolver() .app_cache_dir() - .expect("unable to locate cache directory") + .ok_or_else(|| "Unable to locate cache directory".to_owned())? .join("resonite-mod-manifest.json"), ); @@ -228,15 +290,19 @@ async fn load_manifest(app: AppHandle, bypass_cache: bool) -> Result Result<(), String> { +async fn install_mod_version(app: AppHandle, rmod: ResoluteMod, version: ModVersion) -> Result<(), String> { + let manager = app.state::>(); let resonite_path: String = settings::require(&app, "resonitePath").map_err(|err| err.to_string())?; + manager.lock().await.set_base_dest(resonite_path); // Download the version - let downloader = app.state::(); info!("Installing mod {} v{}", rmod.name, version.semver); - downloader - .download_version(&version, PathBuf::from(resonite_path).as_path(), |_, _| {}) + manager + .lock() + .await + .install_mod(&rmod, &version.semver, |_, _| {}) .await .map_err(|err| { error!("Failed to download mod {} v{}: {}", rmod.name, version.semver, err); @@ -247,6 +313,7 @@ async fn install_version(app: AppHandle, rmod: ResoluteMod, version: ModVersion) Ok(()) } +/// Looks for a possible Resonite path #[tauri::command] async fn discover_resonite_path() -> Result, String> { let path = tauri::async_runtime::spawn_blocking(|| discover_resonite(None)) @@ -269,6 +336,7 @@ async fn discover_resonite_path() -> Result, String> { } } +/// Verifies the Resonite path specified in the settings store exists #[tauri::command] async fn verify_resonite_path(app: AppHandle) -> Result { let resonite_path: String = settings::require(&app, "resonitePath").map_err(|err| err.to_string())?; @@ -277,6 +345,7 @@ async fn verify_resonite_path(app: AppHandle) -> Result { .map_err(|err| err.to_string()) } +/// Calculates the SHA-256 checksum of a file #[tauri::command] async fn hash_file(path: String) -> Result { // Verify the path given is a file @@ -311,6 +380,27 @@ async fn hash_file(path: String) -> Result { Ok(hash) } +/// Opens the app's log directory in the system file browser +#[tauri::command] +async fn open_log_dir(app: AppHandle) -> Result<(), String> { + let path = app + .path_resolver() + .app_log_dir() + .ok_or_else(|| "Unable to get log directory".to_owned())?; + opener::open(path).map_err(|err| format!("Unable to open log directory: {}", err))?; + Ok(()) +} + +/// Ensures a change to the Resonite path setting is propagated to the manager +#[tauri::command] +async fn resonite_path_changed(app: AppHandle) -> Result<(), String> { + let manager = app.state::>(); + let resonite_path: String = settings::require(&app, "resonitePath").map_err(|err| err.to_string())?; + manager.lock().await.set_base_dest(&resonite_path); + info!("Changed manager's base destination to {}", resonite_path); + Ok(()) +} + #[derive(Default)] struct ResoluteState { mods: Mutex, diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index 4556cac..5ed349f 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -28,10 +28,6 @@ "process": { "all": false, "relaunch": true - }, - "shell": { - "all": false, - "open": true } }, "bundle": { diff --git a/package-lock.json b/package-lock.json index 84eb640..5e0b077 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "resolute", "version": "0.5.0", "dependencies": { + "@fontsource/roboto-mono": "^5.0.16", "@mdi/js": "^7.3.67", "@tauri-apps/api": "^1.5.1", "dompurify": "^3.0.6", @@ -440,6 +441,11 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fontsource/roboto-mono": { + "version": "5.0.16", + "resolved": "https://registry.npmjs.org/@fontsource/roboto-mono/-/roboto-mono-5.0.16.tgz", + "integrity": "sha512-unZYfjXts55DQyODz0I9DzbSrS5DRKPNq9crJpNJe/Vy818bLnijprcJv3fvqwdDqTT0dRm2Fhk09QEIdtAc+Q==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", diff --git a/package.json b/package.json index d99978a..e17a219 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format:check": "prettier --check ." }, "dependencies": { + "@fontsource/roboto-mono": "^5.0.16", "@mdi/js": "^7.3.67", "@tauri-apps/api": "^1.5.1", "dompurify": "^3.0.6", diff --git a/ui/error.html b/ui/error.html new file mode 100644 index 0000000..a2321c7 --- /dev/null +++ b/ui/error.html @@ -0,0 +1,13 @@ + + + + + + Resolute + + + +

+ + + diff --git a/ui/src/ErrorApp.vue b/ui/src/ErrorApp.vue new file mode 100644 index 0000000..fa3393e --- /dev/null +++ b/ui/src/ErrorApp.vue @@ -0,0 +1,86 @@ + + + diff --git a/ui/src/composables/settings.js b/ui/src/composables/settings.js index 70e69b8..c5e2e24 100644 --- a/ui/src/composables/settings.js +++ b/ui/src/composables/settings.js @@ -1,4 +1,5 @@ import { reactive } from 'vue'; +import { invoke } from '@tauri-apps/api'; import { Store } from 'tauri-plugin-store-api'; import { info } from 'tauri-plugin-log-api'; @@ -46,6 +47,7 @@ export function useSettings() { await store.set(setting, value); info(`Setting ${setting} set to ${value}, persistNow = ${persistNow}`); if (persistNow) await persist(); + if (setting === 'resonitePath') invoke('resonite_path_changed'); } /** diff --git a/ui/src/error.js b/ui/src/error.js new file mode 100644 index 0000000..42db583 --- /dev/null +++ b/ui/src/error.js @@ -0,0 +1,21 @@ +import { createApp } from 'vue'; +import { createVuetify } from 'vuetify'; +import { aliases, mdi } from 'vuetify/iconsets/mdi-svg'; + +import ErrorApp from './ErrorApp.vue'; + +import 'vuetify/styles'; +import '@fontsource/roboto-mono'; +import './styles/global.css'; + +createApp(ErrorApp) + .use( + createVuetify({ + icons: { + defaultSet: 'mdi', + aliases, + sets: { mdi }, + }, + }), + ) + .mount('#app'); diff --git a/ui/src/stores/mods.js b/ui/src/stores/mods.js index 088f404..265a97d 100644 --- a/ui/src/stores/mods.js +++ b/ui/src/stores/mods.js @@ -57,7 +57,7 @@ export const useModStore = defineStore('mods', () => { await info( `Requesting installation of mod ${mod.name} v${version.semver}`, ); - await invoke('install_version', { + await invoke('install_mod_version', { rmod: mod, version, }); diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css index 8ded434..293a718 100644 --- a/ui/src/styles/global.css +++ b/ui/src/styles/global.css @@ -1,8 +1,4 @@ :root { - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 400; cursor: default; font-synthesis: none; text-rendering: optimizeLegibility; @@ -15,7 +11,7 @@ --theme-primary-lighten-2: 100, 181, 247; } -html, +:root, body, #app { width: 100%; @@ -34,3 +30,17 @@ a:hover:not(.v-list-item) { a:focus:not(.v-list-item) { color: rgb(var(--theme-primary-lighten-2)); } + +pre { + font-family: 'Roboto Mono'; + font-size: 14px; +} + +.text-mono { + font-family: 'Roboto Mono' !important; + font-size: 14px !important; +} + +.text-mono .v-field { + font-size: 14px !important; +} From 1a6e70b6eace574fdc61cf684a0d187c1f68c798 Mon Sep 17 00:00:00 2001 From: Schuyler Cebulskie Date: Fri, 29 Dec 2023 17:04:55 -0500 Subject: [PATCH 02/20] Add version indicator to mod list --- crates/resolute/src/db.rs | 12 ++ crates/resolute/src/error.rs | 3 + crates/resolute/src/manager/mod.rs | 70 +++++++- crates/resolute/src/manifest.rs | 12 ++ crates/resolute/src/mods.rs | 1 + crates/tauri-app/src/main.rs | 73 ++++---- ui/src/components/ModTable.vue | 8 +- ui/src/components/ModVersionStatus.vue | 39 +++++ ui/src/stores/mods.js | 20 ++- ui/src/structs/mod.js | 228 +++++++++++++++++++++++++ 10 files changed, 406 insertions(+), 60 deletions(-) create mode 100644 ui/src/components/ModVersionStatus.vue create mode 100644 ui/src/structs/mod.js diff --git a/crates/resolute/src/db.rs b/crates/resolute/src/db.rs index 04b4cd9..3c8f11b 100644 --- a/crates/resolute/src/db.rs +++ b/crates/resolute/src/db.rs @@ -26,6 +26,18 @@ impl<'a> ResoluteDatabase<'a> { Ok(mods) } + /// Retrieves all mods from the database that have an installed version + pub fn get_installed_mods(&self) -> Result> { + let read = self.db.r_transaction()?; + let mods = read + .scan() + .primary()? + .all() + .filter(|rmod: &ResoluteMod| rmod.installed_version.is_some()) + .collect(); + Ok(mods) + } + /// Retrieves a single mod from the database by its ID pub fn get_mod(&self, id: String) -> Result> { let read = self.db.r_transaction()?; diff --git a/crates/resolute/src/error.rs b/crates/resolute/src/error.rs index 2cbe67a..2d434e0 100644 --- a/crates/resolute/src/error.rs +++ b/crates/resolute/src/error.rs @@ -17,6 +17,9 @@ pub enum Error { #[error("unable to process path: {0}")] Path(String), + #[error("unable to parse url: {0}")] + Url(String), + #[error("json error: {0}")] Json(#[from] serde_json::Error), diff --git a/crates/resolute/src/manager/mod.rs b/crates/resolute/src/manager/mod.rs index fe58deb..b6b5434 100644 --- a/crates/resolute/src/manager/mod.rs +++ b/crates/resolute/src/manager/mod.rs @@ -3,12 +3,12 @@ mod paths; use std::path::Path; -use reqwest::Client; +use log::info; #[cfg(feature = "db")] use crate::db::ResoluteDatabase; -use crate::mods::ResoluteMod; -use crate::{Error, Result}; +use crate::mods::{self, ResoluteMod, ResoluteModMap}; +use crate::{manifest, Error, Result}; pub use self::download::Downloader; pub use self::download::DownloaderBuilder; @@ -19,6 +19,7 @@ pub struct ModManager<#[cfg(feature = "db")] 'a> { #[cfg(feature = "db")] pub db: ResoluteDatabase<'a>, pub downloader: Downloader, + http_client: reqwest::Client, } macro_rules! impl_ModManager_with_without_db { @@ -37,18 +38,72 @@ impl_ModManager_with_without_db! { pub fn new( #[cfg(feature = "db")] db: ResoluteDatabase<'a>, base_dest: impl AsRef, - http_client: &Client, + http_client: &reqwest::Client, ) -> Self { Self { - #[cfg(feature = "db")] db, + #[cfg(feature = "db")] + db, downloader: DownloaderBuilder::new() .base(&base_dest) .http_client(http_client.clone()) .build(), + http_client: http_client.clone(), } } - /// Installs a mod and stores it as installed in the database + /// Gets all mods that have a version installed + #[cfg(feature = "db")] + pub fn get_installed_mods(&self) -> Result { + let mods = self + .db + .get_mods()? + .into_iter() + .map(|rmod| (rmod.id.clone(), rmod)) + .collect(); + Ok(mods) + } + + /// Gets all mods from a manifest, and if the "db" feature is active, marks any installed ones + pub async fn get_all_mods(&self, manifest_config: manifest::Config, bypass_cache: bool) -> Result { + let manifest = manifest::Client::new(manifest_config, self.http_client.clone()); + + // Retrieve the manifest JSON + let json = if !bypass_cache { + manifest.retrieve().await + } else { + info!("Forcing download of manifest"); + manifest.download().await + }?; + + // Parse the JSON into raw manifest data, load that into a mod map, and mark any installed mods in it + let mods = tokio::task::block_in_place(move || -> Result { + let data = manifest.parse(&json)?; + let mut mods = mods::load_manifest(data); + + #[cfg(feature = "db")] + self.mark_installed_mods(&mut mods)?; + + Ok(mods) + })?; + + Ok(mods) + } + + /// Fills in the installed_version field for all mods in a map that are installed + #[cfg(feature = "db")] + pub fn mark_installed_mods(&self, mods: &mut ResoluteModMap) -> Result<()> { + let installed_mods = self.get_installed_mods()?; + + for (id, rmod) in mods.iter_mut() { + if let Some(installed) = installed_mods.get(id) { + rmod.installed_version = installed.installed_version.clone(); + } + } + + Ok(()) + } + + /// Installs a mod, and if the "db" feature is active, stores it as installed in the database pub async fn install_mod

(&self, rmod: &ResoluteMod, version: impl AsRef, progress: P) -> Result<()> where P: Fn(u64, u64), @@ -60,7 +115,8 @@ impl_ModManager_with_without_db! { self.downloader.download_version(version, progress).await?; - #[cfg(feature = "db")] { + #[cfg(feature = "db")] + { let mut rmod = rmod.clone(); rmod.installed_version = Some(version.semver.clone()); self.db.store_mod(rmod)?; diff --git a/crates/resolute/src/manifest.rs b/crates/resolute/src/manifest.rs index f698238..24c5e96 100644 --- a/crates/resolute/src/manifest.rs +++ b/crates/resolute/src/manifest.rs @@ -186,6 +186,18 @@ impl Config { cache_stale_after: Some(Duration::from_secs(60 * 60 * 6)), } } + + /// Attempts to parse a URL and assign it to the remote_url field + pub fn set_remote_url(&mut self, url: U) -> Result<()> + where + U: TryInto, + >::Error: std::fmt::Debug, + { + self.remote_url = url + .try_into() + .map_err(|_err| Error::Url("manifest remote url".to_owned()))?; + Ok(()) + } } impl Default for Config { diff --git a/crates/resolute/src/mods.rs b/crates/resolute/src/mods.rs index 4b65b82..446ab09 100644 --- a/crates/resolute/src/mods.rs +++ b/crates/resolute/src/mods.rs @@ -129,6 +129,7 @@ pub struct ResoluteMod { pub flags: Option>, pub platforms: Option>, pub versions: HashMap, + #[serde(rename = "installedVersion")] pub installed_version: Option, } diff --git a/crates/tauri-app/src/main.rs b/crates/tauri-app/src/main.rs index 5c158f9..615c5cd 100644 --- a/crates/tauri-app/src/main.rs +++ b/crates/tauri-app/src/main.rs @@ -12,7 +12,7 @@ use resolute::{ discover::discover_resonite, manager::ModManager, manifest, - mods::{self, ModVersion, ResoluteMod, ResoluteModMap}, + mods::{ModVersion, ResoluteMod, ResoluteModMap}, }; use sha2::{Digest, Sha256}; use tauri::{AppHandle, Manager, Window, WindowEvent}; @@ -65,7 +65,7 @@ fn main() -> anyhow::Result<()> { .plugin(tauri_plugin_store::Builder::default().build()) .invoke_handler(tauri::generate_handler![ show_window, - load_manifest, + load_all_mods, install_mod_version, discover_resonite_path, verify_resonite_path, @@ -74,7 +74,6 @@ fn main() -> anyhow::Result<()> { resonite_path_changed, ]) .manage(http_client) - .manage(ResoluteState::default()) .setup(|app| { let window = app.get_window("main").ok_or("unable to get main window")?; @@ -247,46 +246,37 @@ fn show_window(window: Window) -> Result<(), String> { /// Loads the manifest data and parses it into a mod map #[tauri::command] -async fn load_manifest(app: AppHandle, bypass_cache: bool) -> Result { - // Configure the manifest client - let mut builder = manifest::Client::builder().cache( - app.path_resolver() - .app_cache_dir() - .ok_or_else(|| "Unable to locate cache directory".to_owned())? - .join("resonite-mod-manifest.json"), - ); - - // Override the manifest URL if the user has customized it +async fn load_all_mods( + app: AppHandle, + manager: tauri::State<'_, Mutex>>, + bypass_cache: bool, +) -> Result { + // Build a manifest config to use + let mut manifest_config = manifest::Config { + cache_file_path: Some( + app.path_resolver() + .app_cache_dir() + .ok_or_else(|| "Unable to locate cache directory".to_owned())? + .join("resonite-mod-manifest.json"), + ), + ..manifest::Config::default() + }; + + // Override the manifest URL if the user has configured a custom one let manifest_url: Option = settings::get(&app, "manifestUrl").map_err(|err| err.to_string())?; if let Some(url) = manifest_url { - builder = builder.url(url.as_ref()); + manifest_config + .set_remote_url(url.as_ref()) + .map_err(|_err| "Unable to parse custom manifest URL".to_owned())?; } - // Build the manifest client using the shared HTTP client - let http = app.state::(); - let client = builder.http_client(http.inner().clone()).build(); - - // Retrieve the manifest JSON - let json = if !bypass_cache { - client.retrieve().await - } else { - info!("Forcing download of manifest"); - client.download().await - } - .map_err(|err| format!("Error downloading manifest: {}", err))?; - - // Parse the manifest JSON then build a mod map out of it - let mods = tauri::async_runtime::spawn_blocking(move || -> Result { - let manifest = client - .parse(json.as_str()) - .map_err(|err| format!("Error parsing manifest: {}", err))?; - Ok(mods::load_manifest(manifest)) - }) - .await - .map_err(|err| format!("Error loading manifest: {}", err))??; - - let state = app.state::(); - *state.mods.lock().await = mods.clone(); + // Retrieve all mods from the manager + let mods = manager + .lock() + .await + .get_all_mods(manifest_config, bypass_cache) + .await + .map_err(|err| format!("Unable to get all mods from manager: {}", err))?; Ok(mods) } @@ -401,11 +391,6 @@ async fn resonite_path_changed(app: AppHandle) -> Result<(), String> { Ok(()) } -#[derive(Default)] -struct ResoluteState { - mods: Mutex, -} - #[derive(Clone, serde::Serialize)] struct Payload { args: Vec, diff --git a/ui/src/components/ModTable.vue b/ui/src/components/ModTable.vue index afc1ffd..0408f17 100644 --- a/ui/src/components/ModTable.vue +++ b/ui/src/components/ModTable.vue @@ -17,6 +17,7 @@ {{ mod.description }} {{ mod.category }} + @@ -89,6 +90,7 @@ import { ref, computed } from 'vue'; import { mdiDownload } from '@mdi/js'; import useSettings from '../composables/settings'; +import ModVersionStatus from './ModVersionStatus.vue'; import ModInstaller from './ModInstaller.vue'; const props = defineProps({ @@ -103,6 +105,7 @@ const headers = computed(() => { { title: 'Name', key: 'name' }, { title: 'Description', key: 'description' }, { title: 'Category', key: 'category' }, + { title: 'Version', key: 'sortableVersionStatus' }, { title: null, sortable: false }, ]; @@ -114,9 +117,12 @@ const headers = computed(() => { return headers; }); + +const items = computed(() => (props.mods ? Object.values(props.mods) : [])); + const groupBy = computed(() => settings.current.groupMods ? [{ key: 'category', order: 'asc' }] : undefined, ); -const items = computed(() => (props.mods ? Object.values(props.mods) : [])); + const filter = ref(null); diff --git a/ui/src/components/ModVersionStatus.vue b/ui/src/components/ModVersionStatus.vue new file mode 100644 index 0000000..f4025d6 --- /dev/null +++ b/ui/src/components/ModVersionStatus.vue @@ -0,0 +1,39 @@ + + + diff --git a/ui/src/stores/mods.js b/ui/src/stores/mods.js index 265a97d..7008b20 100644 --- a/ui/src/stores/mods.js +++ b/ui/src/stores/mods.js @@ -1,10 +1,11 @@ import { ref, reactive } from 'vue'; import { defineStore } from 'pinia'; -import { compare as semverCompare } from 'semver'; import { invoke } from '@tauri-apps/api'; import { message } from '@tauri-apps/api/dialog'; import { info, error } from 'tauri-plugin-log-api'; +import { ResoluteMod } from '../structs/mod'; + export const useModStore = defineStore('mods', () => { const mods = ref(null); const loading = ref(false); @@ -21,7 +22,9 @@ export const useModStore = defineStore('mods', () => { try { await info(`Requesting mod load, bypassCache = ${bypassCache}`); - const mods = await invoke('load_manifest', { bypassCache }); + const mods = await invoke('load_all_mods', { bypassCache }); + for (const id of Object.keys(mods)) mods[id] = new ResoluteMod(mods[id]); + console.debug('Mods loaded', mods); info(`${Object.keys(mods).length} mods loaded`); @@ -45,14 +48,10 @@ export const useModStore = defineStore('mods', () => { */ async function install(modID) { const mod = mods.value[modID]; + const version = mod.latestVersion; - // Determine the latest version - const versions = Object.values(mod.versions); - versions.sort((ver1, ver2) => semverCompare(ver2.semver, ver1.semver)); - const version = versions[0]; - - // Request the version install from the backend and display an alert for the result try { + // Add an operation for the mod being installed and request the installation from the backend operations[modID] = 'install'; await info( `Requesting installation of mod ${mod.name} v${version.semver}`, @@ -61,16 +60,21 @@ export const useModStore = defineStore('mods', () => { rmod: mod, version, }); + + // Update the mod's installed version and notify the user of the success + mod.installedVersion = version; message(`${mod.name} v${version.semver} was successfully installed.`, { title: 'Mod installed', type: 'info', }); } catch (err) { + // Notify the user of the failure message(`Error installing ${mod.name} v${version.semver}:\n${err}`, { title: 'Error installing mod', type: 'error', }); } finally { + // Clear the operation for the mod operations[modID] = null; } } diff --git a/ui/src/structs/mod.js b/ui/src/structs/mod.js new file mode 100644 index 0000000..4449618 --- /dev/null +++ b/ui/src/structs/mod.js @@ -0,0 +1,228 @@ +import { compare as semverCompare, lt as semverLt } from 'semver'; + +/** + * Container for all data about a Resonite mod + */ +export class ResoluteMod { + constructor(data) { + /** + * Full unique identifier + * @type {string} + */ + this.id = data.id; + + /** + * Short name + * @type {string} + */ + this.name = data.name; + + /** + * Summary of the mod's purpose + */ + this.description = data.description; + + /** + * Category the mod belongs to + * @type {string} + */ + this.category = data.category; + + /** + * Creators/contributors of the mod, with the first one being the primary author + * @type {ModAuthor[]} + */ + this.authors = data.authors.map((author) => new ModAuthor(author)); + + /** + * URL to the source code for the mod + * @type {?string} + */ + this.sourceLocation = data.sourceLocation; + + /** + * URL to the homepage for the mod + * @type {?string} + */ + this.website = data.website; + + /** + * List of searchable tags + * @type {string[]} + */ + this.tags = data.tags; + + /** + * Meta flags + * @type {Array<'deprecated'|'plugin'|'final'>} + */ + this.flags = data.flags; + + /** + * Platforms the mod is supported on + * @type {Array<'android'|'headless'|'linux'|'linux-native'|'linux-wine'|'windows'|'other'>} + */ + this.platforms = data.platforms; + + /** + * Available versions + * @type {Map} + */ + this.versions = new Map( + Object.entries(data.versions) + .map(([semver, version]) => [semver, new ModVersion(version)]) + .sort((a, b) => -semverCompare(a[0], b[0])), + ); + + /** + * Semver of the version that is currently installed + * @type {?ModVersion} + */ + this.installedVersion = data.installedVersion + ? this.versions.get(data.installedVersion) + : null; + } + + /** + * Latest available version + * @type {ModVersion} + */ + get latestVersion() { + return this.versions.values().next().value; + } + + /** + * Whether an update is available (the installed version is older than the latest version) + */ + get hasUpdate() { + if (!this.installedVersion) return false; + return semverLt(this.installedVersion.semver, this.latestVersion.semver); + } + + /** + * A number for the version status, for the purposes of sorting. + * 0 for update available, 1 for installed, 2 for not installed + * @type {number} + */ + get sortableVersionStatus() { + return this.hasUpdate ? 0 : this.installedVersion ? 1 : 2; + } + + /** + * A CSS class to use for the version text. + * text-success if installed and up-to-date, text-warning if there is an update available, nothing otherwise. + * @type {string} + */ + get versionTextClass() { + if (!this.installedVersion) return ''; + return this.hasUpdate ? 'text-warning' : 'text-success'; + } + + toJSON() { + return { + ...this, + installedVersion: this.installedVersion?.semver, + }; + } +} + +/** + * Contributor to a {@link ResoluteMod} + */ +export class ModAuthor { + constructor(data) { + /** + * Name/username + * @type {string} + */ + this.name = data.name; + + /** + * URL to the author's homepage + * @type {?string} + */ + this.url = data.url; + + /** + * URL to an avatar/icon + * @type {?string} + */ + this.icon = data.icon; + + /** + * URL to a support page + * @type {?string} + */ + this.support = data.support; + } +} + +/** + * Available version for a {@link ResoluteMod} + */ +export class ModVersion { + constructor(data) { + /** + * Semver version string + * @type {string} + */ + this.semver = data.semver; + + /** + * Files to install + * @type {ModArtifact[]} + */ + this.artifacts = data.artifacts.map( + (artifact) => new ModArtifact(artifact), + ); + + /** + * Required mods, in the form of a map of mod IDs -> required semver range + * @type {Map} + */ + this.dependencies = new Map(Object.entries(data.dependencies)); + + /** + * Mod conflicts, in the form of a map of mod IDs -> conflicting semver range + * @type {Map} + */ + this.conflicts = new Map(Object.entries(data.conflicts)); + + /** + * URL to a release page + * @type {?string} + */ + this.releaseUrl = data.releaseUrl; + } +} + +/** + * File to install for a {@link ModVersion} + */ +export class ModArtifact { + constructor(data) { + /** + * URL to the file to download + * @type {string} + */ + this.url = data.url; + + /** + * SHA-256 checksum of the file + * @type {string} + */ + this.sha256 = data.sha256; + + /** + * Filename to name the downloaded file as + * @type {?string} + */ + this.filename = data.filename; + + /** + * Location to download the file to + * @type {?string} + */ + this.installLocation = data.installLocation; + } +} From 361e9d59095548e8807c2440067e51fc85217f89 Mon Sep 17 00:00:00 2001 From: Schuyler Cebulskie Date: Fri, 29 Dec 2023 18:33:53 -0500 Subject: [PATCH 03/20] Persist the items per page selection to settings (grouped and ungrouped) --- ui/src/components/ModTable.vue | 30 +++++++++++++++++++++++++++++- ui/src/composables/settings.js | 2 ++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/ui/src/components/ModTable.vue b/ui/src/components/ModTable.vue index 0408f17..14a98ae 100644 --- a/ui/src/components/ModTable.vue +++ b/ui/src/components/ModTable.vue @@ -3,11 +3,12 @@ :headers="headers" :items="items" item-key="id" - :items-per-page="25" + :items-per-page="settings.current[modsPerPageSetting]" :loading="loading" :search="filter" :group-by="groupBy" fixed-header + @update:items-per-page="onItemsPerPageUpdate" >