├── Cargo.toml ├── core ├── src │ ├── modrinth_wrapper │ │ ├── mod.rs │ │ └── modrinth.rs │ ├── curseforge_wrapper │ │ ├── test1.md │ │ ├── file_utils.rs │ │ ├── hash.rs │ │ ├── structs.rs │ │ └── mod.rs │ ├── main.rs │ ├── gh_releases │ │ ├── structs.rs │ │ └── mod.rs │ ├── metadata.rs │ ├── cli.rs │ ├── lib.rs │ └── actions.rs ├── Cargo.toml └── README.md ├── tui ├── .envrc ├── .config │ └── config.json5 ├── src │ ├── action.rs │ ├── main.rs │ ├── cli.rs │ ├── logging.rs │ ├── errors.rs │ ├── components.rs │ ├── components │ │ ├── home.rs │ │ ├── list.rs │ │ └── toggle.rs │ ├── app.rs │ ├── tui.rs │ └── config.rs ├── build.rs ├── LICENSE ├── demo_tui.tape ├── .github │ └── workflows │ │ ├── ci.yml │ │ └── cd.yml ├── Cargo.toml └── README.md ├── .gitignore ├── .github └── workflows │ └── rust.yml └── README.md /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["tui", "core"] 3 | -------------------------------------------------------------------------------- /core/src/modrinth_wrapper/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod modrinth; 2 | -------------------------------------------------------------------------------- /core/src/curseforge_wrapper/test1.md: -------------------------------------------------------------------------------- 1 | # This is the first test file 2 | -------------------------------------------------------------------------------- /tui/.envrc: -------------------------------------------------------------------------------- 1 | export TUI_CONFIG=`pwd`/.config 2 | export TUI_DATA=`pwd`/.data 3 | export TUI_LOG_LEVEL=debug 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/target 2 | **/test 3 | **/test_2 4 | **/.DS_Store 5 | /.git 6 | **/*.jukit 7 | **/test2 8 | **/*.gif 9 | -------------------------------------------------------------------------------- /tui/.config/config.json5: -------------------------------------------------------------------------------- 1 | { 2 | "keybindings": { 3 | "Home": { 4 | "": "Quit", // Quit the application 5 | "": "Quit", // Another way to quit 6 | "": "Quit", // Yet another way to quit 7 | "": "Suspend" // Suspend the application 8 | }, 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tui/src/action.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use strum::Display; 3 | 4 | use crate::app::Mode; 5 | 6 | #[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)] 7 | pub enum Action { 8 | Tick, 9 | Render, 10 | Resize(u16, u16), 11 | Suspend, 12 | Resume, 13 | Quit, 14 | ClearScreen, 15 | Error(String), 16 | Help, 17 | Mode(Mode), 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }} 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Build 21 | run: cargo build --verbose 22 | -------------------------------------------------------------------------------- /tui/build.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder}; 3 | 4 | fn main() -> Result<()> { 5 | let build = BuildBuilder::all_build()?; 6 | let gix = GixBuilder::all_git()?; 7 | let cargo = CargoBuilder::all_cargo()?; 8 | Emitter::default() 9 | .add_instructions(&build)? 10 | .add_instructions(&gix)? 11 | .add_instructions(&cargo)? 12 | .emit() 13 | } 14 | -------------------------------------------------------------------------------- /tui/src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use cli::Cli; 3 | use color_eyre::Result; 4 | 5 | use crate::app::App; 6 | 7 | mod action; 8 | mod app; 9 | mod cli; 10 | mod components; 11 | mod config; 12 | mod errors; 13 | mod logging; 14 | mod tui; 15 | 16 | #[tokio::main] 17 | async fn main() -> Result<()> { 18 | crate::errors::init()?; 19 | crate::logging::init()?; 20 | 21 | let args = Cli::parse(); 22 | let mut app = App::new(4.0, 60.0, args.dir).await?; 23 | app.run().await?; 24 | Ok(()) 25 | } 26 | -------------------------------------------------------------------------------- /tui/src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::Parser; 4 | 5 | use crate::config::get_data_dir; 6 | 7 | #[derive(Parser, Debug)] 8 | #[command(author, version = version(), about)] 9 | pub struct Cli { 10 | #[arg(short, long, default_value_os_t = PathBuf::from("./"))] 11 | pub dir: PathBuf, 12 | } 13 | 14 | const VERSION_MESSAGE: &str = concat!( 15 | env!("CARGO_PKG_VERSION"), 16 | "-", 17 | env!("VERGEN_GIT_DESCRIBE"), 18 | " (", 19 | env!("VERGEN_BUILD_DATE"), 20 | ")" 21 | ); 22 | 23 | pub fn version() -> String { 24 | let author = clap::crate_authors!(); 25 | 26 | // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string(); 27 | let data_dir_path = get_data_dir().display().to_string(); 28 | 29 | format!( 30 | "\ 31 | {VERSION_MESSAGE} 32 | 33 | Authors: {author} 34 | 35 | Data directory: {data_dir_path}" 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /tui/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 JayanAXHF 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /core/src/main.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::eyre::Result; 2 | mod actions; 3 | use clap::Parser; 4 | use cli::*; 5 | use futures::lock::Mutex; 6 | use modder::*; 7 | use std::{collections::HashSet, sync::Arc}; 8 | use tracing::{error, info, level_filters::LevelFilter}; 9 | use tracing_subscriber::EnvFilter; 10 | 11 | #[tokio::main] 12 | async fn main() -> Result<()> { 13 | #[cfg(not(debug_assertions))] 14 | { 15 | color_eyre::install()?; 16 | } 17 | #[cfg(debug_assertions)] 18 | { 19 | std::panic::set_hook(Box::new(move |panic_info| { 20 | better_panic::Settings::auto() 21 | .most_recent_first(false) 22 | .lineno_suffix(true) 23 | .verbosity(better_panic::Verbosity::Full) 24 | .create_panic_handler()(panic_info); 25 | })); 26 | } 27 | let cli = Cli::parse(); 28 | 29 | let filter = if cli.silent { 30 | LevelFilter::ERROR 31 | } else { 32 | LevelFilter::INFO 33 | }; 34 | let env_filter = EnvFilter::builder() 35 | .with_default_directive(filter.into()) 36 | .from_env_lossy(); 37 | tracing_subscriber::fmt() 38 | .with_env_filter(env_filter) 39 | .compact() 40 | .init(); 41 | let res = actions::run(cli).await; 42 | if let Err(err) = res { 43 | error!("{err}"); 44 | return Err(err); 45 | } 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "modder" 3 | version = "1.0.0" 4 | edition = "2024" 5 | description = "Modder-rs is a mod manager for Minecraft. This crate contains the cli and the core logic that is also used by the TUI wrapper." 6 | license = "MIT" 7 | authors = ["JayanAXHF"] 8 | homepage = "https://github.com/JayanAXHF/modder-rs" 9 | repository = "https://github.com/JayanAXHF/modder-rs" 10 | readme = "README.md" 11 | keywords = ["minecraft", "mod", "manager", "cli"] 12 | categories = ["command-line-utilities", "games"] 13 | 14 | [dependencies] 15 | better-panic = "0.3.0" 16 | chrono = { version = "0.4.41", features = ["serde"] } 17 | clap = { version = "4.5.39", features = ["derive"] } 18 | color-eyre = "0.6.5" 19 | colored = "3.0.0" 20 | futures = "0.3.31" 21 | hex = "0.4.3" 22 | hmac-sha512 = "1.1.7" 23 | inquire = "0.7.5" 24 | itertools = "0.14.0" 25 | percent-encoding = "2.3.1" 26 | pretty_assertions = { version = "1.4.1" } 27 | reqwest = "0.12.18" 28 | serde = { version = "1.0.219", features = ["derive"] } 29 | serde_json = "1.0.140" 30 | strum = { version = "0.27.1", features = ["derive"] } 31 | tabwriter = "1.4.1" 32 | tempfile = "3.20.0" 33 | thiserror = "2.0.12" 34 | tokio = { version = "1.45.1", features = ["full", "rt-multi-thread"] } 35 | tracing = "0.1.41" 36 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 37 | url = { version = "2.5.4", features = ["serde"] } 38 | zip = "4.2.0" 39 | 40 | [profile.release] 41 | lto = true 42 | strip = true 43 | -------------------------------------------------------------------------------- /tui/demo_tui.tape: -------------------------------------------------------------------------------- 1 | Output demo_tui.gif 2 | Set FontSize 20 3 | Set Width 1920 4 | Set Height 1080 5 | 6 | Set Shell zsh 7 | Sleep 1.5s 8 | Type "cargo run --bin tui " 9 | Sleep 500ms 10 | Type "-- --dir test" 11 | Enter 12 | Sleep 23.5s 13 | Type "jj" 14 | Sleep 1s 15 | Type "j" 16 | Sleep 500ms 17 | Enter 18 | Sleep 2.5s 19 | Type "jjjjjjjjjjjjjjjjjjjjkkkkk" 20 | Escape 21 | Type "k" 22 | Sleep 500ms 23 | Enter 24 | Sleep 3.5s 25 | Type " jjkkjjjkk jjjjjjjjjjjjj jjjjjjjjjjkkjjj" 26 | Sleep 500ms 27 | Type "j " 28 | Sleep 500ms 29 | Enter 30 | Sleep 3s 31 | Escape 32 | Sleep 500ms 33 | Type "k" 34 | Enter 35 | Sleep 500ms 36 | Type "jjkk" 37 | Escape 38 | Type "q" 39 | Sleep 2s 40 | Escape 41 | Type "[A" 42 | Enter 43 | Sleep 10s 44 | Type "jjjk" 45 | Enter 46 | Sleep 2.5s 47 | Type "jjjjkkk" 48 | Escape 49 | Type "k" 50 | Enter 51 | Sleep 500ms 52 | Type "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjg" 53 | Sleep 1.5s 54 | Type "Sjjkk" 55 | Escape 56 | Sleep 1s 57 | Type "L" 58 | Sleep 1s 59 | Type "jjjk" 60 | Sleep 1s 61 | Escape 62 | Sleep 500ms 63 | Type "V" 64 | Sleep 2s 65 | Type "1.21." 66 | Sleep 1s 67 | Type "4" 68 | Enter 69 | Sleep 8s 70 | Type "Mal" 71 | Backspace 3 72 | Sleep 1.5s 73 | Type "Sodium" 74 | Enter 75 | Sleep 1.5s 76 | Type "j j jkjjjjjk" 77 | Sleep 500ms 78 | Type "kkkkkj" 79 | Sleep 5.5s 80 | Escape 81 | Type "hjjjjjjjjjjjjjjjjjjjjjjkkkk" 82 | Sleep 500ms 83 | Type "jkkk" 84 | Sleep 500ms 85 | Type "j" 86 | Sleep 500ms 87 | Type "/" 88 | Backspace 6 89 | Type "Reese" 90 | Enter 91 | Sleep 1.5s 92 | Type "k" 93 | Sleep 500ms 94 | Type " " 95 | Sleep 1s 96 | Enter 97 | Sleep 10.5s 98 | -------------------------------------------------------------------------------- /core/src/curseforge_wrapper/file_utils.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{self, Read}; 3 | use std::path::Path; 4 | 5 | /// Reads the entire content of a file into a byte vector. 6 | /// 7 | /// # Arguments 8 | /// * `jar_file_path` - The path to the file to read. 9 | /// 10 | /// # Returns 11 | /// A `Result` which is `Ok(Vec)` on success, or `Err(std::io::Error)` if an error occurs 12 | /// during file opening or reading. 13 | pub fn get_jar_contents(jar_file_path: &Path) -> io::Result> { 14 | let mut file = File::open(jar_file_path)?; 15 | let mut buffer = Vec::new(); 16 | file.read_to_end(&mut buffer)?; 17 | Ok(buffer) 18 | } 19 | 20 | #[cfg(test)] 21 | mod tests { 22 | use super::*; 23 | use pretty_assertions::assert_eq; 24 | use std::io::Write; 25 | use tempfile::NamedTempFile; 26 | 27 | #[test] 28 | fn test_get_jar_contents() { 29 | let content = b"This is some test content."; 30 | let mut temp_file = NamedTempFile::new().expect("Failed to create temp file"); 31 | temp_file 32 | .write_all(content) 33 | .expect("Failed to write to temp file"); 34 | let path = temp_file.path(); 35 | 36 | let read_content = get_jar_contents(path).expect("Failed to read jar contents"); 37 | assert_eq!(read_content, content); 38 | } 39 | 40 | #[test] 41 | fn test_get_jar_contents_non_existent_file() { 42 | let path = Path::new("non_existent_file.jar"); 43 | let result = get_jar_contents(path); 44 | assert!(result.is_err()); 45 | assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tui/src/logging.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use tracing_error::ErrorLayer; 3 | use tracing_subscriber::{EnvFilter, fmt, prelude::*}; 4 | 5 | use crate::config; 6 | 7 | lazy_static::lazy_static! { 8 | pub static ref LOG_ENV: String = format!("{}_LOG_LEVEL", config::PROJECT_NAME.clone()); 9 | pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); 10 | } 11 | 12 | pub fn init() -> Result<()> { 13 | let directory = config::get_data_dir(); 14 | std::fs::create_dir_all(directory.clone())?; 15 | let log_path = directory.join(LOG_FILE.clone()); 16 | let log_file = std::fs::File::create(log_path)?; 17 | let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::DEBUG.into()); 18 | // If the `RUST_LOG` environment variable is set, use that as the default, otherwise use the 19 | // value of the `LOG_ENV` environment variable. If the `LOG_ENV` environment variable contains 20 | // errors, then this will return an error. 21 | let env_filter = env_filter 22 | .try_from_env() 23 | .or_else(|_| env_filter.with_env_var(LOG_ENV.clone()).from_env())?; 24 | let file_subscriber = fmt::layer() 25 | .with_file(true) 26 | .with_line_number(true) 27 | .with_writer(log_file) 28 | .with_target(false) 29 | .with_ansi(false) 30 | .with_filter(env_filter); 31 | tracing_subscriber::registry() 32 | .with(file_subscriber) 33 | .with(ErrorLayer::default()) 34 | .with(tui_logger::TuiTracingSubscriberLayer) 35 | .try_init()?; 36 | tui_logger::init_logger(tui_logger::LevelFilter::Info).unwrap(); 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /core/README.md: -------------------------------------------------------------------------------- 1 | # Modder Core 2 | 3 | This crate contains the core business logic for the `modder` command-line tool. It handles interactions with modding APIs, file management, and the implementation of the CLI commands. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | cargo install modder --locked 9 | ``` 10 | 11 | ## Features 12 | 13 | - [x] Bulk-update a directory of mods 14 | - [x] Add mods via Modrinth 15 | - [x] Add mods via CurseForge 16 | - [x] Add mods via Github Releases 17 | - [x] Toggle mods in a directory 18 | - [x] List mods with detailed information 19 | - [ ] Support for `modpacks` 20 | 21 | ## Core Functionality 22 | 23 | The crate is structured into several key modules: 24 | 25 | - **`modrinth_wrapper`**: Provides functions for interacting with the Modrinth API (`v2`), including searching for mods, fetching version information, and handling dependencies. 26 | - **`curseforge_wrapper`**: Contains the logic for interacting with the CurseForge API. **Note: This implementation is currently on hold due to API complexity.** 27 | - **`gh_releases`**: Handles fetching release information and downloading mod files from GitHub Repositories. 28 | - **`actions`**: Implements the primary logic for each of the CLI subcommands (e.g., `add`, `update`, `list`). 29 | - **`cli`**: Defines the command-line interface structure, arguments, and subcommands using the `clap` crate. 30 | - **`metadata`**: Manages reading and writing custom metadata to mod JAR files. This is used to track the source of a mod (Modrinth, GitHub, etc.) for future updates. 31 | 32 | ## Usage 33 | 34 | While this crate can be used as a library to build other Minecraft-related tools, its primary purpose is to serve as the engine for the `modder` binary. It is not intended for direct use by end-users. 35 | 36 | ## License 37 | 38 | This project is licensed under the MIT License. 39 | -------------------------------------------------------------------------------- /tui/.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI # Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | 11 | test: 12 | name: Test Suite 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | - name: Install Rust toolchain 18 | uses: dtolnay/rust-toolchain@nightly 19 | - uses: Swatinem/rust-cache@v2 20 | - name: Run tests 21 | run: cargo test --locked --all-features --workspace 22 | 23 | rustfmt: 24 | name: Rustfmt 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | - name: Install Rust toolchain 30 | uses: dtolnay/rust-toolchain@nightly 31 | with: 32 | components: rustfmt 33 | - uses: Swatinem/rust-cache@v2 34 | - name: Check formatting 35 | run: cargo fmt --all --check 36 | 37 | clippy: 38 | name: Clippy 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v4 43 | - name: Install Rust toolchain 44 | uses: dtolnay/rust-toolchain@nightly 45 | with: 46 | components: clippy 47 | - uses: Swatinem/rust-cache@v2 48 | - name: Clippy check 49 | run: cargo clippy --all-targets --all-features --workspace -- -D warnings 50 | 51 | docs: 52 | name: Docs 53 | runs-on: ubuntu-latest 54 | steps: 55 | - name: Checkout repository 56 | uses: actions/checkout@v4 57 | - name: Install Rust toolchain 58 | uses: dtolnay/rust-toolchain@nightly 59 | - uses: Swatinem/rust-cache@v2 60 | - name: Check documentation 61 | env: 62 | RUSTDOCFLAGS: -D warnings 63 | run: cargo doc --no-deps --document-private-items --all-features --workspace --examples 64 | -------------------------------------------------------------------------------- /core/src/curseforge_wrapper/hash.rs: -------------------------------------------------------------------------------- 1 | //! Ported from https://github.com/meza/curseforge-fingerprint/blob/b15012c026c56ca89fad90f8cf9a8e140616e2c0/src/addon/fingerprint.cpp 2 | #![allow(clippy::let_and_return)] 3 | pub struct MurmurHash2; 4 | 5 | const MULTIPLEX: u32 = 1540483477; 6 | 7 | impl MurmurHash2 { 8 | pub fn hash(data: &[u8]) -> u32 { 9 | let n_length = MurmurHash2::normalise(data); 10 | let mut seed = 1_u32 ^ n_length; 11 | let mut num_1 = 0; 12 | let mut num_2 = 0; 13 | for c in data.iter().filter(|&c| !is_whitespace(*c)) { 14 | num_1 |= (*c as u32) << num_2; 15 | num_2 += 8; 16 | if num_2 == 32 { 17 | seed = seed.wrapping_mul(MULTIPLEX) ^ MurmurHash2::mix(num_1); 18 | num_1 = 0; 19 | num_2 = 0; 20 | } 21 | } 22 | if num_2 > 0 { 23 | seed = (seed ^ num_1).wrapping_mul(MULTIPLEX); 24 | } 25 | let hash = (seed ^ (seed >> 13)).wrapping_mul(MULTIPLEX); 26 | hash ^ (hash >> 15) 27 | } 28 | #[inline] 29 | const fn mix(num_1: u32) -> u32 { 30 | let num_3 = num_1.wrapping_mul(MULTIPLEX); 31 | let num_4 = (num_3 ^ (num_3 >> 24)).wrapping_mul(MULTIPLEX); 32 | num_4 33 | } 34 | fn normalise(data: &[u8]) -> u32 { 35 | let mut n_len = 0; 36 | data.iter() 37 | .filter(|&c| !is_whitespace(*c)) 38 | .for_each(|_| n_len += 1); 39 | n_len 40 | } 41 | } 42 | 43 | fn is_whitespace(c: u8) -> bool { 44 | c == b' ' || c == b'\t' || c == b'\r' || c == b'\n' 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use super::*; 50 | use pretty_assertions::assert_eq; 51 | #[test] 52 | fn test_murmur_hash() { 53 | let file = "Hello world"; 54 | let hash = MurmurHash2::hash(file.as_bytes()); 55 | assert_eq!(hash, 1423925525); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "modder_tui" 3 | version = "0.1.5" 4 | edition = "2024" 5 | description = "TUI Wrapper for Modder-rs" 6 | authors = ["JayanAXHF "] 7 | build = "build.rs" 8 | license = "MIT" 9 | homepage = "https://github.com/JayanAXHF/modder-rs" 10 | repository = "https://github.com/JayanAXHF/modder-rs" 11 | readme = "README.md" 12 | keywords = ["minecraft", "mod", "manager", "tui"] 13 | categories = ["command-line-utilities", "games"] 14 | 15 | 16 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 17 | 18 | [dependencies] 19 | better-panic = "0.3.0" 20 | clap = { version = "4.5.20", features = [ 21 | "derive", 22 | "cargo", 23 | "wrap_help", 24 | "unicode", 25 | "string", 26 | "unstable-styles", 27 | ] } 28 | color-eyre = "0.6.3" 29 | config = "0.14.0" 30 | crossterm = { version = "0.29", features = ["serde", "event-stream"] } 31 | derive_deref = "1.1.1" 32 | directories = "5.0.1" 33 | futures = "0.3.31" 34 | human-panic = "2.0.2" 35 | json5 = "0.4.1" 36 | lazy_static = "1.5.0" 37 | libc = "0.2.161" 38 | pretty_assertions = "1.4.1" 39 | ratatui = { version = "0.29.0", features = ["serde", "macros"] } 40 | serde = { version = "1.0.211", features = ["derive"] } 41 | serde_json = "1.0.132" 42 | signal-hook = "0.3.17" 43 | strip-ansi-escapes = "0.2.0" 44 | strum = { version = "0.26.3", features = ["derive"] } 45 | tokio = { version = "1.40.0", features = ["full"] } 46 | tokio-util = "0.7.12" 47 | tracing = "0.1.40" 48 | tracing-error = "0.2.0" 49 | tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } 50 | modder = { path = "../core", version = "1.0.0" } 51 | regex = "1.11.1" 52 | colored = "3.0.0" 53 | textwrap = "0.16.2" 54 | tui-widgets = "0.4.1" 55 | tui-input = { version = "0.12.1", features = ["ratatui"] } 56 | throbber-widgets-tui = "0.8.0" 57 | tui-logger = { version = "0.17.3", features = ["tracing", "tracing-subscriber", "tracing-support"] } 58 | env_logger = "0.11.8" 59 | 60 | 61 | [build-dependencies] 62 | anyhow = "1.0.90" 63 | vergen-gix = { version = "1.0.2", features = ["build", "cargo"] } 64 | 65 | 66 | [patch.crates-io] 67 | crossterm = "0.28.1" 68 | -------------------------------------------------------------------------------- /tui/src/errors.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use color_eyre::Result; 4 | use tracing::error; 5 | 6 | pub fn init() -> Result<()> { 7 | let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() 8 | .panic_section(format!( 9 | "This is a bug. Consider reporting it at {}", 10 | env!("CARGO_PKG_REPOSITORY") 11 | )) 12 | .capture_span_trace_by_default(false) 13 | .display_location_section(false) 14 | .display_env_section(false) 15 | .into_hooks(); 16 | eyre_hook.install()?; 17 | std::panic::set_hook(Box::new(move |panic_info| { 18 | if let Ok(mut t) = crate::tui::Tui::new() { 19 | if let Err(r) = t.exit() { 20 | error!("Unable to exit Terminal: {:?}", r); 21 | } 22 | } 23 | 24 | #[cfg(not(debug_assertions))] 25 | { 26 | use human_panic::{handle_dump, metadata, print_msg}; 27 | let metadata = metadata!(); 28 | let file_path = handle_dump(&metadata, panic_info); 29 | // prints human-panic message 30 | print_msg(file_path, &metadata) 31 | .expect("human-panic: printing error message to console failed"); 32 | eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr 33 | } 34 | let msg = format!("{}", panic_hook.panic_report(panic_info)); 35 | error!("Error: {}", strip_ansi_escapes::strip_str(msg)); 36 | 37 | #[cfg(debug_assertions)] 38 | { 39 | // Better Panic stacktrace that is only enabled when debugging. 40 | better_panic::Settings::auto() 41 | .most_recent_first(false) 42 | .lineno_suffix(true) 43 | .verbosity(better_panic::Verbosity::Full) 44 | .create_panic_handler()(panic_info); 45 | } 46 | 47 | std::process::exit(libc::EXIT_FAILURE); 48 | })); 49 | Ok(()) 50 | } 51 | 52 | /// Similar to the `std::dbg!` macro, but generates `tracing` events rather 53 | /// than printing to stdout. 54 | /// 55 | /// By default, the verbosity level for the generated events is `DEBUG`, but 56 | /// this can be customized. 57 | #[macro_export] 58 | macro_rules! trace_dbg { 59 | (target: $target:expr, level: $level:expr, $ex:expr) => { 60 | { 61 | match $ex { 62 | value => { 63 | tracing::event!(target: $target, $level, ?value, stringify!($ex)); 64 | value 65 | } 66 | } 67 | } 68 | }; 69 | (level: $level:expr, $ex:expr) => { 70 | trace_dbg!(target: module_path!(), level: $level, $ex) 71 | }; 72 | (target: $target:expr, $ex:expr) => { 73 | trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) 74 | }; 75 | ($ex:expr) => { 76 | trace_dbg!(level: tracing::Level::DEBUG, $ex) 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Modder-rs 2 | 3 | [![CI](https://github.com/jayansunil/modder/actions/workflows/rust.yml/badge.svg)](https://github.com/jayansunil/modder/actions/workflows/rust.yml) 4 | 5 | A simple, fast tool for managing Minecraft mods from the command line. 6 | 7 | Modder is a tool for managing mods for Minecraft. It can add mods from Modrinth, CurseForge, and Github Releases. Other features include bulk-updating a directory of mods to a specified version, listing detailed information about the mods in a directory, and toggling mods on or off without deleting the files. 8 | 9 | ```bash 10 | cargo install --locked modder_tui 11 | ``` 12 | 13 | ## Features 14 | 15 | - [x] Bulk-update a directory of mods 16 | - [x] Add mods via Modrinth 17 | - [x] Add mods via CurseForge 18 | - [x] Add mods via Github Releases 19 | - [x] Toggle mods in a directory (enables/disables them by renaming the file extension) 20 | - [x] List mods with details like version, source, and category 21 | - [ ] Support for `modpacks` 22 | 23 | ## Workspace Structure 24 | 25 | This repository is a cargo workspace containing two main crates: 26 | 27 | - `core`: The primary crate that contains all the command-line logic, API wrappers, and file management code. 28 | - `tui`: A work-in-progress crate for a Terminal User Interface (TUI) for `modder`. 29 | 30 | ## Installation 31 | 32 | 1. Ensure you have Rust and Cargo installed. 33 | 2. Clone the repository: 34 | ```sh 35 | git clone https://github.com/jayansunil/modder.git 36 | cd modder 37 | ``` 38 | 3. Install the binary: 39 | ```sh 40 | cargo install --path . 41 | ``` 42 | This will install the `modder` binary in your cargo bin path. 43 | 44 | ## Usage 45 | 46 | Modder provides several commands to manage your mods. 47 | 48 | ### `add` 49 | 50 | Add a mod from Modrinth, CurseForge, or GitHub. 51 | 52 | ```sh 53 | modder add --version --loader 54 | ``` 55 | 56 | - **Example (Modrinth):** 57 | ```sh 58 | modder add sodium --version 1.21 --loader fabric 59 | ``` 60 | - **Example (GitHub):** If the mod is on GitHub, `modder` will infer it. 61 | ```sh 62 | modder add fabricmc/fabric-api --version 1.21 63 | ``` 64 | - **Example (CurseForge):** 65 | ```sh 66 | modder add create --version 1.20.1 --loader forge --source curseforge 67 | ``` 68 | 69 | ### `update` 70 | 71 | Bulk-update all mods in a directory to a specific game version. 72 | 73 | ```sh 74 | modder update --dir ./mods --version 75 | ``` 76 | 77 | - **Example:** 78 | ```sh 79 | modder update --dir ./mods --version 1.21 --delete-previous 80 | ``` 81 | 82 | ### `list` 83 | 84 | List all mods in a directory with detailed information. 85 | 86 | ```sh 87 | modder list [--dir ./mods] [--verbose] 88 | ``` 89 | 90 | - **Example:** 91 | ```sh 92 | modder list --dir ./mods --verbose 93 | ``` 94 | 95 | ### `toggle` 96 | 97 | Enable or disable mods in a directory interactively. 98 | 99 | ```sh 100 | modder toggle [--dir ./mods] 101 | ``` 102 | 103 | ### `quick-add` 104 | 105 | Interactively select from a list of popular mods to add. 106 | 107 | ```sh 108 | modder quick-add --version --loader 109 | ``` 110 | 111 | ## License 112 | 113 | This project is licensed under the MIT License. See the [LICENSE](tui/LICENSE) file for details. 114 | -------------------------------------------------------------------------------- /core/src/gh_releases/structs.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::{fs, path::PathBuf}; 4 | use url::Url; 5 | 6 | use crate::{cli::Source, metadata::Metadata}; 7 | 8 | use super::Error; 9 | 10 | type Result = std::result::Result; 11 | 12 | #[derive(Debug, Serialize, Deserialize, Clone)] 13 | pub struct Release { 14 | pub url: Url, 15 | pub html_url: Url, 16 | pub assets_url: Url, 17 | pub upload_url: String, 18 | pub tarball_url: Option, 19 | pub zipball_url: Option, 20 | pub id: u64, 21 | pub node_id: String, 22 | pub tag_name: String, 23 | pub target_commitish: String, 24 | pub name: Option, 25 | pub body: Option, 26 | pub draft: bool, 27 | pub prerelease: bool, 28 | pub created_at: DateTime, 29 | pub published_at: Option>, 30 | pub author: User, 31 | pub assets: Vec, 32 | pub body_html: Option, 33 | pub body_text: Option, 34 | pub mentions_count: Option, 35 | pub discussion_url: Option, 36 | pub reactions: Option, 37 | } 38 | 39 | #[derive(Debug, Serialize, Deserialize, Clone)] 40 | pub struct User { 41 | pub name: Option, 42 | pub email: Option, 43 | pub login: String, 44 | pub id: u64, 45 | pub node_id: String, 46 | pub avatar_url: Url, 47 | pub gravatar_id: Option, 48 | pub url: Url, 49 | pub html_url: Url, 50 | pub followers_url: Url, 51 | pub following_url: String, 52 | pub gists_url: String, 53 | pub starred_url: Option, 54 | pub subscriptions_url: Url, 55 | pub organizations_url: Url, 56 | pub repos_url: Url, 57 | pub events_url: String, 58 | pub received_events_url: Url, 59 | pub r#type: String, 60 | pub site_admin: bool, 61 | pub starred_at: Option, 62 | pub user_view_type: String, 63 | } 64 | 65 | #[derive(Debug, Serialize, Deserialize, Clone)] 66 | pub struct ReleaseAsset { 67 | pub url: Url, 68 | pub browser_download_url: Url, 69 | pub id: u64, 70 | pub node_id: String, 71 | pub name: String, 72 | pub label: Option, 73 | pub state: AssetState, 74 | pub content_type: String, 75 | pub size: i64, 76 | pub digest: Option, 77 | pub download_count: i64, 78 | pub created_at: DateTime, 79 | pub updated_at: DateTime, 80 | pub uploader: Option, 81 | } 82 | 83 | #[derive(Debug, Serialize, Deserialize, Clone)] 84 | #[serde(rename_all = "lowercase")] 85 | pub enum AssetState { 86 | Uploaded, 87 | Open, 88 | } 89 | 90 | #[derive(Debug, Serialize, Deserialize, Clone)] 91 | pub struct ReactionRollup { 92 | pub url: Url, 93 | pub total_count: i64, 94 | #[serde(rename = "+1")] 95 | pub plus_one: i64, 96 | #[serde(rename = "-1")] 97 | pub minus_one: i64, 98 | pub laugh: i64, 99 | pub confused: i64, 100 | pub heart: i64, 101 | pub hooray: i64, 102 | pub eyes: i64, 103 | pub rocket: i64, 104 | } 105 | 106 | impl ReleaseAsset { 107 | pub fn get_download_url(&self) -> Option { 108 | Some(self.browser_download_url.clone()) 109 | } 110 | pub async fn download(&self, path: PathBuf, repo: String) -> Result<()> { 111 | let url = self.get_download_url().expect("Asset has no download url"); 112 | let file_content = reqwest::get(url.clone()).await.unwrap(); 113 | fs::write(&path, file_content.bytes().await.unwrap())?; 114 | let handle = tokio::spawn(async move { 115 | // Adds metadata to the file for later use with `update` option 116 | Metadata::add_metadata(path.clone(), Source::Github, "repo", &repo).unwrap(); 117 | }); 118 | handle.await.unwrap(); 119 | Ok(()) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /core/src/gh_releases/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::UrlBuilder; 2 | mod structs; 3 | 4 | const GH_RELEASES_API: &str = "https://api.github.com/repos"; 5 | 6 | #[derive(Default, Clone)] 7 | pub struct GHReleasesAPI { 8 | pub client: reqwest::Client, 9 | pub token: Option>, 10 | } 11 | 12 | #[derive(thiserror::Error, Debug)] 13 | pub enum Error { 14 | #[error("Error sending the request. This may mean that the request was malformed: {0:?}")] 15 | Reqwest(#[from] reqwest::Error), 16 | #[error("Error deserializing the response: {0:?}")] 17 | Serde(#[from] serde_json::Error), 18 | #[error("No releases found")] 19 | NoReleases, 20 | #[error("Authorization failed: {0}")] 21 | AuthFailed(String), 22 | #[error("Mod not found for the particular game version or loader")] 23 | ModNotFound, 24 | #[error("Error writing the mod to a file: {0}")] 25 | WriteFileErr(#[from] std::io::Error), 26 | #[error("Unknown error: {0}")] 27 | UnknownError(#[from] color_eyre::eyre::Report), 28 | } 29 | type Result = std::result::Result; 30 | 31 | impl GHReleasesAPI { 32 | pub fn new() -> Self { 33 | Self { 34 | client: reqwest::Client::new(), 35 | token: None, 36 | } 37 | } 38 | pub fn token(&mut self, token: String) { 39 | self.token = Some(token.into_boxed_str()); 40 | } 41 | #[tracing::instrument(level = "info", skip(self))] 42 | pub async fn get_releases(&self, owner: &str, repo: &str) -> Result> { 43 | let url = UrlBuilder::new(GH_RELEASES_API, &format!("/{}/{}/releases", owner, repo)); 44 | let mut headers = reqwest::header::HeaderMap::new(); 45 | let response = self.client.get(url.to_string()); 46 | if let Some(token) = self.token.as_ref() { 47 | headers.insert( 48 | reqwest::header::AUTHORIZATION, 49 | reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), 50 | ); 51 | } 52 | headers.insert( 53 | reqwest::header::USER_AGENT, 54 | reqwest::header::HeaderValue::from_static("modder-rs"), 55 | ); 56 | let response = response.headers(headers).send().await?; 57 | let response = match response.error_for_status() { 58 | Ok(response) => Ok(response), 59 | Err(e) => { 60 | let code = e.status().unwrap().as_u16(); 61 | if code == 401 || code == 403 { 62 | Err(Error::AuthFailed(e.to_string())) 63 | } else { 64 | return Err(Error::Reqwest(e)); 65 | } 66 | } 67 | }?; 68 | let res_text: String = response.text().await?; 69 | let releases: Vec = serde_json::from_str(&res_text)?; 70 | if releases.is_empty() { 71 | return Err(Error::NoReleases); 72 | } 73 | 74 | Ok(releases) 75 | } 76 | } 77 | 78 | pub async fn get_mod_from_release( 79 | releases: &[structs::Release], 80 | loader: &str, 81 | version: &str, 82 | ) -> Result { 83 | let mut found = false; 84 | let mut asset_found = None; 85 | let correct_asset = { 86 | for release in releases { 87 | let asset = release 88 | .assets 89 | .iter() 90 | .find(|asset| asset.name.contains(loader) && asset.name.contains(version)); 91 | if asset.is_some() { 92 | found = true; 93 | asset_found = asset; 94 | break; 95 | } 96 | } 97 | if found { asset_found } else { None } 98 | }; 99 | match correct_asset { 100 | Some(release) => Ok(release.clone()), 101 | None => Err(Error::ModNotFound), 102 | } 103 | } 104 | 105 | #[cfg(test)] 106 | mod tests { 107 | use super::*; 108 | #[tokio::test] 109 | async fn test_get_mod_from_release() { 110 | let gh_api = GHReleasesAPI::new(); 111 | let releases = gh_api.get_releases("fabricmc", "fabric").await.unwrap(); 112 | let r1_21_4 = get_mod_from_release(&releases, "fabric", "1.21.4").await; 113 | println!("{:#?}", r1_21_4); 114 | assert!(r1_21_4.is_ok()); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /tui/src/components.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use crossterm::event::{KeyEvent, MouseEvent}; 3 | use ratatui::{ 4 | Frame, 5 | layout::{Rect, Size}, 6 | }; 7 | use tokio::sync::mpsc::UnboundedSender; 8 | 9 | use crate::{action::Action, app::Mode, config::Config, tui::Event}; 10 | 11 | pub mod add; 12 | pub mod home; 13 | pub mod list; 14 | pub mod toggle; 15 | 16 | /// `Component` is a trait that represents a visual and interactive element of the user interface. 17 | /// 18 | /// Implementors of this trait can be registered with the main application loop and will be able to 19 | /// receive events, update state, and be rendered on the screen. 20 | pub trait Component { 21 | /// Register an action handler that can send actions for processing if necessary. 22 | /// 23 | /// # Arguments 24 | /// 25 | /// * `tx` - An unbounded sender that can send actions. 26 | /// 27 | /// # Returns 28 | /// 29 | /// * `Result<()>` - An Ok result or an error. 30 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 31 | let _ = tx; // to appease clippy 32 | Ok(()) 33 | } 34 | /// Register a configuration handler that provides configuration settings if necessary. 35 | /// 36 | /// # Arguments 37 | /// 38 | /// * `config` - Configuration settings. 39 | /// 40 | /// # Returns 41 | /// 42 | /// * `Result<()>` - An Ok result or an error. 43 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 44 | let _ = config; // to appease clippy 45 | Ok(()) 46 | } 47 | /// Initialize the component with a specified area if necessary. 48 | /// 49 | /// # Arguments 50 | /// 51 | /// * `area` - Rectangular area to initialize the component within. 52 | /// 53 | /// # Returns 54 | /// 55 | /// * `Result<()>` - An Ok result or an error. 56 | fn init(&mut self, area: Size) -> Result<()> { 57 | let _ = area; // to appease clippy 58 | Ok(()) 59 | } 60 | /// Handle incoming events and produce actions if necessary. 61 | /// 62 | /// # Arguments 63 | /// 64 | /// * `event` - An optional event to be processed. 65 | /// 66 | /// # Returns 67 | /// 68 | /// * `Result>` - An action to be processed or none. 69 | fn handle_events(&mut self, event: Option) -> Result> { 70 | let action = match event { 71 | Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, 72 | Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, 73 | _ => None, 74 | }; 75 | Ok(action) 76 | } 77 | /// Handle key events and produce actions if necessary. 78 | /// 79 | /// # Arguments 80 | /// 81 | /// * `key` - A key event to be processed. 82 | /// 83 | /// # Returns 84 | /// 85 | /// * `Result>` - An action to be processed or none. 86 | fn handle_key_event(&mut self, key: KeyEvent) -> Result> { 87 | let _ = key; // to appease clippy 88 | Ok(None) 89 | } 90 | /// Handle mouse events and produce actions if necessary. 91 | /// 92 | /// # Arguments 93 | /// 94 | /// * `mouse` - A mouse event to be processed. 95 | /// 96 | /// # Returns 97 | /// 98 | /// * `Result>` - An action to be processed or none. 99 | fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result> { 100 | let _ = mouse; // to appease clippy 101 | Ok(None) 102 | } 103 | /// Update the state of the component based on a received action. (REQUIRED) 104 | /// 105 | /// # Arguments 106 | /// 107 | /// * `action` - An action that may modify the state of the component. 108 | /// 109 | /// # Returns 110 | /// 111 | /// * `Result>` - An action to be processed or none. 112 | fn update(&mut self, action: Action) -> Result> { 113 | let _ = action; // to appease clippy 114 | Ok(None) 115 | } 116 | /// Render the component on the screen. (REQUIRED) 117 | /// 118 | /// # Arguments 119 | /// 120 | /// * `f` - A frame used for rendering. 121 | /// * `area` - The area in which the component should be drawn. 122 | /// 123 | /// # Returns 124 | /// 125 | /// * `Result<()>` - An Ok result or an error. 126 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>; 127 | /// Gets the mode for which the component is rendered. 128 | fn get_mode(&self) -> Mode; 129 | } 130 | -------------------------------------------------------------------------------- /core/src/metadata.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use std::{ 3 | collections::HashMap, 4 | env::temp_dir, 5 | fs::{self, File}, 6 | io::{Cursor, Read, Write}, 7 | path::PathBuf, 8 | }; 9 | use zip::{ZipWriter, write::FileOptions}; 10 | 11 | use crate::cli::Source; 12 | 13 | pub struct Metadata; 14 | 15 | #[derive(thiserror::Error, Debug)] 16 | pub enum Error { 17 | #[error("Error reading or writing the metadata file: {0}")] 18 | IOErr(#[from] std::io::Error), 19 | #[error("Error unarchiving the jar file: {0}")] 20 | Unzip(#[from] zip::result::ZipError), 21 | #[error("Error parsing the metadata file into UTF-8 String: {0}")] 22 | ParseErr(#[from] std::string::FromUtf8Error), 23 | #[error("No key found")] 24 | NoKeyFound, 25 | #[error("Error deserializing the metadata file: {0}")] 26 | SerdeErr(#[from] serde_json::Error), 27 | } 28 | 29 | type Result = std::result::Result; 30 | 31 | impl Metadata { 32 | pub fn add_metadata(path: PathBuf, source: Source, key: &str, value: &str) -> Result<()> { 33 | let mut file = File::open(path.clone())?; 34 | let mut buffer = Vec::new(); 35 | file.read_to_end(&mut buffer)?; 36 | let mut zip = zip::ZipArchive::new(Cursor::new(buffer))?; 37 | let metadata = format!("source: {}\n{}: {}", source, key, value); 38 | let tmp_file_path = temp_dir().join("temp.jar"); 39 | let mut tmp_file = File::create(tmp_file_path.clone())?; 40 | let mut zipwriter = ZipWriter::new(&mut tmp_file); 41 | let options: FileOptions<()> = FileOptions::default(); 42 | for i in 0..zip.len() { 43 | let mut file = zip.by_index(i)?; 44 | let mut contents = Vec::new(); 45 | file.read_to_end(&mut contents)?; 46 | 47 | zipwriter.start_file(file.name(), options)?; 48 | zipwriter.write_all(&contents)?; 49 | } 50 | zipwriter.start_file("META-INF/MODDER-RS.MF", options)?; 51 | zipwriter.write_all(metadata.as_bytes())?; 52 | zipwriter.finish()?; 53 | fs::copy(tmp_file_path.clone(), path.clone()).unwrap(); 54 | fs::remove_file(tmp_file_path).unwrap(); 55 | 56 | Ok(()) 57 | } 58 | 59 | pub fn get_source(path: PathBuf) -> Result { 60 | let mut file = File::open(path)?; 61 | let mut buffer = Vec::new(); 62 | file.read_to_end(&mut buffer)?; 63 | let mut zip = zip::ZipArchive::new(Cursor::new(buffer))?; 64 | let mut metadata = zip.by_name("META-INF/MODDER-RS.MF")?; 65 | let mut contents = Vec::new(); 66 | metadata.read_to_end(&mut contents)?; 67 | let metadata = String::from_utf8(contents)?; 68 | let source = metadata 69 | .lines() 70 | .find(|l| l.split(":").next().unwrap_or("") == "source") 71 | .unwrap() 72 | .split(":") 73 | .collect_vec()[1]; 74 | Ok(source.try_into().unwrap_or(Source::Modrinth)) 75 | } 76 | pub fn get_kv(path: PathBuf, key: &str) -> Result { 77 | let mut file = File::open(path)?; 78 | let mut buffer = Vec::new(); 79 | file.read_to_end(&mut buffer)?; 80 | let mut zip = zip::ZipArchive::new(Cursor::new(buffer))?; 81 | let mut metadata = zip.by_name("META-INF/MODDER-RS.MF")?; 82 | let mut contents = Vec::new(); 83 | metadata.read_to_end(&mut contents)?; 84 | let metadata = String::from_utf8(contents)?; 85 | let kv = metadata 86 | .lines() 87 | .find(|l| l.split(":").next().unwrap_or("") == key); 88 | match kv { 89 | Some(kv) => Ok(kv.split(":").collect_vec()[1].to_string()), 90 | None => Err(Error::NoKeyFound), 91 | } 92 | } 93 | pub fn get_all_metadata(path: PathBuf) -> Result> { 94 | let mut file = File::open(path)?; 95 | let mut buffer = Vec::new(); 96 | file.read_to_end(&mut buffer)?; 97 | let mut zip = zip::ZipArchive::new(Cursor::new(buffer))?; 98 | let mut metadata = zip.by_name("META-INF/MODDER-RS.MF")?; 99 | let mut contents = Vec::new(); 100 | metadata.read_to_end(&mut contents)?; 101 | let metadata = String::from_utf8(contents)?; 102 | let hashmap = metadata 103 | .lines() 104 | .map(|l| { 105 | let split = l.split(":").map(str::trim).collect_vec(); 106 | (split[0].to_string(), split[1].to_string()) 107 | }) 108 | .collect::>(); 109 | Ok(hashmap) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tui/.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD # Continuous Deployment 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[v]?[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | publish: 10 | 11 | name: Publishing for ${{ matrix.os }} 12 | runs-on: ${{ matrix.os }} 13 | 14 | strategy: 15 | matrix: 16 | include: 17 | - os: macos-latest 18 | os-name: macos 19 | target: x86_64-apple-darwin 20 | architecture: x86_64 21 | binary-postfix: "" 22 | binary-name: tui 23 | use-cross: false 24 | - os: macos-latest 25 | os-name: macos 26 | target: aarch64-apple-darwin 27 | architecture: arm64 28 | binary-postfix: "" 29 | use-cross: false 30 | binary-name: tui 31 | - os: ubuntu-latest 32 | os-name: linux 33 | target: x86_64-unknown-linux-gnu 34 | architecture: x86_64 35 | binary-postfix: "" 36 | use-cross: false 37 | binary-name: tui 38 | - os: windows-latest 39 | os-name: windows 40 | target: x86_64-pc-windows-msvc 41 | architecture: x86_64 42 | binary-postfix: ".exe" 43 | use-cross: false 44 | binary-name: tui 45 | - os: ubuntu-latest 46 | os-name: linux 47 | target: aarch64-unknown-linux-gnu 48 | architecture: arm64 49 | binary-postfix: "" 50 | use-cross: true 51 | binary-name: tui 52 | - os: ubuntu-latest 53 | os-name: linux 54 | target: i686-unknown-linux-gnu 55 | architecture: i686 56 | binary-postfix: "" 57 | use-cross: true 58 | binary-name: tui 59 | 60 | steps: 61 | - name: Checkout repository 62 | uses: actions/checkout@v4 63 | - name: Install Rust toolchain 64 | uses: actions-rs/toolchain@v1 65 | with: 66 | toolchain: stable 67 | 68 | target: ${{ matrix.target }} 69 | 70 | profile: minimal 71 | override: true 72 | - uses: Swatinem/rust-cache@v2 73 | - name: Cargo build 74 | uses: actions-rs/cargo@v1 75 | with: 76 | command: build 77 | 78 | use-cross: ${{ matrix.use-cross }} 79 | 80 | toolchain: stable 81 | 82 | args: --release --target ${{ matrix.target }} 83 | 84 | 85 | - name: install strip command 86 | shell: bash 87 | run: | 88 | 89 | if [[ ${{ matrix.target }} == aarch64-unknown-linux-gnu ]]; then 90 | 91 | sudo apt update 92 | sudo apt-get install -y binutils-aarch64-linux-gnu 93 | fi 94 | - name: Packaging final binary 95 | shell: bash 96 | run: | 97 | 98 | cd target/${{ matrix.target }}/release 99 | 100 | 101 | ####### reduce binary size by removing debug symbols ####### 102 | 103 | BINARY_NAME=${{ matrix.binary-name }}${{ matrix.binary-postfix }} 104 | if [[ ${{ matrix.target }} == aarch64-unknown-linux-gnu ]]; then 105 | 106 | GCC_PREFIX="aarch64-linux-gnu-" 107 | else 108 | GCC_PREFIX="" 109 | fi 110 | "$GCC_PREFIX"strip $BINARY_NAME 111 | 112 | ########## create tar.gz ########## 113 | 114 | RELEASE_NAME=${{ matrix.binary-name }}-${GITHUB_REF/refs\/tags\//}-${{ matrix.os-name }}-${{ matrix.architecture }} 115 | 116 | tar czvf $RELEASE_NAME.tar.gz $BINARY_NAME 117 | 118 | ########## create sha256 ########## 119 | 120 | if [[ ${{ runner.os }} == 'Windows' ]]; then 121 | 122 | certutil -hashfile $RELEASE_NAME.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.sha256 123 | else 124 | shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.sha256 125 | fi 126 | - name: Releasing assets 127 | uses: softprops/action-gh-release@v1 128 | with: 129 | files: | 130 | 131 | target/${{ matrix.target }}/release/${{ matrix.binary-name }}-*.tar.gz 132 | target/${{ matrix.target }}/release/${{ matrix.binary-name }}-*.sha256 133 | 134 | env: 135 | 136 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 137 | 138 | 139 | publish-cargo: 140 | name: Publishing to Cargo 141 | runs-on: ubuntu-latest 142 | steps: 143 | - name: Checkout repository 144 | uses: actions/checkout@v4 145 | - name: Install Rust toolchain 146 | uses: dtolnay/rust-toolchain@stable 147 | - uses: Swatinem/rust-cache@v2 148 | - run: cargo publish 149 | env: 150 | 151 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 152 | 153 | -------------------------------------------------------------------------------- /tui/README.md: -------------------------------------------------------------------------------- 1 | # Modder TUI 2 | 3 | A Terminal User Interface for modder-rs, a command-line Minecraft mod manager. 4 | 5 | ## Features 6 | 7 | * List installed mods 8 | * Enable and disable mods 9 | * Add new mods from Modrinth or GitHub 10 | * Search for mods 11 | * View mod details 12 | 13 | ![How it Looks](https://vhs.charm.sh/vhs-aqUn5U8TEFwtZNIA48IDR.gif) 14 | 15 | ## Installation 16 | ### Cargo 17 | ```sh 18 | cargo install modder_tui --locked 19 | ``` 20 | ### Manual 21 | 1. Clone the repository: 22 | ```bash 23 | git clone https://github.com/JayanAXHF/modder-rs.git 24 | ``` 25 | 2. Navigate to the `tui` directory: 26 | ```bash 27 | cd modder-rs/tui 28 | ``` 29 | 3. Build the project: 30 | ```bash 31 | cargo build --release 32 | ``` 33 | 4. Run the application: 34 | ```bash 35 | ./target/release/tui --dir /path/to/your/mods 36 | ``` 37 | 38 | ## Usage 39 | 40 | The application is divided into several modes, each with its own set of keybindings and functionality. 41 | 42 | ### Home 43 | 44 | The default mode, which displays a menu of available actions. 45 | 46 | ### List 47 | 48 | Displays a list of all installed mods in the specified directory. You can view details for each mod. 49 | 50 | ### Toggle 51 | 52 | Allows you to enable or disable mods by renaming the mod files (e.g., `mod.jar` to `mod.jar.disabled`). 53 | 54 | ### Add 55 | 56 | Search for and download new mods from Modrinth or GitHub. 57 | 58 | ## Keybindings 59 | 60 | ### Global 61 | 62 | * `Ctrl-c`, `Ctrl-d`, `q`: Quit the application 63 | * `Ctrl-z`: Suspend the application 64 | 65 | ### Home 66 | 67 | * `j` or `Down Arrow`: Select next item 68 | * `k` or `Up Arrow`: Select previous item 69 | * `g` or `Home`: Select first item 70 | * `G` or `End`: Select last item 71 | * `Enter`: Select the highlighted mode and switch to it 72 | 73 | ### List 74 | 75 | * `h` or `Left Arrow`: Deselect item 76 | * `j` or `Down Arrow`: Select next item 77 | * `k` or `Up Arrow`: Select previous item 78 | * `g` or `Home`: Select first item 79 | * `G` or `End`: Select last item 80 | * `/`: Enter Search mode 81 | * `Esc`: Go back to Home 82 | 83 | #### Search Mode 84 | 85 | * `Tab` or `Esc`: Exit Search mode 86 | * Any other key: Updates the search query 87 | 88 | ### Toggle 89 | 90 | * `h` or `Left Arrow`: Deselect item 91 | * `j` or `Down Arrow`: Select next item 92 | * `k` or `Up Arrow`: Select previous item 93 | * `g` or `Home`: Select first item 94 | * `G` or `End`: Select last item 95 | * `Space`: Toggle whether a mod is enabled or disabled 96 | * `Enter`: Apply the changes (rename files) 97 | * `/`: Enter Search mode 98 | * `Esc`: Go back to Home 99 | 100 | #### Search Mode 101 | 102 | * `Tab` or `Esc`: Exit Search mode 103 | * Any other key: Updates the search query 104 | 105 | ### Add 106 | 107 | * `h` or `Left Arrow`: Deselect item in the current mods list 108 | * `j` or `Down Arrow`: Select next item in the current mods list 109 | * `k` or `Up Arrow`: Select previous item in the current mods list 110 | * `g` or `Home`: Select first item in the current mods list 111 | * `G` or `End`: Select last item in the current mods list 112 | * `S`: Change source (Modrinth/Github) 113 | * `R`: View search results 114 | * `V`: Enter version 115 | * `/`: Enter search mode 116 | * `l`: View search results 117 | * `J` or `s`: View selected mods 118 | * `L`: Change loader 119 | * `Esc`: Go back to Home 120 | 121 | #### Search Mode 122 | 123 | * `Tab` or `Esc`: Exit search mode 124 | * `Enter`: Perform search 125 | * Any other key: Updates the search query 126 | 127 | #### Toggle Source Mode 128 | 129 | * `h` or `Left Arrow`: Deselect source 130 | * `j` or `Down Arrow`: Select next source 131 | * `k` or `Up Arrow`: Select previous source 132 | * `g` or `Home`: Select first source 133 | * `G` or `End`: Select last source 134 | * `Enter`: Perform search if version, search query and loader are set 135 | * `Esc`: Go back to Normal mode 136 | 137 | #### Change Loader Mode 138 | 139 | * `Tab` or `Esc`: Go back to Normal mode 140 | * `Enter`: Perform search if version and search query are set, otherwise go to search mode 141 | * `h` or `Left Arrow`: Deselect loader 142 | * `j` or `Down Arrow`: Select next loader 143 | * `k` or `Up Arrow`: Select previous loader 144 | * `g` or `Home`: Select first loader 145 | * `G` or `End`: Select last loader 146 | 147 | #### Version Input Mode 148 | 149 | * `Tab` or `Esc`: Go back to Normal mode 150 | * `Enter`: Perform search if version, search query and loader are set, otherwise go to search mode 151 | * Any other key: Updates the version input 152 | 153 | #### Search Result List Mode 154 | 155 | * `h` or `Left Arrow`: Deselect item 156 | * `j` or `Down Arrow`: Select next item 157 | * `k` or `Up Arrow`: Select previous item 158 | * `g` or `Home`: Select first item 159 | * `G` or `End`: Select last item 160 | * `Space`: Toggle selection of a mod 161 | * `Enter`: Download selected mods 162 | * `Esc`: Go back to Normal mode 163 | 164 | #### Selected List Mode 165 | 166 | * `j` or `Down Arrow`: Select next item 167 | * `k` or `Up Arrow`: Select previous item 168 | * `g` or `Home`: Select first item 169 | * `G` or `End`: Select last item 170 | * `J`: Go to Version Input mode 171 | * `Esc`: Go back to Normal mode 172 | -------------------------------------------------------------------------------- /tui/src/components/home.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use crossterm::event::KeyCode; 3 | use layout::Flex; 4 | use ratatui::{prelude::*, widgets::*}; 5 | use style::palette::tailwind::SLATE; 6 | use tokio::sync::mpsc::UnboundedSender; 7 | 8 | use super::Component; 9 | use crate::{action::Action, app::Mode, config::Config}; 10 | 11 | fn center(area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect { 12 | let [area] = Layout::horizontal([horizontal]) 13 | .flex(Flex::Center) 14 | .areas(area); 15 | let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area); 16 | area 17 | } 18 | 19 | #[derive(Default)] 20 | pub struct Home { 21 | command_tx: Option>, 22 | config: Config, 23 | list: MenuList, 24 | mode: Mode, 25 | enabled: bool, 26 | } 27 | 28 | #[derive(Debug, Clone, Default)] 29 | struct MenuList { 30 | list_items: Vec, 31 | state: ListState, 32 | } 33 | 34 | #[derive(Debug, Clone, Default)] 35 | struct MenuListItem { 36 | mode: Mode, 37 | } 38 | 39 | const SELECTED_STYLE: Style = Style::new().bg(SLATE.c800).add_modifier(Modifier::BOLD); 40 | 41 | impl FromIterator for MenuList { 42 | fn from_iter>(iter: I) -> Self { 43 | let items = iter.into_iter().map(MenuListItem::new).collect(); 44 | let state = ListState::default(); 45 | Self { 46 | list_items: items, 47 | state, 48 | } 49 | } 50 | } 51 | impl MenuListItem { 52 | fn new(mode: Mode) -> Self { 53 | Self { mode } 54 | } 55 | } 56 | 57 | impl MenuList { 58 | fn select_none(&mut self) { 59 | self.state.select(None); 60 | } 61 | 62 | fn select_next(&mut self) { 63 | self.state.select_next(); 64 | } 65 | fn select_previous(&mut self) { 66 | self.state.select_previous(); 67 | } 68 | 69 | fn select_first(&mut self) { 70 | self.state.select_first(); 71 | } 72 | 73 | fn select_last(&mut self) { 74 | self.state.select_last(); 75 | } 76 | } 77 | 78 | impl Home { 79 | pub fn new() -> Self { 80 | Home { 81 | list: MenuList::from_iter(vec![Mode::Add, Mode::Toggle, Mode::List]), 82 | mode: Mode::Home, 83 | enabled: true, 84 | ..Default::default() 85 | } 86 | } 87 | } 88 | 89 | impl Component for Home { 90 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 91 | self.command_tx = Some(tx); 92 | Ok(()) 93 | } 94 | fn get_mode(&self) -> Mode { 95 | self.mode 96 | } 97 | 98 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 99 | self.config = config; 100 | Ok(()) 101 | } 102 | 103 | fn update(&mut self, action: Action) -> Result> { 104 | match action { 105 | Action::Tick => {} 106 | Action::Render => { 107 | // add any logic here that should run on every render 108 | } 109 | Action::Mode(mode) => { 110 | self.enabled = mode == Mode::Home; 111 | } 112 | _ => {} 113 | } 114 | Ok(None) 115 | } 116 | fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) -> Result> { 117 | if !self.enabled { 118 | return Ok(None); 119 | } 120 | match key.code { 121 | KeyCode::Char('h') | KeyCode::Left => self.list.select_none(), 122 | KeyCode::Char('j') | KeyCode::Down => self.list.select_next(), 123 | KeyCode::Char('k') | KeyCode::Up => self.list.select_previous(), 124 | KeyCode::Char('g') | KeyCode::Home => self.list.select_first(), 125 | KeyCode::Char('G') | KeyCode::End => self.list.select_last(), 126 | KeyCode::Enter => { 127 | self.command_tx.as_ref().unwrap().send(Action::Mode( 128 | self.list.list_items[self.list.state.selected().unwrap_or(0)].mode, 129 | ))?; 130 | return Ok(None); 131 | } 132 | KeyCode::Char('q') => return Ok(Some(Action::Quit)), 133 | _ => {} 134 | }; 135 | Ok(None) 136 | } 137 | 138 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 139 | let items: Vec = self.list.list_items.iter().map(ListItem::from).collect(); 140 | let list = List::new(items) 141 | .highlight_style(SELECTED_STYLE) 142 | .highlight_symbol(">") 143 | .highlight_spacing(HighlightSpacing::Always) 144 | .scroll_padding(5) 145 | .block( 146 | Block::new() 147 | .padding(Padding::uniform(1)) 148 | .title_top(Line::raw("Modes").centered().bold()), 149 | ); 150 | 151 | let center_area = center( 152 | area, 153 | Constraint::Percentage(15), 154 | Constraint::Length(7), // top and bottom border + content 155 | ); 156 | frame.render_stateful_widget(list, center_area, &mut self.list.state); 157 | 158 | Ok(()) 159 | } 160 | } 161 | 162 | impl From<&MenuListItem> for ListItem<'_> { 163 | fn from(value: &MenuListItem) -> Self { 164 | ListItem::new(value.mode.to_string()) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /core/src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | use itertools::Itertools; 3 | use std::{fmt::Display, path::PathBuf, sync::LazyLock}; 4 | use strum::{EnumIter, IntoEnumIterator}; 5 | 6 | use crate::ModLoader; 7 | pub static SOURCES: LazyLock> = LazyLock::new(|| Source::iter().collect_vec()); 8 | /// Modder is a tool for managing mods for Minecraft. 9 | /// It can add mods from Modrinth and Github. 10 | /// Other features include bulk-updating a directory of mods to a specified version 11 | /// and listing information about the mods in a directory. 12 | /// The `toggle` feature allows you to enable or disable 13 | /// mods in a directory without having to remove them. 14 | /// 15 | /// Modder is still in development and may have bugs. 16 | /// Please report any issues on the GitHub repository. 17 | /// 18 | /// 19 | /// Developed by JayanAXHF 20 | #[derive(Debug, Parser)] 21 | pub struct Cli { 22 | #[command(subcommand)] 23 | pub command: Commands, 24 | /// Whether to print the output to the console. If `false`, only error messages will be printed 25 | #[arg(short, long, default_value_t = false)] 26 | pub silent: bool, 27 | } 28 | 29 | #[derive(Debug, Subcommand)] 30 | pub enum Commands { 31 | /// Add a mod to the supplied directory (defaults to current directory) 32 | #[command(arg_required_else_help = true)] 33 | Add { 34 | /// The mod name 35 | #[arg(required = true)] 36 | mod_: String, 37 | /// The game version to add this mod for 38 | #[arg(short, long)] 39 | version: Option, 40 | /// Where to download the mod from 41 | #[arg(short, long)] 42 | source: Option, 43 | /// Github token for any mods nested in a github repo. 44 | #[arg(short, long)] 45 | token: Option, 46 | /// Mod Loader 47 | #[arg(short, long, default_value_t= ModLoader::Fabric)] 48 | loader: ModLoader, 49 | /// The directory to update mods in 50 | #[arg( default_value_os_t = PathBuf::from("./"))] 51 | dir: PathBuf, 52 | }, 53 | /// Bulk-update a directory of mods to the specified version 54 | #[command(arg_required_else_help = true)] 55 | Update { 56 | /// The directory to update mods in 57 | #[arg( default_value_os_t = PathBuf::from("./"))] 58 | dir: PathBuf, 59 | /// The game version to add this mod for 60 | #[arg(short, long)] 61 | version: Option, 62 | #[arg(short, long)] 63 | delete_previous: bool, 64 | /// Github token for any mods nested in a github repo. 65 | #[arg(short, long)] 66 | token: Option, 67 | /// Where to download the mod from 68 | #[arg(short, long)] 69 | source: Option, 70 | /// Don't check other sources if the mod is not found on 71 | #[arg(long, default_value_t = true)] 72 | other_sources: bool, 73 | #[arg(short, long)] 74 | loader: Option, 75 | }, 76 | /// Quickly add mods from a curated list to the supplied directory (defaults to current directory) 77 | QuickAdd { 78 | /// The game version to add this mod for 79 | #[arg(short, long)] 80 | version: Option, 81 | /// Find top `limit` mods from Modrinth 82 | #[arg(short, long, default_value_t = 100)] 83 | limit: u16, 84 | /// The mod loader to use 85 | #[arg(short, long, default_value_t = ModLoader::Fabric)] 86 | loader: ModLoader, 87 | }, 88 | /// Toggle a mod in the supplied directory (defaults to current directory) 89 | Toggle { 90 | /// The game version to add this mod for 91 | #[arg(short, long)] 92 | version: Option, 93 | /// The directory to toggle mods in 94 | #[arg(short, long, default_value_os_t = PathBuf::from("./"))] 95 | dir: PathBuf, 96 | }, 97 | /// List all the mods in the supplied directory (defaults to current directory) 98 | List { 99 | /// The directory to list mods in 100 | #[arg(default_value_os_t = PathBuf::from("./"))] 101 | dir: PathBuf, 102 | /// Whether to print verbose imformation 103 | #[arg(short, long, default_value_t = false)] 104 | verbose: bool, 105 | }, 106 | } 107 | 108 | impl Display for Commands { 109 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 110 | let text = match self { 111 | Commands::QuickAdd { .. } => "Quick Add".to_string(), 112 | Commands::Update { .. } => "Update".to_string(), 113 | Commands::Add { .. } => "Add".to_string(), 114 | Commands::Toggle { .. } => "Toggle".to_string(), 115 | Commands::List { .. } => "List".to_string(), 116 | }; 117 | write!(f, "{}", text) 118 | } 119 | } 120 | 121 | #[derive(Debug, Clone, clap::ValueEnum, PartialEq, Default, Hash, Eq, EnumIter)] 122 | pub enum Source { 123 | #[default] 124 | Modrinth, 125 | Github, 126 | CurseForge, 127 | } 128 | 129 | impl Display for Source { 130 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 131 | let text = match self { 132 | Source::Modrinth => "modrinth".to_string(), 133 | Source::Github => "github".to_string(), 134 | Source::CurseForge => "curseforge".to_string(), 135 | }; 136 | write!(f, "{}", text) 137 | } 138 | } 139 | 140 | impl TryInto for &str { 141 | type Error = String; 142 | fn try_into(self) -> Result { 143 | match self.trim().to_lowercase().as_str() { 144 | "modrinth" => Ok(Source::Modrinth), 145 | "github" => Ok(Source::Github), 146 | _ => Err("Invalid source".to_string()), 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /tui/src/app.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use color_eyre::Result; 4 | use crossterm::event::KeyEvent; 5 | use ratatui::prelude::Rect; 6 | use serde::{Deserialize, Serialize}; 7 | use strum::Display; 8 | use tokio::sync::mpsc; 9 | use tracing::debug; 10 | 11 | use crate::{ 12 | action::Action, 13 | components::{ 14 | Component, add::AddComponent, home::Home, list::ListComponent, toggle::ToggleComponent, 15 | }, 16 | config::Config, 17 | tui::{Event, Tui}, 18 | }; 19 | 20 | pub struct App { 21 | config: Config, 22 | tick_rate: f64, 23 | frame_rate: f64, 24 | components: Vec>, 25 | should_quit: bool, 26 | should_suspend: bool, 27 | mode: Mode, 28 | last_tick_key_events: Vec, 29 | action_tx: mpsc::UnboundedSender, 30 | action_rx: mpsc::UnboundedReceiver, 31 | } 32 | 33 | #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display)] 34 | pub enum Mode { 35 | #[default] 36 | Home, 37 | Add, 38 | QuickAdd, 39 | Toggle, 40 | List, 41 | } 42 | 43 | impl App { 44 | pub async fn new(tick_rate: f64, frame_rate: f64, dir: PathBuf) -> Result { 45 | let (action_tx, action_rx) = mpsc::unbounded_channel(); 46 | Ok(Self { 47 | tick_rate, 48 | frame_rate, 49 | components: vec![ 50 | Box::new(Home::new()), 51 | Box::new(ListComponent::new(dir.clone()).await), 52 | Box::new(ToggleComponent::new(dir.clone()).await), 53 | Box::new(AddComponent::new(dir).await), 54 | ], 55 | should_quit: false, 56 | should_suspend: false, 57 | config: Config::new()?, 58 | mode: Mode::Home, 59 | last_tick_key_events: Vec::new(), 60 | action_tx, 61 | action_rx, 62 | }) 63 | } 64 | 65 | pub async fn run(&mut self) -> Result<()> { 66 | let mut tui = Tui::new()? 67 | // .mouse(true) // uncomment this line to enable mouse support 68 | .tick_rate(self.tick_rate) 69 | .frame_rate(self.frame_rate); 70 | tui.enter()?; 71 | 72 | for component in self.components.iter_mut() { 73 | component.register_action_handler(self.action_tx.clone())?; 74 | } 75 | for component in self.components.iter_mut() { 76 | component.register_config_handler(self.config.clone())?; 77 | } 78 | for component in self.components.iter_mut() { 79 | component.init(tui.size()?)?; 80 | } 81 | 82 | let action_tx = self.action_tx.clone(); 83 | loop { 84 | self.handle_events(&mut tui).await?; 85 | self.handle_actions(&mut tui)?; 86 | if self.should_suspend { 87 | tui.suspend()?; 88 | action_tx.send(Action::Resume)?; 89 | action_tx.send(Action::ClearScreen)?; 90 | // tui.mouse(true); 91 | tui.enter()?; 92 | } else if self.should_quit { 93 | tui.stop()?; 94 | break; 95 | } 96 | } 97 | tui.exit()?; 98 | Ok(()) 99 | } 100 | 101 | async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { 102 | let Some(event) = tui.next_event().await else { 103 | return Ok(()); 104 | }; 105 | let action_tx = self.action_tx.clone(); 106 | match event { 107 | Event::Quit => action_tx.send(Action::Quit)?, 108 | Event::Tick => action_tx.send(Action::Tick)?, 109 | Event::Render => action_tx.send(Action::Render)?, 110 | Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, 111 | Event::Key(key) => self.handle_key_event(key)?, 112 | _ => {} 113 | } 114 | for component in self.components.iter_mut() { 115 | if let Some(action) = component.handle_events(Some(event.clone()))? { 116 | action_tx.send(action)?; 117 | } 118 | } 119 | Ok(()) 120 | } 121 | 122 | fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { 123 | let action_tx = self.action_tx.clone(); 124 | let Some(keymap) = self.config.keybindings.get(&self.mode) else { 125 | return Ok(()); 126 | }; 127 | debug!("Got key: {key:?}"); 128 | debug!("Got keymap: {keymap:?}"); 129 | match keymap.get(&vec![key]) { 130 | Some(action) => { 131 | debug!("Got action: {action:?}"); 132 | action_tx.send(action.clone())?; 133 | } 134 | _ => { 135 | // If the key was not handled as a single key action, 136 | // then consider it for multi-key combinations. 137 | self.last_tick_key_events.push(key); 138 | 139 | // Check for multi-key combinations 140 | if let Some(action) = keymap.get(&self.last_tick_key_events) { 141 | debug!("Got action: {action:?}"); 142 | action_tx.send(action.clone())?; 143 | } 144 | } 145 | } 146 | Ok(()) 147 | } 148 | 149 | fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> { 150 | while let Ok(action) = self.action_rx.try_recv() { 151 | if action != Action::Tick && action != Action::Render { 152 | debug!("{action:?}"); 153 | } 154 | match action { 155 | Action::Tick => { 156 | self.last_tick_key_events.drain(..); 157 | } 158 | Action::Quit => self.should_quit = true, 159 | Action::Suspend => self.should_suspend = true, 160 | Action::Resume => self.should_suspend = false, 161 | Action::ClearScreen => tui.terminal.clear()?, 162 | Action::Resize(w, h) => self.handle_resize(tui, w, h)?, 163 | Action::Render => self.render(tui)?, 164 | Action::Mode(mode) => self.mode = mode, 165 | _ => {} 166 | } 167 | for component in self.components.iter_mut() { 168 | if let Some(action) = component.update(action.clone())? { 169 | self.action_tx.send(action)? 170 | }; 171 | } 172 | } 173 | Ok(()) 174 | } 175 | 176 | fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> { 177 | tui.resize(Rect::new(0, 0, w, h))?; 178 | self.render(tui)?; 179 | Ok(()) 180 | } 181 | 182 | fn render(&mut self, tui: &mut Tui) -> Result<()> { 183 | tui.draw(|frame| { 184 | for component in self.components.iter_mut() { 185 | if component.get_mode() == self.mode { 186 | if let Err(err) = component.draw(frame, frame.area()) { 187 | let _ = self 188 | .action_tx 189 | .send(Action::Error(format!("Failed to draw: {:?}", err))); 190 | } 191 | } 192 | } 193 | })?; 194 | Ok(()) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /core/src/curseforge_wrapper/structs.rs: -------------------------------------------------------------------------------- 1 | use crate::ModLoader; 2 | use serde::Deserialize; 3 | use std::fmt::Display; 4 | use thiserror::Error; 5 | 6 | #[derive(Debug, Error)] 7 | pub enum CurseForgeError { 8 | #[error("Invalid response from CurseForge")] 9 | InvalidResponse, 10 | #[error("JSON Parsing error: {0}")] 11 | JsonParsingError(#[from] serde_json::Error), 12 | #[error("HTTP Error: {0}")] 13 | HttpError(#[from] reqwest::Error), 14 | #[error("IO Error: {0}")] 15 | IoError(#[from] std::io::Error), 16 | #[error("No game version found for mod {0}")] 17 | NoGameVersionFound(String), 18 | #[error("No fingerprint found for mod {0}")] 19 | NoFingerprintFound(String), 20 | #[error("No mod found")] 21 | NoModFound, 22 | #[error("URL Parse error: {0}")] 23 | UrlParseError(#[from] url::ParseError), 24 | #[error("Unknown error: {0}")] 25 | UnknownError(#[from] color_eyre::eyre::Report), 26 | } 27 | 28 | #[derive(Debug, Deserialize)] 29 | #[serde(rename_all = "camelCase")] 30 | pub struct Root { 31 | pub data: Vec, 32 | pub pagination: Option, 33 | } 34 | 35 | #[derive(Debug, Deserialize)] 36 | #[serde(rename_all = "camelCase")] 37 | pub struct FileSearchRoot { 38 | pub data: Vec, 39 | pub pagination: Option, 40 | } 41 | 42 | #[derive(Debug, Deserialize, Clone)] 43 | #[serde(rename_all = "camelCase")] 44 | pub struct Mod { 45 | pub id: u32, 46 | pub game_id: u32, 47 | pub name: String, 48 | pub slug: String, 49 | pub links: Links, 50 | pub summary: String, 51 | pub status: u32, 52 | pub download_count: u32, 53 | pub is_featured: bool, 54 | pub primary_category_id: u32, 55 | pub categories: Vec, 56 | pub class_id: u32, 57 | pub authors: Vec, 58 | pub logo: Logo, 59 | pub screenshots: Vec, 60 | pub main_file_id: u32, 61 | pub latest_files: Vec, 62 | pub latest_files_indexes: Vec, 63 | pub latest_early_access_files_indexes: Vec, 64 | pub date_created: String, 65 | pub date_modified: String, 66 | pub date_released: String, 67 | pub allow_mod_distribution: bool, 68 | pub game_popularity_rank: u32, 69 | pub is_available: bool, 70 | pub thumbs_up_count: u32, 71 | pub rating: Option, 72 | } 73 | 74 | #[derive(Debug, Deserialize, Clone)] 75 | #[serde(rename_all = "camelCase")] 76 | pub struct Links { 77 | pub website_url: Option, 78 | pub wiki_url: Option, 79 | pub issues_url: Option, 80 | pub source_url: Option, 81 | } 82 | 83 | #[derive(Debug, Deserialize, Clone)] 84 | #[serde(rename_all = "camelCase")] 85 | pub struct Category { 86 | pub id: u32, 87 | pub game_id: u32, 88 | pub name: String, 89 | pub slug: String, 90 | pub url: String, 91 | pub icon_url: String, 92 | pub date_modified: String, 93 | pub is_class: bool, 94 | pub class_id: u32, 95 | pub parent_category_id: u32, 96 | pub display_index: Option, 97 | } 98 | 99 | #[derive(Debug, Deserialize, Clone)] 100 | #[serde(rename_all = "camelCase")] 101 | pub struct Author { 102 | pub id: u32, 103 | pub name: String, 104 | pub url: String, 105 | } 106 | 107 | #[derive(Debug, Deserialize, Clone)] 108 | #[serde(rename_all = "camelCase")] 109 | pub struct Logo { 110 | pub id: u32, 111 | pub mod_id: u32, 112 | pub title: String, 113 | pub description: String, 114 | pub thumbnail_url: String, 115 | pub url: String, 116 | } 117 | 118 | #[derive(Debug, Deserialize, Clone)] 119 | #[serde(rename_all = "camelCase")] 120 | pub struct Screenshot { 121 | pub id: u32, 122 | pub mod_id: u32, 123 | pub title: String, 124 | pub description: String, 125 | pub thumbnail_url: String, 126 | pub url: String, 127 | } 128 | 129 | #[derive(Debug, Deserialize, Clone)] 130 | #[serde(rename_all = "camelCase")] 131 | pub struct File { 132 | pub id: u32, 133 | pub game_id: u32, 134 | pub mod_id: u32, 135 | pub is_available: bool, 136 | pub display_name: String, 137 | pub file_name: String, 138 | pub release_type: u32, 139 | pub file_status: u32, 140 | pub hashes: Vec, 141 | pub file_date: String, 142 | pub file_length: u64, 143 | pub download_count: u32, 144 | pub file_size_on_disk: Option, 145 | pub download_url: Option, 146 | pub game_versions: Vec, 147 | pub sortable_game_versions: Vec, 148 | pub dependencies: Vec, 149 | pub expose_as_alternative: Option, 150 | pub parent_project_file_id: Option, 151 | pub alternate_file_id: Option, 152 | pub is_server_pack: bool, 153 | pub server_pack_file_id: Option, 154 | pub is_early_access_content: Option, 155 | pub early_access_end_date: Option, 156 | pub file_fingerprint: u64, 157 | pub modules: Vec, 158 | } 159 | 160 | #[derive(Debug, Deserialize, Clone)] 161 | #[serde(rename_all = "camelCase")] 162 | pub struct FileHash { 163 | pub value: String, 164 | pub algo: u32, 165 | } 166 | 167 | #[derive(Debug, Deserialize, Clone)] 168 | #[serde(rename_all = "camelCase")] 169 | pub struct SortableGameVersion { 170 | pub game_version_name: String, 171 | pub game_version_padded: String, 172 | pub game_version: String, 173 | pub game_version_release_date: String, 174 | pub game_version_type_id: Option, 175 | } 176 | 177 | #[derive(Debug, Deserialize, Clone)] 178 | #[serde(rename_all = "camelCase")] 179 | pub struct Dependency { 180 | pub mod_id: u32, 181 | pub relation_type: u32, 182 | } 183 | 184 | #[derive(Debug, Deserialize, Clone)] 185 | #[serde(rename_all = "camelCase")] 186 | pub struct Module { 187 | pub name: String, 188 | pub fingerprint: u64, 189 | } 190 | 191 | #[derive(Debug, Deserialize, Clone)] 192 | #[serde(rename_all = "camelCase")] 193 | pub struct FileIndex { 194 | pub game_version: String, 195 | pub file_id: u32, 196 | pub filename: String, 197 | pub release_type: u32, 198 | pub game_version_type_id: Option, 199 | pub mod_loader: Option, 200 | } 201 | 202 | #[derive(Debug, Deserialize)] 203 | #[serde(rename_all = "camelCase")] 204 | pub struct Pagination { 205 | pub index: u32, 206 | pub page_size: u32, 207 | pub result_count: u32, 208 | pub total_count: u32, 209 | } 210 | 211 | pub struct ModBuilder { 212 | pub game_version: String, 213 | pub search: String, 214 | } 215 | 216 | #[derive(Debug, Deserialize, Clone)] 217 | #[serde(rename_all = "camelCase")] 218 | pub struct FingerprintResponseRoot { 219 | pub data: FingerprintResponse, 220 | } 221 | 222 | #[derive(Debug, Deserialize, Clone)] 223 | #[serde(rename_all = "camelCase")] 224 | pub struct FingerprintResponse { 225 | #[serde(default)] 226 | pub is_cache_built: bool, 227 | pub exact_matches: Vec, 228 | pub exact_fingerprints: Vec, 229 | pub partial_matches: Vec, 230 | pub partial_match_fingerprints: std::collections::HashMap>, 231 | pub installed_fingerprints: Vec, 232 | pub unmatched_fingerprints: Option>, 233 | } 234 | 235 | #[derive(Debug, Deserialize, Clone)] 236 | #[serde(rename_all = "camelCase")] 237 | pub struct ExactMatch { 238 | pub id: u32, 239 | pub file: File, 240 | pub latest_files: Vec, 241 | } 242 | 243 | #[derive(Debug, Deserialize, Clone)] 244 | #[serde(rename_all = "camelCase")] 245 | pub struct PartialMatch { 246 | pub id: u32, 247 | pub file: File, 248 | pub latest_files: Vec, 249 | } 250 | 251 | #[derive(Debug, Deserialize, Clone)] 252 | pub struct DownloadFile { 253 | pub data: String, 254 | } 255 | 256 | #[derive(Debug, Deserialize, Clone)] 257 | pub struct GetModFileResponse { 258 | pub data: File, 259 | } 260 | 261 | pub trait CurseForgeMod { 262 | fn get_version_and_loader(&self, game_version: &str) -> Option; 263 | } 264 | 265 | impl CurseForgeMod for Mod { 266 | fn get_version_and_loader(&self, game_version: &str) -> Option { 267 | self.latest_files_indexes 268 | .iter() 269 | .find(|file_index| file_index.game_version == game_version) 270 | .cloned() 271 | } 272 | } 273 | 274 | pub trait AsNum { 275 | fn as_num(&self) -> u8; 276 | } 277 | impl AsNum for ModLoader { 278 | fn as_num(&self) -> u8 { 279 | match self { 280 | ModLoader::Forge => 1, 281 | ModLoader::Cauldron => 2, 282 | ModLoader::LiteLoader => 3, 283 | ModLoader::Fabric => 4, 284 | ModLoader::Quilt => 5, 285 | ModLoader::NeoForge => 6, 286 | ModLoader::Any => 0, 287 | } 288 | } 289 | } 290 | 291 | impl Display for Mod { 292 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 293 | write!(f, "{}", self.name) 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /tui/src/tui.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] // Remove this once you start using the code 2 | 3 | use std::{ 4 | io::{Stdout, stdout}, 5 | ops::{Deref, DerefMut}, 6 | time::Duration, 7 | }; 8 | 9 | use color_eyre::Result; 10 | use crossterm::{ 11 | cursor, 12 | event::{ 13 | DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, 14 | Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent, 15 | }, 16 | terminal::{EnterAlternateScreen, LeaveAlternateScreen}, 17 | }; 18 | use futures::{FutureExt, StreamExt}; 19 | use ratatui::backend::CrosstermBackend as Backend; 20 | use serde::{Deserialize, Serialize}; 21 | use tokio::{ 22 | sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, 23 | task::JoinHandle, 24 | time::interval, 25 | }; 26 | use tokio_util::sync::CancellationToken; 27 | use tracing::error; 28 | 29 | #[derive(Clone, Debug, Serialize, Deserialize)] 30 | pub enum Event { 31 | Init, 32 | Quit, 33 | Error, 34 | Closed, 35 | Tick, 36 | Render, 37 | FocusGained, 38 | FocusLost, 39 | Paste(String), 40 | Key(KeyEvent), 41 | Mouse(MouseEvent), 42 | Resize(u16, u16), 43 | } 44 | 45 | impl From for CrosstermEvent { 46 | fn from(event: Event) -> Self { 47 | match event { 48 | Event::Key(key) => crossterm::event::Event::Key(key), 49 | Event::Mouse(mouse) => crossterm::event::Event::Mouse(mouse), 50 | Event::Resize(x, y) => crossterm::event::Event::Resize(x, y), 51 | Event::FocusLost => crossterm::event::Event::FocusLost, 52 | Event::FocusGained => crossterm::event::Event::FocusGained, 53 | Event::Paste(s) => crossterm::event::Event::Paste(s), 54 | _ => unreachable!(), 55 | } 56 | } 57 | } 58 | 59 | pub struct Tui { 60 | pub terminal: ratatui::Terminal>, 61 | pub task: JoinHandle<()>, 62 | pub cancellation_token: CancellationToken, 63 | pub event_rx: UnboundedReceiver, 64 | pub event_tx: UnboundedSender, 65 | pub frame_rate: f64, 66 | pub tick_rate: f64, 67 | pub mouse: bool, 68 | pub paste: bool, 69 | } 70 | 71 | impl Tui { 72 | pub fn new() -> Result { 73 | let (event_tx, event_rx) = mpsc::unbounded_channel(); 74 | Ok(Self { 75 | terminal: ratatui::Terminal::new(Backend::new(stdout()))?, 76 | task: tokio::spawn(async {}), 77 | cancellation_token: CancellationToken::new(), 78 | event_rx, 79 | event_tx, 80 | frame_rate: 60.0, 81 | tick_rate: 4.0, 82 | mouse: false, 83 | paste: false, 84 | }) 85 | } 86 | 87 | pub fn tick_rate(mut self, tick_rate: f64) -> Self { 88 | self.tick_rate = tick_rate; 89 | self 90 | } 91 | 92 | pub fn frame_rate(mut self, frame_rate: f64) -> Self { 93 | self.frame_rate = frame_rate; 94 | self 95 | } 96 | 97 | pub fn mouse(mut self, mouse: bool) -> Self { 98 | self.mouse = mouse; 99 | self 100 | } 101 | 102 | pub fn paste(mut self, paste: bool) -> Self { 103 | self.paste = paste; 104 | self 105 | } 106 | 107 | pub fn start(&mut self) { 108 | self.cancel(); // Cancel any existing task 109 | self.cancellation_token = CancellationToken::new(); 110 | let event_loop = Self::event_loop( 111 | self.event_tx.clone(), 112 | self.cancellation_token.clone(), 113 | self.tick_rate, 114 | self.frame_rate, 115 | ); 116 | self.task = tokio::spawn(async { 117 | event_loop.await; 118 | }); 119 | } 120 | 121 | async fn event_loop( 122 | event_tx: UnboundedSender, 123 | cancellation_token: CancellationToken, 124 | tick_rate: f64, 125 | frame_rate: f64, 126 | ) { 127 | let mut event_stream = EventStream::new(); 128 | let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); 129 | let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); 130 | 131 | // if this fails, then it's likely a bug in the calling code 132 | event_tx 133 | .send(Event::Init) 134 | .expect("failed to send init event"); 135 | loop { 136 | let event = tokio::select! { 137 | _ = cancellation_token.cancelled() => { 138 | break; 139 | } 140 | _ = tick_interval.tick() => Event::Tick, 141 | _ = render_interval.tick() => Event::Render, 142 | crossterm_event = event_stream.next().fuse() => match crossterm_event { 143 | Some(Ok(event)) => match event { 144 | CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), 145 | CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), 146 | CrosstermEvent::Resize(x, y) => Event::Resize(x, y), 147 | CrosstermEvent::FocusLost => Event::FocusLost, 148 | CrosstermEvent::FocusGained => Event::FocusGained, 149 | CrosstermEvent::Paste(s) => Event::Paste(s), 150 | _ => continue, // ignore other events 151 | } 152 | Some(Err(_)) => Event::Error, 153 | None => break, // the event stream has stopped and will not produce any more events 154 | }, 155 | }; 156 | if event_tx.send(event).is_err() { 157 | // the receiver has been dropped, so there's no point in continuing the loop 158 | break; 159 | } 160 | } 161 | cancellation_token.cancel(); 162 | } 163 | 164 | pub fn stop(&self) -> Result<()> { 165 | self.cancel(); 166 | let mut counter = 0; 167 | while !self.task.is_finished() { 168 | std::thread::sleep(Duration::from_millis(1)); 169 | counter += 1; 170 | if counter > 50 { 171 | self.task.abort(); 172 | } 173 | if counter > 100 { 174 | error!("Failed to abort task in 100 milliseconds for unknown reason"); 175 | break; 176 | } 177 | } 178 | Ok(()) 179 | } 180 | 181 | pub fn enter(&mut self) -> Result<()> { 182 | crossterm::terminal::enable_raw_mode()?; 183 | crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; 184 | if self.mouse { 185 | crossterm::execute!(stdout(), EnableMouseCapture)?; 186 | } 187 | if self.paste { 188 | crossterm::execute!(stdout(), EnableBracketedPaste)?; 189 | } 190 | self.start(); 191 | Ok(()) 192 | } 193 | 194 | pub fn exit(&mut self) -> Result<()> { 195 | self.stop()?; 196 | if crossterm::terminal::is_raw_mode_enabled()? { 197 | self.flush()?; 198 | if self.paste { 199 | crossterm::execute!(stdout(), DisableBracketedPaste)?; 200 | } 201 | if self.mouse { 202 | crossterm::execute!(stdout(), DisableMouseCapture)?; 203 | } 204 | crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; 205 | crossterm::terminal::disable_raw_mode()?; 206 | } 207 | Ok(()) 208 | } 209 | 210 | pub fn cancel(&self) { 211 | self.cancellation_token.cancel(); 212 | } 213 | 214 | pub fn suspend(&mut self) -> Result<()> { 215 | self.exit()?; 216 | #[cfg(not(windows))] 217 | signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; 218 | Ok(()) 219 | } 220 | 221 | pub fn resume(&mut self) -> Result<()> { 222 | self.enter()?; 223 | Ok(()) 224 | } 225 | 226 | pub async fn next_event(&mut self) -> Option { 227 | self.event_rx.recv().await 228 | } 229 | } 230 | 231 | impl Deref for Tui { 232 | type Target = ratatui::Terminal>; 233 | 234 | fn deref(&self) -> &Self::Target { 235 | &self.terminal 236 | } 237 | } 238 | 239 | impl DerefMut for Tui { 240 | fn deref_mut(&mut self) -> &mut Self::Target { 241 | &mut self.terminal 242 | } 243 | } 244 | 245 | impl Drop for Tui { 246 | fn drop(&mut self) { 247 | self.stop().unwrap(); 248 | if crossterm::terminal::is_raw_mode_enabled().unwrap() { 249 | self.flush().unwrap(); 250 | if self.paste { 251 | crossterm::execute!(stdout(), DisableBracketedPaste).unwrap(); 252 | } 253 | if self.mouse { 254 | crossterm::execute!(stdout(), DisableMouseCapture).unwrap(); 255 | } 256 | crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show).unwrap(); 257 | crossterm::terminal::disable_raw_mode().unwrap(); 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /core/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | pub mod cli; 3 | pub mod curseforge_wrapper; 4 | pub mod gh_releases; 5 | pub mod metadata; 6 | pub mod modrinth_wrapper; 7 | use clap::ValueEnum; 8 | use cli::Source; 9 | use color_eyre::Result; 10 | use color_eyre::eyre::bail; 11 | use curseforge_wrapper::{CurseForgeAPI, CurseForgeMod}; 12 | use gh_releases::{Error, GHReleasesAPI}; 13 | use hmac_sha512::Hash; 14 | use itertools::Itertools; 15 | use metadata::Metadata; 16 | use modrinth_wrapper::modrinth; 17 | use serde::Deserialize; 18 | use std::collections::HashSet; 19 | use std::ffi::OsStr; 20 | use std::fmt; 21 | use std::hash::RandomState; 22 | use std::sync::{Arc, LazyLock}; 23 | use std::{env, path::PathBuf}; 24 | use std::{fmt::Display, fs, io::Read}; 25 | use strum::{Display, EnumIter, IntoEnumIterator}; 26 | use tokio::task::JoinHandle; 27 | use tracing::{self, error, info}; 28 | 29 | pub static MOD_LOADERS: LazyLock> = 30 | LazyLock::new(|| ModLoader::iter().collect_vec()); 31 | #[derive(Debug, Deserialize, Clone)] 32 | pub enum Mods { 33 | AntiXray, 34 | AppleSkin, 35 | CarpetExtra, 36 | EasyAuth, 37 | EssentialCommands, 38 | FabricApi, 39 | FabricCarpet, 40 | Geyser, 41 | Lithium, 42 | Origins, 43 | SkinRestorer, 44 | Status, 45 | WorldEdit, 46 | } 47 | 48 | impl Display for Mods { 49 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 50 | let text = match self { 51 | Mods::FabricApi => "fabric-api".to_string(), 52 | Mods::AntiXray => "anti-xray".to_string(), 53 | Mods::AppleSkin => "appleskin".to_string(), 54 | Mods::EasyAuth => "easy-auth".to_string(), 55 | Mods::EssentialCommands => "essential-commands".to_string(), 56 | Mods::Lithium => "lithium".to_string(), 57 | Mods::Origins => "origins".to_string(), 58 | Mods::SkinRestorer => "skin-restorer".to_string(), 59 | Mods::Status => "status".to_string(), 60 | Mods::CarpetExtra => "carpet-extra".to_string(), 61 | Mods::FabricCarpet => "fabric-carpet".to_string(), 62 | Mods::Geyser => "geyser".to_string(), 63 | Mods::WorldEdit => "worldedit".to_string(), 64 | }; 65 | 66 | write!(f, "{}", text) 67 | } 68 | } 69 | 70 | pub fn calc_sha512(filename: &str) -> String { 71 | let mut file = fs::File::open(filename).unwrap(); 72 | let mut text = Vec::new(); 73 | file.read_to_end(&mut text).unwrap(); 74 | let hash = Hash::hash(text); 75 | hex::encode(hash) 76 | } 77 | 78 | #[allow(clippy::too_many_arguments)] 79 | pub async fn update_dir( 80 | github: &mut GHReleasesAPI, 81 | curseforge: CurseForgeAPI, 82 | dir: &str, 83 | new_version: &str, 84 | del_prev: bool, 85 | prefix: &str, 86 | source: Option, 87 | no_other_sources: bool, 88 | loader: Option, 89 | ) -> Result<()> { 90 | let mut handles = Vec::new(); 91 | let github = Arc::new(github.clone()); 92 | let curseforge = Arc::new(curseforge.clone()); 93 | let source = source.clone().unwrap_or(Source::Modrinth); 94 | for entry in fs::read_dir(dir).unwrap() { 95 | let new_version = new_version.to_string(); 96 | let loader = loader.clone(); 97 | let prefix = prefix.to_string(); 98 | let source = source.clone(); 99 | let github = github.clone(); 100 | let curseforge = curseforge.clone(); 101 | let handle: JoinHandle> = tokio::spawn(async move { 102 | let entry = entry.unwrap(); 103 | let path = entry.path(); 104 | if path.is_file() && path.extension().unwrap_or(OsStr::new("")) == "jar" { 105 | info!("Updating {:?}", path); 106 | let success: Result<()> = match source { 107 | Source::Modrinth => modrinth::update_from_file( 108 | path.to_str().unwrap(), 109 | &new_version, 110 | &prefix, 111 | loader.clone(), 112 | ) 113 | .await 114 | .map_err(|err| err.into()), 115 | Source::Github => { 116 | update_file_github( 117 | (*github).clone(), 118 | path.to_str().unwrap(), 119 | &new_version, 120 | &prefix, 121 | ) 122 | .await 123 | } 124 | Source::CurseForge => { 125 | update_file_curseforge( 126 | (*curseforge).clone(), 127 | path.to_str().unwrap(), 128 | &new_version, 129 | &prefix, 130 | ) 131 | .await 132 | } 133 | }; 134 | if success.is_err() && no_other_sources { 135 | let mut set = HashSet::::from_iter(Source::iter()); 136 | set.remove(&source); 137 | for source in set { 138 | let loader = loader.clone(); 139 | info!( 140 | "Trying to update {} with {}", 141 | path.to_str().unwrap(), 142 | source 143 | ); 144 | let success: Result<()> = match source { 145 | Source::Modrinth => modrinth::update_from_file( 146 | path.to_str().unwrap(), 147 | &new_version, 148 | &prefix, 149 | loader, 150 | ) 151 | .await 152 | .map_err(|err| err.into()), 153 | Source::Github => { 154 | update_file_github( 155 | (*github).clone(), 156 | path.to_str().unwrap(), 157 | &new_version, 158 | &prefix, 159 | ) 160 | .await 161 | } 162 | Source::CurseForge => { 163 | update_file_curseforge( 164 | (*curseforge).clone(), 165 | path.to_str().unwrap(), 166 | &new_version, 167 | &prefix, 168 | ) 169 | .await 170 | } 171 | }; 172 | match success { 173 | Ok(_) => { 174 | info!( 175 | "Successfully updated {} with {}", 176 | path.to_str().unwrap(), 177 | source 178 | ); 179 | if del_prev && path.ends_with(entry.file_name()) { 180 | fs::remove_file(path).unwrap(); 181 | } 182 | 183 | break; 184 | } 185 | Err(err) => { 186 | error!( 187 | "Failed to update {} with {}: {err}", 188 | path.to_str().unwrap(), 189 | source 190 | ); 191 | continue; 192 | } 193 | } 194 | } 195 | } 196 | } 197 | 198 | Ok(()) 199 | }); 200 | handles.push(handle); 201 | } 202 | for handle in handles { 203 | handle.await??; 204 | } 205 | Ok(()) 206 | } 207 | 208 | pub fn get_minecraft_dir() -> PathBuf { 209 | let home_dir = env::var("HOME").ok().map(PathBuf::from); 210 | #[cfg(target_os = "windows")] 211 | { 212 | let appdata = env::var("APPDATA").expect("%APPDATA% not set"); 213 | PathBuf::from(appdata).join(".minecraft") 214 | } 215 | 216 | #[cfg(target_os = "macos")] 217 | { 218 | home_dir 219 | .expect("HOME not set") 220 | .join("Library/Application Support/minecraft") 221 | } 222 | 223 | #[cfg(target_os = "linux")] 224 | { 225 | home_dir.expect("HOME not set").join(".minecraft") 226 | } 227 | } 228 | 229 | #[derive(Clone, Debug, Eq, Hash, PartialEq, Default)] 230 | pub struct Link { 231 | pub text: String, 232 | pub url: String, 233 | } 234 | 235 | impl Link { 236 | pub fn new(text: String, url: String) -> Self { 237 | Self { text, url } 238 | } 239 | } 240 | 241 | impl fmt::Display for Link { 242 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 243 | write!( 244 | f, 245 | "\u{1b}]8;;{}\u{1b}\\{}\u{1b}]8;;\u{1b}\\", 246 | self.url, self.text 247 | ) 248 | } 249 | } 250 | 251 | pub struct UrlBuilder { 252 | pub base: String, 253 | pub path: String, 254 | pub params: Vec<(String, String)>, 255 | } 256 | impl UrlBuilder { 257 | pub fn new(base: &str, path: &str) -> Self { 258 | Self { 259 | base: base.to_string(), 260 | path: path.to_string(), 261 | params: Vec::new(), 262 | } 263 | } 264 | fn add_param(&mut self, key: &str, value: &str) { 265 | self.params.push((key.to_string(), value.to_string())); 266 | } 267 | } 268 | 269 | impl fmt::Display for UrlBuilder { 270 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 271 | let mut url = self.base.clone(); 272 | url.push_str(&self.path); 273 | if self.params.is_empty() { 274 | return write!(f, "{}", url); 275 | } 276 | let mut iter = self.params.iter(); 277 | let first = iter.next().unwrap(); 278 | url.push('?'); 279 | url.push_str(&first.0); 280 | url.push('='); 281 | url.push_str(&first.1); 282 | for param in iter { 283 | url.push('&'); 284 | url.push_str(¶m.0); 285 | url.push('='); 286 | url.push_str(¶m.1); 287 | } 288 | write!(f, "{}", url) 289 | } 290 | } 291 | 292 | #[derive( 293 | Debug, clap::ValueEnum, PartialEq, Default, Eq, Clone, Display, Hash, EnumIter, strum::AsRefStr, 294 | )] 295 | pub enum ModLoader { 296 | Forge, 297 | #[default] 298 | Fabric, 299 | Quilt, 300 | NeoForge, 301 | Cauldron, 302 | LiteLoader, 303 | Any, 304 | } 305 | 306 | pub async fn update_file_github( 307 | github: GHReleasesAPI, 308 | filename: &str, 309 | new_version: &str, 310 | prefix: &str, 311 | ) -> Result<()> { 312 | let metadata = Metadata::get_all_metadata(PathBuf::from(filename)); 313 | let Ok(metadata) = metadata else { 314 | bail!("Could not find metadata for {}", filename); 315 | }; 316 | let source: Result = match metadata.get("source") { 317 | Some(source) => Ok(Source::from_str(source, true).unwrap()), 318 | None => bail!("No key found"), 319 | }; 320 | 321 | if let Ok(Source::Github) = source { 322 | info!("Checking Github for mod"); 323 | let repo = metadata.get("repo"); 324 | if repo.is_none() { 325 | bail!("Could not find repo for {}", filename); 326 | } 327 | let repo = repo.unwrap(); 328 | let split = repo.split("/").collect_vec(); 329 | let update = github.get_releases(split[0].trim(), split[1]).await; 330 | 331 | if update.is_err() { 332 | bail!( 333 | "Could not find update for {}: {:?}", 334 | filename, 335 | update.err().unwrap() 336 | ); 337 | } 338 | let update = update.unwrap(); 339 | let mod_ = gh_releases::get_mod_from_release(&update, "fabric", new_version).await?; 340 | mod_.download(format!("{}/{}", prefix, mod_.name).into(), split.join("/")) 341 | .await 342 | .unwrap(); 343 | Ok(()) 344 | } else { 345 | Err(Error::NoReleases)? 346 | } 347 | } 348 | 349 | pub async fn update_file_curseforge( 350 | curseforge: CurseForgeAPI, 351 | filename: &str, 352 | new_version: &str, 353 | prefix: &str, 354 | ) -> Result<()> { 355 | let mod_ = curseforge 356 | .get_mod_from_file(PathBuf::from(filename)) 357 | .await?; 358 | let new_mod = curseforge.get_mods(mod_.id).await?; 359 | let Some(new_mod) = new_mod.first() else { 360 | bail!("Version not found for {filename}"); 361 | }; 362 | let Some(new_version) = new_mod.get_version_and_loader(new_version) else { 363 | bail!("Version {new_version} not found for {filename}"); 364 | }; 365 | curseforge 366 | .download_mod(new_mod.id, new_version.file_id, prefix.into()) 367 | .await?; 368 | Ok(()) 369 | } 370 | -------------------------------------------------------------------------------- /core/src/curseforge_wrapper/mod.rs: -------------------------------------------------------------------------------- 1 | mod file_utils; 2 | mod hash; 3 | mod structs; 4 | use crate::ModLoader; 5 | use color_eyre::eyre::Context; 6 | pub use file_utils::get_jar_contents; 7 | pub use hash::*; 8 | use percent_encoding::percent_decode; 9 | use reqwest::{ 10 | Method, 11 | header::{HeaderMap, HeaderName, HeaderValue}, 12 | }; 13 | use serde_json::json; 14 | use std::{fs, path::PathBuf, sync::LazyLock}; 15 | pub use structs::*; 16 | use tracing::debug; 17 | use url::Url; 18 | 19 | type Result = color_eyre::Result; 20 | pub const GAME_ID: u32 = 432; 21 | pub const BASE_URL: &str = "https://api.curseforge.com/v1"; 22 | pub const API_KEY: &str = env!("CURSEFORGE_API_KEY"); 23 | pub static HEADERS: LazyLock = LazyLock::new(|| { 24 | let mut headers = HeaderMap::new(); 25 | headers.insert( 26 | HeaderName::from_static("x-api-key"), 27 | HeaderValue::from_str(API_KEY) 28 | .context("Invalid API key") 29 | .unwrap(), 30 | ); 31 | headers.insert( 32 | HeaderName::from_static("accept"), 33 | HeaderValue::from_static("application/json"), 34 | ); 35 | headers 36 | }); 37 | 38 | pub trait AsModIdVec { 39 | fn as_mod_id_vec(&self) -> Vec; 40 | } 41 | 42 | impl AsModIdVec for &[u32] { 43 | fn as_mod_id_vec(&self) -> Vec { 44 | self.to_vec() 45 | } 46 | } 47 | impl AsModIdVec for u32 { 48 | fn as_mod_id_vec(&self) -> Vec { 49 | vec![*self] 50 | } 51 | } 52 | 53 | #[derive(Clone)] 54 | pub struct CurseForgeAPI { 55 | pub client: reqwest::Client, 56 | pub api_key: String, 57 | } 58 | 59 | impl CurseForgeAPI { 60 | pub fn new(api_key: String) -> Self { 61 | Self { 62 | client: reqwest::Client::new(), 63 | api_key, 64 | } 65 | } 66 | pub async fn search_mods( 67 | &self, 68 | game_version: &str, 69 | loader: ModLoader, 70 | search: &str, 71 | page_size: u32, 72 | ) -> Result> { 73 | let params = [ 74 | ("gameId", GAME_ID.to_string()), 75 | ("index", 0.to_string()), 76 | ("searchFilter", search.to_string()), 77 | ("gameVersion", game_version.to_string()), 78 | ("pageSize", page_size.to_string()), 79 | ("sortField", 6.to_string()), 80 | ("gameFlavors[0]", loader.as_num().to_string()), 81 | ("sortOrder", "desc".to_string()), 82 | ]; 83 | let params_str = params 84 | .iter() 85 | .map(|(k, v)| format!("{}={}", k, v)) 86 | .collect::>() 87 | .join("&"); 88 | let url = format!("{}/mods/search?{params_str}", BASE_URL); 89 | debug!(url = ?url); 90 | let headers = HEADERS.clone(); 91 | let response = self 92 | .client 93 | .request(Method::GET, Url::parse(&url)?) 94 | .headers(headers) 95 | .send() 96 | .await?; 97 | let response = response.error_for_status()?; 98 | let body = response.text().await?; 99 | debug!(body = ?body); 100 | let root: Root = serde_json::from_str(&body)?; 101 | debug!(root_data = ?root.data); 102 | Ok(root.data) 103 | } 104 | pub async fn get_mods(&self, mod_ids: T) -> Result> 105 | where 106 | T: AsModIdVec, 107 | { 108 | let mod_ids = mod_ids.as_mod_id_vec(); 109 | let body = json!({ 110 | "modIds": mod_ids, 111 | "filterPcOnly": true, 112 | }); 113 | let url = format!("{}/mods", BASE_URL); 114 | let mut headers = HEADERS.clone(); 115 | headers.insert( 116 | HeaderName::from_static("content-type"), 117 | HeaderValue::from_static("application/json"), 118 | ); 119 | let response = self 120 | .client 121 | .request(Method::POST, Url::parse(&url)?) 122 | .headers(headers) 123 | .body(serde_json::to_string(&body)?) 124 | .send() 125 | .await?; 126 | let response = response.error_for_status()?; 127 | let body = response.text().await?; 128 | let root: Root = serde_json::from_str(&body)?; 129 | Ok(root.data) 130 | } 131 | pub async fn get_mod_files( 132 | &self, 133 | mod_id: u32, 134 | game_version: &str, 135 | mod_loader: ModLoader, 136 | ) -> Result> { 137 | let params = [ 138 | ("index", 0.to_string()), 139 | ("gameVersion", game_version.to_string()), 140 | ("pageSize", 1.to_string()), 141 | ("modLoaderType", mod_loader.as_num().to_string()), 142 | ]; 143 | let params_str = params 144 | .iter() 145 | .map(|(k, v)| format!("{}={}", k, v)) 146 | .collect::>() 147 | .join("&"); 148 | let url = format!("{BASE_URL}/mods/{mod_id}/files?{params_str}"); 149 | let response = self 150 | .client 151 | .request(Method::GET, Url::parse(&url)?) 152 | .headers(HEADERS.clone()) 153 | .send() 154 | .await?; 155 | let response = response.error_for_status()?; 156 | let body = response.text().await?; 157 | let root = serde_json::from_str::(&body)?; 158 | Ok(root.data) 159 | } 160 | pub async fn download_mod(&self, mod_id: u32, file_id: u32, dir: PathBuf) -> Result<()> { 161 | let url = format!( 162 | "{}/mods/{}/files/{}/download-url", 163 | BASE_URL, mod_id, file_id 164 | ); 165 | let response = self 166 | .client 167 | .request(Method::GET, Url::parse(&url)?) 168 | .headers(HEADERS.clone()) 169 | .send() 170 | .await?; 171 | let response = response.error_for_status()?; 172 | let body = response.text().await?; 173 | let json = serde_json::from_str::(&body)?; 174 | let url = json.data; 175 | let file_data = reqwest::get(url).await?; 176 | let file_name = file_data.url().path_segments().unwrap().last().unwrap(); 177 | let file_name = percent_decode(file_name.as_bytes()).decode_utf8_lossy(); 178 | let path = dir.join(file_name.to_string()); 179 | fs::create_dir_all(path.parent().unwrap())?; 180 | fs::write(&path, file_data.bytes().await?)?; 181 | Ok(()) 182 | } 183 | pub async fn get_version_from_file(&self, file: PathBuf) -> Result { 184 | let f = file.clone(); 185 | let f_name = f.file_name().unwrap().to_str().unwrap(); 186 | let contents = get_jar_contents(&file)?; 187 | let fingerprint = MurmurHash2::hash(&contents); 188 | let url = format!("{BASE_URL}/fingerprints/{GAME_ID}"); 189 | let mut headers = HEADERS.clone(); 190 | headers.insert( 191 | HeaderName::from_static("content-type"), 192 | HeaderValue::from_static("application/json"), 193 | ); 194 | let body = json!({ 195 | "fingerprints": [ 196 | fingerprint 197 | ]}); 198 | let body = serde_json::to_string(&body)?; 199 | let response = self 200 | .client 201 | .request(Method::POST, Url::parse(&url)?) 202 | .headers(headers) 203 | .body(body) 204 | .send() 205 | .await?; 206 | let response = response.error_for_status()?; 207 | let body = response.text().await?; 208 | 209 | let res: FingerprintResponseRoot = serde_json::from_str(&body)?; 210 | let res = res.data; 211 | if res.exact_matches.is_empty() { 212 | return Err(CurseForgeError::NoFingerprintFound(f_name.to_string())); 213 | } 214 | let exact_match = res.exact_matches.first().unwrap(); 215 | let file = exact_match.file.clone(); 216 | Ok(file) 217 | } 218 | pub async fn get_mod_from_file(&self, file: PathBuf) -> Result { 219 | let f = file.clone(); 220 | let f_name = f.file_name().unwrap().to_str().unwrap(); 221 | let contents = get_jar_contents(&file)?; 222 | let fingerprint = MurmurHash2::hash(&contents); 223 | let url = format!("{BASE_URL}/fingerprints/{GAME_ID}"); 224 | let mut headers = HEADERS.clone(); 225 | headers.insert( 226 | HeaderName::from_static("content-type"), 227 | HeaderValue::from_static("application/json"), 228 | ); 229 | let body = json!({ 230 | "fingerprints": [ 231 | fingerprint 232 | ]}); 233 | let body = serde_json::to_string(&body)?; 234 | let response = self 235 | .client 236 | .request(Method::POST, Url::parse(&url)?) 237 | .headers(headers) 238 | .body(body) 239 | .send() 240 | .await?; 241 | let response = response.error_for_status()?; 242 | let body = response.text().await?; 243 | let res: FingerprintResponseRoot = serde_json::from_str(&body)?; 244 | let res = res.data; 245 | if res.exact_matches.is_empty() { 246 | return Err(CurseForgeError::NoFingerprintFound(f_name.to_string())); 247 | } 248 | let exact_match = res.exact_matches.first().unwrap(); 249 | let file = exact_match.file.clone(); 250 | let mod_id = file.mod_id; 251 | let mod_ = self.get_mods(mod_id).await?; 252 | mod_.first().cloned().ok_or(CurseForgeError::NoModFound) 253 | } 254 | pub async fn get_dependencies(&self, mod_id: u32, version: &str) -> Result> { 255 | let mod_ = self.get_mods(mod_id).await?; 256 | let mod_ = mod_.first().cloned().ok_or(CurseForgeError::NoModFound)?; 257 | let file_index = mod_ 258 | .latest_files_indexes 259 | .iter() 260 | .find(|file| file.game_version == version) 261 | .cloned() 262 | .ok_or(CurseForgeError::NoGameVersionFound(version.to_string()))?; 263 | let url = format!("{}/mods/{}/files/{}", BASE_URL, mod_id, file_index.file_id); 264 | let file = self.client.get(url).headers(HEADERS.clone()).send().await?; 265 | let file = file.error_for_status()?; 266 | let body = file.text().await?; 267 | let file: GetModFileResponse = serde_json::from_str(&body)?; 268 | let file = file.data; 269 | 270 | let dep_ids = file.dependencies; 271 | let mut deps = Vec::with_capacity(dep_ids.len()); 272 | for dep in dep_ids { 273 | let mod_ = self.get_mods(dep.mod_id).await?; 274 | let mod_ = mod_.first().cloned().ok_or(CurseForgeError::NoModFound)?; 275 | deps.push(mod_); 276 | } 277 | Result::Ok(deps) 278 | } 279 | } 280 | 281 | #[cfg(test)] 282 | mod tests { 283 | use super::*; 284 | use pretty_assertions::assert_eq; 285 | #[tokio::test] 286 | async fn test_search_mods() { 287 | let api = CurseForgeAPI::new(API_KEY.to_string()); 288 | let loader = ModLoader::Fabric; 289 | let mods = api 290 | .search_mods("1.21.4", loader, "Carpet", 10) 291 | .await 292 | .unwrap(); 293 | assert_eq!(!mods.is_empty(), true); 294 | } 295 | #[tokio::test] 296 | async fn test_get_mods() { 297 | std::panic::set_hook(Box::new(move |panic_info| { 298 | better_panic::Settings::auto() 299 | .most_recent_first(false) 300 | .lineno_suffix(true) 301 | .verbosity(better_panic::Verbosity::Full) 302 | .create_panic_handler()(panic_info); 303 | })); 304 | 305 | let api = CurseForgeAPI::new(API_KEY.to_string()); 306 | let mods = api 307 | .get_mods(&[349239u32, 349240u32] as &[u32]) 308 | .await 309 | .unwrap(); 310 | assert_eq!(!mods.is_empty(), true); 311 | } 312 | #[tokio::test] 313 | async fn full_test() -> Result<()> { 314 | color_eyre::install()?; 315 | let search = "carpet"; 316 | let loader = ModLoader::Fabric; 317 | let v = "1.21.4"; 318 | let api = CurseForgeAPI::new(API_KEY.to_string()); 319 | let mods = api.search_mods(v, loader, search, 10).await.unwrap(); 320 | println!("{:#?}", mods); 321 | let prompt = inquire::MultiSelect::new("Select mods", mods); 322 | let selected = prompt.prompt().unwrap(); 323 | for mod_ in selected { 324 | let version = mod_.get_version_and_loader(v).unwrap(); 325 | api.download_mod(mod_.id, version.file_id, PathBuf::from("mods")) 326 | .await?; 327 | } 328 | Ok(()) 329 | } 330 | #[tokio::test] 331 | async fn test_get_dependencies() { 332 | std::panic::set_hook(Box::new(move |panic_info| { 333 | better_panic::Settings::auto() 334 | .most_recent_first(false) 335 | .lineno_suffix(true) 336 | .verbosity(better_panic::Verbosity::Full) 337 | .create_panic_handler()(panic_info); 338 | })); 339 | let api = CurseForgeAPI::new(API_KEY.to_string()); 340 | // 447673 --> Sodium Extra 341 | let deps = api.get_dependencies(447673, "1.21.4").await.unwrap(); 342 | assert_eq!(!deps.is_empty(), true); 343 | } 344 | #[tokio::test] 345 | async fn test_fingerprint_specific_jar() { 346 | color_eyre::install().unwrap(); 347 | let api = CurseForgeAPI::new(API_KEY.to_string()); 348 | let jar_path = PathBuf::from( 349 | "/Users/jayansunil/Dev/rust/modder/tui/test/createaddition-1.19.2-1.2.3.jar", 350 | ); 351 | let fingerprint = MurmurHash2::hash(&get_jar_contents(&jar_path).unwrap()); 352 | dbg!(&fingerprint); 353 | // The fingerprint will be debug-printed by dbg!(&fingerprint) inside get_mod_from_file 354 | let mod_ = api.get_mod_from_file(jar_path).await.unwrap(); 355 | assert_eq!(mod_.name, "CreateAddition"); 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /core/src/modrinth_wrapper/modrinth.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | use crate::gh_releases::{self}; 3 | use crate::metadata::Error as MetadataError; 4 | use crate::{Link, ModLoader, calc_sha512}; 5 | use color_eyre::eyre::ContextCompat; 6 | use colored::Colorize; 7 | use futures::lock::Mutex; 8 | use serde::{Deserialize, Serialize}; 9 | use std::sync::Arc; 10 | use std::{fmt::Display, fs}; 11 | use tracing::{self, debug, error, info}; 12 | 13 | #[derive(thiserror::Error, Debug)] 14 | pub enum Error { 15 | #[error("Error sending the request. This may mean that the request was malformed: {0:?}")] 16 | RequestErr(#[from] reqwest::Error), 17 | #[error("Error deserializing the response: {0:?}")] 18 | SerdeErr(#[from] serde_json::Error), 19 | #[error("No versions found for mod {0}")] 20 | NoVersionsFound(String), 21 | // TODO: Move this to `lib.rs` 22 | #[error("Metadata error: {0}")] 23 | MetadataErr(#[from] MetadataError), 24 | #[error("Unknown error: {0}")] 25 | UnknownError(#[from] color_eyre::eyre::ErrReport), 26 | #[error("Error getting mod from github: {0}")] 27 | GithubError(#[from] gh_releases::Error), 28 | #[error("Error writing file: {0}")] 29 | IoError(#[from] std::io::Error), 30 | } 31 | 32 | type Result = color_eyre::Result; 33 | 34 | const GRAY: (u8, u8, u8) = (128, 128, 128); 35 | 36 | #[derive(Debug, Deserialize, Clone)] 37 | pub struct VersionData { 38 | name: Option, 39 | version_number: Option, 40 | game_versions: Option>, 41 | changelog: Option, 42 | pub dependencies: Option>, 43 | version_type: Option, 44 | loaders: Option>, 45 | featured: Option, 46 | status: Option, 47 | id: String, 48 | pub project_id: String, 49 | author_id: String, 50 | date_published: String, 51 | downloads: u32, 52 | changelog_url: Option, 53 | pub files: Option>, 54 | } 55 | 56 | #[derive(Debug, Deserialize, Clone, Eq, PartialEq)] 57 | pub struct Dependency { 58 | version_id: Option, 59 | project_id: Option, 60 | file_name: Option, 61 | dependency_type: Option, 62 | } 63 | 64 | #[derive(Debug, Deserialize, Clone)] 65 | pub struct File { 66 | pub hashes: FileHash, 67 | url: String, 68 | pub filename: String, 69 | primary: bool, 70 | size: u32, 71 | file_type: Option, 72 | } 73 | 74 | #[derive(Debug, Deserialize, Clone)] 75 | pub struct FileHash { 76 | pub sha512: String, 77 | sha1: String, 78 | } 79 | 80 | #[derive(Debug, Deserialize)] 81 | pub struct GetProject { 82 | id: String, 83 | slug: String, 84 | project_type: String, 85 | team: String, 86 | title: String, 87 | description: String, 88 | categories: Vec, 89 | additional_categories: Vec, 90 | client_side: String, 91 | server_side: String, 92 | body: String, 93 | status: String, 94 | requested_status: Option, 95 | issues_url: Option, 96 | source_url: Option, 97 | wiki_url: Option, 98 | discord_url: Option, 99 | donation_urls: Vec, 100 | icon_url: Option, 101 | color: Option, 102 | thread_id: String, 103 | monetization_status: Option, 104 | body_url: Option, 105 | moderator_message: Option, 106 | published: String, 107 | updated: String, 108 | approved: Option, 109 | queued: Option, 110 | downloads: u32, 111 | followers: u32, 112 | license: License, 113 | versions: Vec, 114 | game_versions: Vec, 115 | loaders: Vec, 116 | gallery: Vec, 117 | } 118 | 119 | #[derive(Debug, Deserialize)] 120 | #[serde(rename_all = "camelCase")] 121 | struct ModeratorMessage { 122 | message: String, 123 | body: Option, 124 | } 125 | 126 | #[derive(Debug, Deserialize)] 127 | #[serde(rename_all = "camelCase")] 128 | struct License { 129 | id: String, 130 | name: String, 131 | url: Option, 132 | } 133 | 134 | #[derive(Debug, Deserialize)] 135 | #[serde(rename_all = "camelCase")] 136 | struct DonationLink { 137 | id: String, 138 | platform: String, 139 | url: String, 140 | } 141 | 142 | #[derive(Debug, Deserialize, Clone)] 143 | #[serde(rename_all = "camelCase")] 144 | struct GalleryImage { 145 | url: String, 146 | featured: bool, 147 | title: Option, 148 | description: Option, 149 | created: String, 150 | ordering: Option, 151 | } 152 | 153 | impl GetProject { 154 | pub async fn from_id(id: &str) -> Option { 155 | let res = reqwest::get(format!("https://api.modrinth.com/v2/project/{}", id)).await; 156 | if res.is_err() { 157 | error!("Error getting project: {}", res.err().unwrap()); 158 | return None; 159 | } 160 | let res = res.unwrap(); 161 | let text = res.text().await.unwrap(); 162 | debug!(text); 163 | let res: Result = serde_json::from_str(&text).map_err(Error::SerdeErr); 164 | if res.is_err() { 165 | error!("Error parsing project: {}", res.err().unwrap()); 166 | return None; 167 | } 168 | Some(res.unwrap()) 169 | } 170 | pub fn get_title(&self) -> String { 171 | self.title.clone() 172 | } 173 | pub fn get_categories(&self) -> Vec { 174 | self.categories.clone() 175 | } 176 | pub fn get_slug(&self) -> String { 177 | self.slug.clone() 178 | } 179 | } 180 | 181 | pub struct Modrinth; 182 | 183 | impl Modrinth { 184 | async fn get_version_data( 185 | mod_name: &str, 186 | version: &str, 187 | mod_loader: &str, 188 | ) -> Result> { 189 | debug!(mod_name = ?mod_name, version = ?version, mod_loader = ?mod_loader); 190 | let versions = reqwest::get(format!( 191 | "https://api.modrinth.com/v2/project/{}/version?game_versions=[\"{}\"]&loaders=[\"{}\"]", 192 | mod_name, version, mod_loader.to_lowercase() 193 | )) 194 | .await 195 | .expect("Failed to get versions"); 196 | 197 | let versions = versions.text().await.unwrap(); 198 | debug!(versions = ?versions); 199 | serde_json::from_str(&versions).map_err(Error::SerdeErr) 200 | } 201 | pub async fn search_mods(query: &str, limit: u16, offset: u16) -> ProjectSearch { 202 | let client = reqwest::Client::new(); 203 | let res = client .get(format!("https://api.modrinth.com/v2/search?query={}&limit={}&index=relevance&facets=%5B%5B%22project_type%3Amod%22%5D%5D&offset={}",query,limit, offset )) .send().await.unwrap(); 204 | 205 | let res_text = res.text().await.unwrap(); 206 | 207 | let parsed: ProjectSearch = serde_json::from_str(&res_text).unwrap(); 208 | parsed 209 | } 210 | 211 | pub async fn get_version( 212 | mod_name: &str, 213 | version: &str, 214 | loader: ModLoader, 215 | ) -> Option { 216 | #[allow(clippy::unnecessary_to_owned)] 217 | let versions = Modrinth::get_version_data(mod_name, version, &loader.to_string()).await; 218 | if versions.is_err() { 219 | error!( 220 | "Error parsing versions for mod {}: {}. This may mean that this mod is not available for this version", 221 | mod_name, 222 | versions.err().unwrap() 223 | ); 224 | return None; 225 | } 226 | let versions = versions.unwrap(); 227 | 228 | if versions.is_empty() { 229 | error!("No versions found for mod {} for {}", mod_name, version); 230 | return None; 231 | } 232 | Some(versions[0].clone()) 233 | } 234 | 235 | pub async fn get_top_mods(limit: u16) -> Vec { 236 | let mut mods = Vec::new(); 237 | let mut handles = Vec::new(); 238 | let temp_mods = Arc::new(Mutex::new(Vec::new())); 239 | for i in 0..(limit / 100) { 240 | let temp_mods = Arc::clone(&temp_mods); 241 | let handle = tokio::spawn(async move { 242 | let parsed = Modrinth::search_mods("", 100, i * 100).await; 243 | let hits = parsed.hits; 244 | 245 | let mut temp_mods_guard = temp_mods.lock().await; 246 | temp_mods_guard.extend(hits); 247 | }); 248 | handles.push(handle); 249 | } 250 | info!(temp_mods = ?temp_mods.lock().await.len(), "Got mods"); 251 | 252 | if limit % 100 != 0 { 253 | let temp_mods = Arc::clone(&temp_mods.clone()); 254 | handles.push(tokio::spawn(async move { 255 | let res = Modrinth::search_mods("", limit % 100, (limit / 100) * 100).await; 256 | let hits = res.hits; 257 | let mut temp_mods = temp_mods.lock().await; 258 | temp_mods.extend(hits); 259 | })); 260 | } 261 | for handle in handles { 262 | handle.await.unwrap(); 263 | } 264 | mods.extend( 265 | Arc::clone(&temp_mods) 266 | .lock() 267 | .await 268 | .iter() 269 | .cloned() 270 | .collect::>(), 271 | ); 272 | mods 273 | } 274 | pub async fn download_dependencies( 275 | mod_: &Mod, 276 | version: &str, 277 | prev_deps: Arc>>, 278 | prefix: &str, 279 | loader: ModLoader, 280 | ) { 281 | let mod_ = Modrinth::get_version(&mod_.slug, version, loader.clone()).await; 282 | let mut prev_deps = prev_deps.lock().await; 283 | let mut handles = Vec::new(); 284 | 285 | if let Some(mod_) = mod_ { 286 | for dependency in mod_.dependencies.unwrap() { 287 | let loader = loader.clone(); 288 | if prev_deps.contains(&dependency) { 289 | info!( 290 | "Skipping dependency {}", 291 | dependency.file_name.unwrap_or("Unknown".to_string()) 292 | ); 293 | continue; 294 | } 295 | prev_deps.push(dependency.clone()); 296 | let dependency = 297 | Modrinth::get_version(&dependency.project_id.unwrap(), version, loader).await; 298 | 299 | if let Some(dependency) = dependency { 300 | info!( 301 | "Downloading dependency {}", 302 | dependency.clone().files.unwrap()[0].filename 303 | ); 304 | let prefix = prefix.to_string(); 305 | let handle = tokio::spawn(async move { 306 | download_file(&dependency.files.unwrap()[0], &prefix).await; 307 | }); 308 | handles.push(handle); 309 | } 310 | } 311 | } 312 | for handle in handles { 313 | handle.await.unwrap(); 314 | } 315 | } 316 | } 317 | 318 | #[derive(Debug, Serialize, Deserialize, Clone)] 319 | #[serde(rename_all = "snake_case")] 320 | pub struct Project { 321 | pub slug: String, 322 | pub title: String, 323 | pub description: String, 324 | pub categories: Vec, 325 | pub client_side: SupportLevel, 326 | pub server_side: SupportLevel, 327 | pub project_type: ProjectType, 328 | pub downloads: u64, 329 | pub icon_url: Option, 330 | pub color: Option, 331 | pub thread_id: Option, 332 | pub monetization_status: Option, 333 | pub project_id: String, 334 | pub author: String, 335 | pub display_categories: Vec, 336 | pub versions: Vec, 337 | pub follows: u64, 338 | pub date_created: String, 339 | pub date_modified: String, 340 | pub latest_version: Option, 341 | pub license: String, 342 | pub gallery: Vec, 343 | pub featured_gallery: Option, 344 | } 345 | 346 | impl Display for Project { 347 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 348 | write!(f, "{}", self.title) 349 | } 350 | } 351 | 352 | #[derive(Debug, Serialize, Deserialize, Clone)] 353 | #[serde(rename_all = "lowercase")] 354 | pub enum SupportLevel { 355 | Required, 356 | Optional, 357 | Unsupported, 358 | Unknown, 359 | } 360 | 361 | #[derive(Debug, Serialize, Deserialize, Clone)] 362 | #[serde(rename_all = "lowercase")] 363 | pub enum ProjectType { 364 | Mod, 365 | Modpack, 366 | Resourcepack, 367 | Shader, 368 | } 369 | 370 | #[derive(Debug, Serialize, Deserialize, Clone)] 371 | #[serde(rename_all = "kebab-case")] 372 | pub enum MonetizationStatus { 373 | Monetized, 374 | Demonetized, 375 | ForceDemonetized, 376 | } 377 | 378 | #[derive(Debug, Serialize, Deserialize)] 379 | pub struct ProjectSearch { 380 | pub hits: Vec, 381 | offset: u16, 382 | limit: u16, 383 | total_hits: u16, 384 | } 385 | 386 | #[derive(Debug, Clone, Hash, Eq, PartialEq)] 387 | pub struct Mod { 388 | pub slug: String, 389 | pub title: String, 390 | } 391 | 392 | impl From for Mod { 393 | fn from(project: Project) -> Self { 394 | Mod { 395 | slug: project.slug, 396 | title: project.title, 397 | } 398 | } 399 | } 400 | 401 | impl Display for Mod { 402 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 403 | write!(f, "{}", self.title) 404 | } 405 | } 406 | 407 | impl VersionData { 408 | pub async fn from_hash(hash: String) -> Result { 409 | // TODO: Add this to the API 410 | let res = reqwest::get(format!("https://api.modrinth.com/v2/version_file/{hash}")) 411 | .await 412 | .unwrap(); 413 | let res = res.text().await.unwrap(); 414 | let res: Result = serde_json::from_str(&res).map_err(Error::SerdeErr); 415 | res 416 | } 417 | pub fn format_verbose(&self, mod_name: &str, categories: &[String]) -> String { 418 | let mut output = String::new(); 419 | let url = format!("https://modrinth.com/mod/{}", self.project_id); 420 | let link = Link::new(url.clone(), url); 421 | output.push_str(&format!( 422 | "{} {}\n", 423 | mod_name.bold(), 424 | self.version_number 425 | .clone() 426 | .unwrap() 427 | .truecolor(GRAY.0, GRAY.1, GRAY.2) 428 | )); 429 | output.push_str(&format!("\tURL: {}\n", link.to_string().blue(),)); 430 | output.push_str(&format!("\tMod version: {}\n", self.name.clone().unwrap())); 431 | output.push_str(&format!( 432 | "\tGame versions: {}\n", 433 | self.game_versions.clone().unwrap().join(", ").green() 434 | )); 435 | output.push_str(&format!( 436 | "\tLoaders: {}\n", 437 | self.loaders.clone().unwrap().join(", ").cyan() 438 | )); 439 | output.push_str(&format!( 440 | "\tCategories: {}\n", 441 | categories.join(", ").yellow() 442 | )); 443 | output.push_str(&format!("\tStatus: {}\n", self.status.clone().unwrap())); 444 | output.push_str(&format!( 445 | "\tDate published: {}\n", 446 | self.date_published.clone() 447 | )); 448 | output.push_str(&format!("\tDownloads: {}\n\n", self.downloads.clone())); 449 | 450 | output 451 | } 452 | pub fn format(&self, mod_name: &str) -> String { 453 | let mut output = String::new(); 454 | let url = format!("https://modrinth.com/mod/{}", self.project_id); 455 | let link = Link::new(mod_name.to_string(), url); 456 | let version_type = match self 457 | .version_type 458 | .clone() 459 | .unwrap_or_default() 460 | .to_uppercase() 461 | .as_str() 462 | { 463 | "RELEASE" => "RELEASE".green(), 464 | "BETA" => "BETA".yellow(), 465 | "ALPHA" => "ALPHA".red(), 466 | _ => "UNKNOWN".cyan(), 467 | }; 468 | output.push_str(&format!( 469 | "{}\t{}\t{}\n", 470 | version_type, 471 | self.project_id.truecolor(GRAY.0, GRAY.1, GRAY.2), 472 | link.to_string().bold() 473 | )); 474 | 475 | output 476 | } 477 | pub fn get_game_versions(&self) -> Option> { 478 | self.game_versions.clone() 479 | } 480 | pub fn get_version(&self) -> String { 481 | self.version_number.clone().unwrap_or_default() 482 | } 483 | pub fn get_version_type(&self) -> String { 484 | self.version_type.clone().unwrap_or_default() 485 | } 486 | } 487 | 488 | pub async fn update_from_file( 489 | filename: &str, 490 | new_version: &str, 491 | prefix: &str, 492 | loader: Option, 493 | ) -> Result<()> { 494 | let hash = calc_sha512(filename); 495 | let version_data = VersionData::from_hash(hash).await?; 496 | let loader = if let Some(loader) = loader { 497 | loader 498 | } else { 499 | match version_data 500 | .loaders 501 | .unwrap_or(vec!["fabric".to_string()]) 502 | .first() 503 | .context("No loader found")? 504 | .as_str() 505 | { 506 | "fabric" => ModLoader::Fabric, 507 | "forge" => ModLoader::Forge, 508 | "quilt" => ModLoader::Quilt, 509 | "neoforge" => ModLoader::NeoForge, 510 | "cauldron" => ModLoader::Cauldron, 511 | "liteloader" => ModLoader::LiteLoader, 512 | _ => ModLoader::Any, 513 | } 514 | }; 515 | 516 | let new_version_data = 517 | Modrinth::get_version(&version_data.project_id, new_version, loader).await; 518 | 519 | let Some(new_version_data) = new_version_data else { 520 | return Err(Error::NoVersionsFound(filename.to_string())); 521 | }; 522 | 523 | download_file(&new_version_data.clone().files.unwrap()[0], prefix).await; 524 | 525 | Ok(()) 526 | } 527 | 528 | pub async fn download_file(file: &File, prefix: &str) { 529 | let file_content = reqwest::get(file.url.clone()).await.unwrap(); 530 | fs::write( 531 | format!("{}/{}", prefix, file.filename.clone()), 532 | file_content.bytes().await.unwrap(), 533 | ) 534 | .unwrap(); 535 | } 536 | -------------------------------------------------------------------------------- /core/src/actions.rs: -------------------------------------------------------------------------------- 1 | use crate::modrinth_wrapper::modrinth::Mod; 2 | use cli::Source; 3 | use color_eyre::eyre::bail; 4 | use colored::Colorize; 5 | use curseforge_wrapper::{API_KEY, CurseForgeAPI, CurseForgeMod}; 6 | use gh_releases::GHReleasesAPI; 7 | use itertools::Itertools; 8 | use metadata::Metadata; 9 | use modrinth_wrapper::modrinth::{self, VersionData}; 10 | use modrinth_wrapper::modrinth::{GetProject, Modrinth}; 11 | use percent_encoding::percent_decode; 12 | use std::collections::HashMap; 13 | use std::fs; 14 | use std::io::Write; 15 | use std::path::PathBuf; 16 | use tabwriter::TabWriter; 17 | use tokio::task::JoinHandle; 18 | 19 | use crate::*; 20 | const GRAY: (u8, u8, u8) = (128, 128, 128); 21 | 22 | pub async fn run(cli: Cli) -> color_eyre::Result<()> { 23 | let dependencies = Arc::new(Mutex::new(Vec::new())); 24 | match cli.command { 25 | Commands::QuickAdd { 26 | version, 27 | limit, 28 | loader, 29 | } => { 30 | let version = if let Some(version) = version { 31 | version 32 | } else { 33 | inquire::Text::new("Version").prompt().unwrap() 34 | }; 35 | let mods: Vec = Modrinth::get_top_mods(limit).await; 36 | let mods = mods 37 | .into_iter() 38 | .map(|mod_| mod_.into()) 39 | .collect::>(); 40 | let extras = vec![ 41 | Mod { 42 | slug: "anti-xray".into(), 43 | title: "Anti Xray".into(), 44 | }, 45 | Mod { 46 | slug: "appleskin".into(), 47 | title: "Apple Skin".into(), 48 | }, 49 | Mod { 50 | slug: "carpet-extra".into(), 51 | title: "Carpet Extra".into(), 52 | }, 53 | Mod { 54 | slug: "easyauth".into(), 55 | title: "Easy Auth".into(), 56 | }, 57 | Mod { 58 | slug: "essential-commands".into(), 59 | title: "Essential Commands".into(), 60 | }, 61 | Mod { 62 | slug: "fabric-carpet".into(), 63 | title: "Fabric Carpet".into(), 64 | }, 65 | Mod { 66 | slug: "geyser".into(), 67 | title: "Geyser".into(), 68 | }, 69 | Mod { 70 | slug: "origins".into(), 71 | title: "Origins".into(), 72 | }, 73 | Mod { 74 | slug: "skinrestorer".into(), 75 | title: "Skin Restorer".into(), 76 | }, 77 | Mod { 78 | slug: "status".into(), 79 | title: "Status".into(), 80 | }, 81 | ]; 82 | let mods = mods 83 | .into_iter() 84 | .chain(extras.into_iter()) 85 | .collect::>(); 86 | let mods = mods.into_iter().collect::>(); 87 | let prompt = inquire::MultiSelect::new("Select Mods", mods); 88 | let mods = prompt.prompt().unwrap(); 89 | let mut handles = Vec::new(); 90 | for mod_ in mods { 91 | let version = version.clone(); 92 | let dependencies = Arc::clone(&dependencies); 93 | let loader = loader.clone(); 94 | let handle = tokio::spawn(async move { 95 | let version_data = 96 | Modrinth::get_version(&mod_.slug, &version, loader.clone()).await; 97 | if let Some(version_data) = version_data { 98 | info!("Downloading {}", mod_.title); 99 | modrinth::download_file(&version_data.clone().files.unwrap()[0], "./") 100 | .await; 101 | Modrinth::download_dependencies( 102 | &mod_, 103 | &version, 104 | dependencies, 105 | "./", 106 | loader, 107 | ) 108 | .await; 109 | } 110 | }); 111 | handles.push(handle); 112 | } 113 | for handle in handles { 114 | handle.await.unwrap(); 115 | } 116 | return Ok(()); 117 | } 118 | Commands::Update { 119 | dir, 120 | version, 121 | delete_previous, 122 | token, 123 | source, 124 | other_sources, 125 | loader, 126 | } => { 127 | let version = if let Some(version) = version { 128 | version 129 | } else { 130 | inquire::Text::new("Version").prompt().unwrap() 131 | }; 132 | let update_dir = dir.into_os_string().into_string().unwrap(); 133 | let mut github = GHReleasesAPI::new(); 134 | if let Some(token) = token { 135 | github.token(token.clone()); 136 | } 137 | let curseforge = CurseForgeAPI::new(API_KEY.to_string()); 138 | 139 | modder::update_dir( 140 | &mut github, 141 | curseforge, 142 | &update_dir, 143 | &version, 144 | delete_previous, 145 | &update_dir, 146 | source, 147 | other_sources, 148 | loader, 149 | ) 150 | .await?; 151 | } 152 | Commands::Add { 153 | mod_, 154 | version, 155 | source, 156 | token, 157 | loader, 158 | dir, 159 | } => { 160 | let version = if let Some(version) = version { 161 | version 162 | } else { 163 | inquire::Text::new("Version").prompt().unwrap() 164 | }; 165 | let source = match source { 166 | Some(source) => source, 167 | None => { 168 | if mod_.contains('/') { 169 | Source::Github 170 | } else { 171 | Source::Modrinth 172 | } 173 | } 174 | }; 175 | match source { 176 | Source::Github => { 177 | let mod_ = mod_.split('/').collect_vec(); 178 | let mut gh = GHReleasesAPI::new(); 179 | if let Some(token) = token { 180 | gh.token(token); 181 | } 182 | let releases = gh.get_releases(mod_[0], mod_[1]).await.unwrap(); 183 | // TODO: Add support for other loaders 184 | let release = 185 | gh_releases::get_mod_from_release(&releases, "fabric", &version).await?; 186 | let url = release.get_download_url().unwrap(); 187 | let file_name = 188 | percent_decode(url.path_segments().unwrap().last().unwrap().as_bytes()) 189 | .decode_utf8_lossy() 190 | .to_string(); 191 | let path = format!("./{}", file_name); 192 | info!("Downloading {}", file_name); 193 | release 194 | .download(path.clone().into(), mod_.join("/")) 195 | .await?; 196 | } 197 | Source::Modrinth => { 198 | let res = Modrinth::search_mods(&mod_, 100, 0).await; 199 | let hits = res.hits; 200 | if hits.is_empty() { 201 | bail!("Could not find mod {}", mod_); 202 | } 203 | if hits.len() == 1 { 204 | let mod_ = hits[0].clone(); 205 | let version_data = 206 | Modrinth::get_version(&mod_.slug, &version, loader.clone()).await; 207 | if let Some(version_data) = version_data { 208 | info!("Downloading {}", mod_.title); 209 | modrinth::download_file(&version_data.clone().files.unwrap()[0], "./") 210 | .await; 211 | Modrinth::download_dependencies( 212 | &mod_.into(), 213 | &version, 214 | dependencies.clone(), 215 | "./", 216 | loader, 217 | ) 218 | .await; 219 | return Ok(()); 220 | } else { 221 | info!( 222 | "Could not find version {} for {}, trying curseforge", 223 | version, mod_.title 224 | ); 225 | bail!("Could not find version {} for {}", version, mod_.title); 226 | } 227 | } 228 | let prompt = inquire::MultiSelect::new("Select Mods", hits); 229 | let hits = prompt.prompt().unwrap(); 230 | let mut handles = Vec::new(); 231 | for hit in hits { 232 | let loader = loader.clone(); 233 | let version = version.clone(); 234 | let dependencies = Arc::clone(&dependencies); 235 | let handle = tokio::spawn(async move { 236 | let version_data = 237 | Modrinth::get_version(&hit.slug, &version, loader.clone()).await; 238 | if let Some(version_data) = version_data { 239 | info!("Downloading {}", hit.title); 240 | modrinth::download_file( 241 | &version_data.clone().files.unwrap()[0], 242 | "./", 243 | ) 244 | .await; 245 | Modrinth::download_dependencies( 246 | &hit.into(), 247 | &version, 248 | dependencies, 249 | "./", 250 | loader, 251 | ) 252 | .await; 253 | } else { 254 | bail!("Could not find version {} for {}", version, hit.title); 255 | } 256 | Ok(()) 257 | }); 258 | 259 | handles.push(handle); 260 | } 261 | for handle in handles { 262 | handle.await??; 263 | } 264 | } 265 | Source::CurseForge => { 266 | let api = CurseForgeAPI::new(API_KEY.to_string()); 267 | let dependencies = Arc::new(Mutex::new(Vec::new())); 268 | let mods = api.search_mods(&version, loader, &mod_, 30).await.unwrap(); 269 | let prompt = inquire::MultiSelect::new("Select mods", mods); 270 | let selected = prompt.prompt().unwrap(); 271 | let mut handles = Vec::new(); 272 | let dir = Arc::new(dir.clone()); 273 | for mod_ in selected { 274 | let dependencies = Arc::clone(&dependencies); 275 | let version = version.clone(); 276 | let api = api.clone(); 277 | let dir = dir.clone(); 278 | let handle: JoinHandle> = tokio::spawn(async move { 279 | info!("Downloading {}", mod_.name); 280 | let v = mod_.get_version_and_loader(&version).unwrap(); 281 | 282 | api.download_mod(mod_.id, v.file_id, dir.to_path_buf()) 283 | .await?; 284 | let deps = api.get_dependencies(mod_.id, &version).await?; 285 | for dep in deps { 286 | if dependencies.lock().await.contains(&dep.id) { 287 | info!("Skipping dependency {}", dep.name); 288 | } 289 | info!("Downloading dependency {}", dep.name); 290 | let v = dep.get_version_and_loader(&version).unwrap(); 291 | api.download_mod(dep.id, v.file_id, dir.to_path_buf()) 292 | .await?; 293 | } 294 | Ok(()) 295 | }); 296 | handles.push(handle); 297 | } 298 | for handle in handles { 299 | handle.await?.unwrap(); 300 | } 301 | } 302 | } 303 | } 304 | Commands::Toggle { version: _, dir } => toggle(dir)?, 305 | Commands::List { dir, verbose } => { 306 | let files = fs::read_dir(dir).unwrap(); 307 | 308 | let mut output = String::new(); 309 | let mut handles = Vec::new(); 310 | for f in files { 311 | let handle = tokio::spawn(async move { 312 | let Ok(f) = f else { 313 | return None; 314 | }; 315 | let path = f.path(); 316 | let extension = path 317 | .extension() 318 | .unwrap_or_default() 319 | .to_str() 320 | .unwrap_or_default(); 321 | 322 | if extension != "jar" && extension != "disabled" { 323 | return None; 324 | } 325 | 326 | let path_str = path.to_str().unwrap_or_default().to_string(); 327 | let hash = calc_sha512(&path_str); 328 | let version_data = VersionData::from_hash(hash).await; 329 | if version_data.is_err() { 330 | println!(" "); 331 | let metadata = Metadata::get_all_metadata(path_str.clone().into()); 332 | if metadata.is_err() { 333 | return None; 334 | } 335 | let Ok(metadata) = metadata else { 336 | return None; 337 | }; 338 | let source = metadata.get("source")?; 339 | if source.is_empty() { 340 | return None; 341 | } 342 | let repo = metadata.get("repo")?; 343 | let repo_name = repo.split('/').last()?; 344 | let link = Link::new( 345 | repo_name.to_string(), 346 | format!("https://github.com/{}", repo), 347 | ); 348 | let out = if verbose { 349 | format!( 350 | "{} {} {}", 351 | "GITHUB".yellow(), 352 | repo.truecolor(GRAY.0, GRAY.1, GRAY.2), 353 | link.to_string().bold() 354 | ) 355 | } else { 356 | format!( 357 | "{}\t{}\t{}", 358 | "GITHUB".yellow(), 359 | repo.truecolor(GRAY.0, GRAY.1, GRAY.2), 360 | link.to_string().bold() 361 | ) 362 | }; 363 | return Some(out); 364 | } 365 | let Ok(version_data) = version_data else { 366 | return None; 367 | }; 368 | let project = GetProject::from_id(&version_data.project_id).await?; 369 | let out = if verbose { 370 | version_data.format_verbose(&project.get_title(), &project.get_categories()) 371 | } else { 372 | version_data.format(&project.get_title()) 373 | }; 374 | Some(out) 375 | }); 376 | handles.push(handle); 377 | } 378 | for handle in handles { 379 | let out = match handle.await { 380 | Ok(out) => out, 381 | Err(_) => continue, 382 | }; 383 | output.push_str(&out.unwrap_or_default()); 384 | } 385 | 386 | let mut tw = TabWriter::new(vec![]); 387 | tw.write_all(output.as_bytes()).unwrap(); 388 | tw.flush().unwrap(); 389 | let written = String::from_utf8(tw.into_inner().unwrap()).unwrap(); 390 | println!("{}", written); 391 | } 392 | }; 393 | Ok(()) 394 | } 395 | 396 | fn toggle(dir: PathBuf) -> color_eyre::Result<()> { 397 | let files = fs::read_dir(dir.clone()).unwrap(); 398 | let toggle_map = files.map(|f| { 399 | let f = f.unwrap(); 400 | let path = f.path().to_str().unwrap().to_string(); 401 | let file_name = f.file_name().to_string_lossy().to_string(); 402 | if file_name.ends_with(".disabled") { 403 | (path, false) 404 | } else { 405 | (path, true) 406 | } 407 | }); 408 | let toggle_map = toggle_map.collect::>(); 409 | let filenames = toggle_map 410 | .keys() 411 | .map(|f| f.split('/').last().unwrap()) 412 | .collect::>(); 413 | let defaults = filenames 414 | .iter() 415 | .enumerate() 416 | .filter_map(|(i, f)| { 417 | let path = &format!("{}{}", dir.to_str().unwrap(), f); 418 | if *toggle_map.get(path).unwrap() { 419 | Some(i) 420 | } else { 421 | None 422 | } 423 | }) 424 | .collect::>(); 425 | 426 | let prompt = inquire::MultiSelect::new("Select Mods", filenames).with_default(&defaults); 427 | let filenames = prompt.prompt().unwrap(); 428 | for filename in toggle_map.iter() { 429 | let name = &filename.0.split('/').last().unwrap_or(""); 430 | let predicate = !filenames.contains(name); 431 | let path = filename.0.clone(); 432 | if predicate { 433 | if !path.ends_with(".disabled") { 434 | fs::rename(&path, format!("{}.disabled", path)).unwrap(); 435 | } 436 | continue; 437 | } 438 | if path.ends_with(".disabled") { 439 | fs::rename(&path, path.replace(".disabled", "")).unwrap(); 440 | } 441 | } 442 | Ok(()) 443 | } 444 | -------------------------------------------------------------------------------- /tui/src/components/list.rs: -------------------------------------------------------------------------------- 1 | use super::Component; 2 | use crate::{action::Action, app::Mode, config::Config}; 3 | use color_eyre::Result; 4 | use crossterm::event::KeyCode; 5 | use modder::{ 6 | calc_sha512, 7 | cli::Source, 8 | curseforge_wrapper::{API_KEY, CurseForgeAPI}, 9 | metadata::Metadata, 10 | modrinth_wrapper::modrinth::{GetProject, VersionData}, 11 | }; 12 | use ratatui::{prelude::*, widgets::*}; 13 | use std::{fs, path::PathBuf}; 14 | use style::palette::tailwind::SLATE; 15 | use tokio::sync::mpsc::UnboundedSender; 16 | use tracing::{debug, error}; 17 | use tui_input::Input; 18 | use tui_input::backend::crossterm::EventHandler; 19 | 20 | #[derive(Default)] 21 | pub struct ListComponent { 22 | command_tx: Option>, 23 | config: Config, 24 | list: ModList, 25 | mode: Mode, 26 | enabled: bool, 27 | state: State, 28 | input: Input, 29 | dir: PathBuf, 30 | } 31 | 32 | #[derive(Debug, Clone, Default)] 33 | struct ModList { 34 | filtered_items: Vec, 35 | list_items: Vec, 36 | state: ListState, 37 | } 38 | 39 | #[derive(Debug, Clone, Default, PartialEq)] 40 | enum State { 41 | #[default] 42 | Normal, 43 | Search, 44 | } 45 | 46 | #[derive(Debug, Clone, Default)] 47 | struct ModListItem { 48 | name: String, 49 | source: Source, 50 | project_id: String, 51 | version: String, 52 | game_version: Option, 53 | category: Option, 54 | version_type: String, 55 | } 56 | 57 | const SELECTED_STYLE: Style = Style::new().bg(SLATE.c800).add_modifier(Modifier::BOLD); 58 | 59 | impl FromIterator for ModList { 60 | fn from_iter>(iter: I) -> Self { 61 | let items = iter.into_iter().collect(); 62 | let state = ListState::default(); 63 | Self { 64 | filtered_items: Vec::new(), 65 | list_items: items, 66 | state, 67 | } 68 | } 69 | } 70 | 71 | impl ModList { 72 | fn select_none(&mut self) { 73 | self.state.select(None); 74 | } 75 | 76 | fn select_next(&mut self) { 77 | self.state.select_next(); 78 | } 79 | fn select_previous(&mut self) { 80 | self.state.select_previous(); 81 | } 82 | 83 | fn select_first(&mut self) { 84 | self.state.select_first(); 85 | } 86 | 87 | fn select_last(&mut self) { 88 | self.state.select_last(); 89 | } 90 | } 91 | 92 | impl ListComponent { 93 | pub async fn new(dir: PathBuf) -> Self { 94 | let dir_clone = dir.clone(); 95 | 96 | let items = tokio::spawn(async move { get_mods(dir_clone.clone()).await }).await; 97 | let items = items.unwrap_or(Vec::new()); 98 | 99 | ListComponent { 100 | list: ModList::from_iter(items), 101 | mode: Mode::List, 102 | enabled: true, 103 | dir, 104 | ..Default::default() 105 | } 106 | } 107 | pub fn toggle_state(&mut self) { 108 | self.state = match self.state { 109 | State::Normal => State::Search, 110 | State::Search => State::Normal, 111 | }; 112 | } 113 | } 114 | 115 | impl Component for ListComponent { 116 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 117 | self.command_tx = Some(tx); 118 | Ok(()) 119 | } 120 | fn get_mode(&self) -> Mode { 121 | self.mode 122 | } 123 | 124 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 125 | self.config = config; 126 | Ok(()) 127 | } 128 | 129 | fn update(&mut self, action: Action) -> Result> { 130 | match action { 131 | Action::Tick => {} 132 | Action::Render => { 133 | // add any logic here that should run on every render 134 | } 135 | Action::Mode(mode) => { 136 | self.enabled = mode == self.mode; 137 | if self.enabled { 138 | self.list.select_first(); 139 | self.list.filtered_items = Vec::new(); 140 | let dir = self.dir.clone(); 141 | self.list.list_items = 142 | futures::executor::block_on(async move { get_mods(dir).await }); 143 | } 144 | } 145 | _ => {} 146 | } 147 | Ok(None) 148 | } 149 | fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) -> Result> { 150 | if !self.enabled { 151 | return Ok(None); 152 | } 153 | if self.state == State::Search { 154 | match key.code { 155 | KeyCode::Tab | KeyCode::Esc => self.toggle_state(), 156 | KeyCode::Enter => {} 157 | _ => { 158 | self.input.handle_event(&crossterm::event::Event::Key(key)); 159 | let val = self.input.value(); 160 | let filtered_items = self 161 | .list 162 | .list_items 163 | .iter() 164 | .filter(|item| item.name.to_lowercase().contains(&val.to_lowercase())) 165 | .cloned() 166 | .collect(); 167 | self.list.filtered_items = filtered_items; 168 | self.list.state.select_first(); 169 | } 170 | } 171 | return Ok(None); 172 | } 173 | match key.code { 174 | KeyCode::Char('h') | KeyCode::Left => self.list.select_none(), 175 | KeyCode::Char('j') | KeyCode::Down => self.list.select_next(), 176 | KeyCode::Char('k') | KeyCode::Up => self.list.select_previous(), 177 | KeyCode::Char('g') | KeyCode::Home => self.list.select_first(), 178 | KeyCode::Char('G') | KeyCode::End => self.list.select_last(), 179 | KeyCode::Esc => { 180 | self.command_tx.clone().unwrap().send(Action::ClearScreen)?; 181 | return Ok(Some(Action::Mode(Mode::Home))); 182 | } 183 | KeyCode::Char('q') => return Ok(Some(Action::Quit)), 184 | KeyCode::Char('/') => self.toggle_state(), 185 | _ => {} 186 | }; 187 | Ok(None) 188 | } 189 | 190 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 191 | let items: Vec = if self.list.filtered_items.is_empty() { 192 | self.list.list_items.iter().map(ListItem::from).collect() 193 | } else { 194 | self.list 195 | .filtered_items 196 | .iter() 197 | .map(ListItem::from) 198 | .collect() 199 | }; 200 | let list = List::new(items) 201 | .highlight_style(SELECTED_STYLE) 202 | .highlight_symbol("> ") 203 | .highlight_spacing(HighlightSpacing::Always) 204 | .block( 205 | Block::new() 206 | .borders(Borders::ALL) 207 | .padding(Padding::uniform(1)) 208 | .border_type(BorderType::Rounded) 209 | .title_top(Line::raw("Mods").centered().bold()), 210 | ); 211 | 212 | let [top, center] = 213 | Layout::vertical([Constraint::Min(3), Constraint::Percentage(100)]).areas(area); 214 | let [left, right] = 215 | Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]) 216 | .areas(center); 217 | let [lt, lb] = 218 | Layout::vertical([Constraint::Percentage(100), Constraint::Min(3)]).areas(left); 219 | let top_text = Paragraph::new("List") 220 | .bold() 221 | .block( 222 | Block::default() 223 | .padding(Padding::symmetric(1, 0)) 224 | .border_type(BorderType::Rounded) 225 | .borders(Borders::ALL), 226 | ) 227 | .style(Style::default().fg(Color::White)); 228 | let right_widget = 229 | if self.list.state.selected().is_some() && !self.list.list_items.is_empty() { 230 | let selected = self.list.state.selected().unwrap(); 231 | let idx = selected.min(self.list.list_items.len() - 1); 232 | let item = if self.list.filtered_items.is_empty() { 233 | &self.list.list_items[idx] 234 | } else { 235 | &self.list.filtered_items[idx] 236 | }; 237 | 238 | let name_span = Span::styled( 239 | item.name.clone() + " ", 240 | Style::default().add_modifier(Modifier::BOLD), 241 | ); 242 | let version_span = Span::styled( 243 | item.version.clone(), 244 | Style::default().add_modifier(Modifier::DIM), 245 | ); 246 | let top_line = Line::from(vec![name_span, version_span]); 247 | 248 | let new_link = if item.source == Source::Modrinth { 249 | format!("https://modrinth.com/mod/{}", item.project_id) 250 | } else { 251 | format!("https://github.com/{}", item.project_id) 252 | }; 253 | 254 | let lines = vec![ 255 | top_line, 256 | Line::from(vec![ 257 | Span::styled("\tSource: ", Style::default().add_modifier(Modifier::BOLD)), 258 | Span::raw(item.source.to_string()), 259 | ]), 260 | Line::from(vec![ 261 | Span::styled( 262 | "\tVersion: ", 263 | Style::default() 264 | .fg(Color::Yellow) 265 | .add_modifier(Modifier::BOLD), 266 | ), 267 | Span::raw(&item.version), 268 | ]), 269 | Line::from(vec![ 270 | Span::styled( 271 | "\tURL: ", 272 | Style::default() 273 | .fg(Color::Cyan) 274 | .add_modifier(Modifier::BOLD), 275 | ), 276 | Span::raw(new_link.to_string()), 277 | ]), 278 | Line::from(vec![ 279 | Span::styled( 280 | "\tMod version: ", 281 | Style::default().add_modifier(Modifier::BOLD), 282 | ), 283 | Span::raw(format!("[{}]", item.version)), 284 | ]), 285 | Line::from(vec![ 286 | Span::styled( 287 | "\tGame versions: ", 288 | Style::default().add_modifier(Modifier::BOLD), 289 | ), 290 | Span::raw(item.game_version.clone().unwrap_or_else(|| "-".to_string())), 291 | ]), 292 | Line::from(vec![ 293 | Span::styled("\tLoaders: ", Style::default().add_modifier(Modifier::BOLD)), 294 | Span::raw(item.category.clone().unwrap_or_else(|| "-".to_string())), 295 | ]), 296 | Line::from(vec![ 297 | Span::styled( 298 | "\tCategories: ", 299 | Style::default().add_modifier(Modifier::BOLD), 300 | ), 301 | Span::raw(item.category.clone().unwrap_or_else(|| "-".to_string())), 302 | ]), 303 | Line::from(vec![ 304 | Span::styled( 305 | "\tStatus: ", 306 | Style::default() 307 | .fg(Color::Green) 308 | .add_modifier(Modifier::BOLD), 309 | ), 310 | Span::raw(item.version_type.clone()), 311 | ]), 312 | // Add more fields as needed 313 | ]; 314 | let para = Paragraph::new(lines).alignment(Alignment::Left); 315 | para 316 | } else { 317 | Paragraph::new(Span::raw("No mod selected")).alignment(Alignment::Left) 318 | }; 319 | let right_widget = right_widget.block( 320 | Block::new() 321 | .borders(Borders::ALL) 322 | .padding(Padding::uniform(1)) 323 | .title_top(Line::raw("info").centered().bold()) 324 | .border_type(BorderType::Rounded), 325 | ); 326 | let style = match self.state { 327 | State::Normal => Style::default(), 328 | State::Search => Color::Yellow.into(), 329 | }; 330 | let input = Paragraph::new(self.input.value()) 331 | .style(style) 332 | .block(Block::bordered().title("Input")); 333 | frame.render_widget(input, lb); 334 | frame.render_widget(top_text, top); 335 | frame.render_widget(right_widget, right); 336 | frame.render_stateful_widget(list, lt, &mut self.list.state); 337 | Ok(()) 338 | } 339 | } 340 | 341 | impl From<&ModListItem> for ListItem<'_> { 342 | fn from(value: &ModListItem) -> Self { 343 | ListItem::new(value.format()) 344 | } 345 | } 346 | 347 | #[allow(clippy::needless_lifetimes)] 348 | impl<'a> ModListItem { 349 | fn format(&self) -> Line<'a> { 350 | let version_type_style = match self.version_type.to_uppercase().as_str() { 351 | "RELEASE" => Style::default().fg(Color::Green), 352 | "BETA" => Style::default().fg(Color::Yellow), 353 | "ALPHA" => Style::default().fg(Color::Red), 354 | "GITHUB" => Style::default().fg(Color::Cyan), 355 | _ => Style::default().fg(Color::Cyan), 356 | }; 357 | let version_type_text = match self.version_type.to_uppercase().as_str() { 358 | "RELEASE" => "RELEASE", 359 | "BETA" => "BETA ", 360 | "ALPHA" => "ALPHA ", 361 | "GITHUB" => "GITHUB ", 362 | _ => "UNKNOWN", 363 | }; 364 | let span = Span::styled(version_type_text.to_string() + " ", version_type_style); 365 | let id_span = Span::styled( 366 | self.project_id.clone() + " ", 367 | Style::default().add_modifier(Modifier::DIM), 368 | ); 369 | let name = self.name.clone(); 370 | let name_span = Span::styled(name.clone(), Style::default().add_modifier(Modifier::BOLD)); 371 | Line::from(vec![span, id_span, name_span]) 372 | } 373 | } 374 | 375 | async fn get_mods(dir: PathBuf) -> Vec { 376 | let files = fs::read_dir(dir).unwrap(); 377 | 378 | let regex = regex::Regex::new(r#"\b\d+\.\d+(?:\.\d+)?(?:-(?:pre|rc)\d+)?\b"#); 379 | let mut output = Vec::new(); 380 | let mut handles = Vec::new(); 381 | for f in files { 382 | let regex = regex.clone(); 383 | let handle = tokio::spawn(async move { 384 | if f.is_err() { 385 | return None; 386 | } 387 | let f = f.unwrap(); 388 | let path = f.path(); 389 | let extension = path 390 | .extension() 391 | .unwrap_or_default() 392 | .to_str() 393 | .unwrap_or_default(); 394 | 395 | if extension != "jar" && extension != "disabled" { 396 | return None; 397 | } 398 | 399 | let path_str = path.to_str().unwrap_or_default().to_string(); 400 | let hash = calc_sha512(&path_str); 401 | let version_data = VersionData::from_hash(hash).await; 402 | if version_data.is_err() { 403 | debug!(path = ?path); 404 | let cf = CurseForgeAPI::new(API_KEY.to_string()); 405 | let mod_ = cf.get_mod_from_file(path.clone()).await; 406 | let file = cf.get_version_from_file(path.clone()).await; 407 | if mod_.is_err() || file.is_err() { 408 | debug!(path = ?path); 409 | let metadata = Metadata::get_all_metadata(path_str.clone().into()); 410 | if metadata.is_err() { 411 | error!(version_data = ?version_data, "Failed to get version data for {}", path_str); 412 | return None; 413 | } 414 | let metadata = metadata.unwrap(); 415 | let source = metadata.get("source").unwrap(); 416 | if source.is_empty() { 417 | error!(version_data = ?version_data, "Failed to get version data for {}", path_str); 418 | return None; 419 | } 420 | let repo = metadata.get("repo").unwrap(); 421 | let repo_name = repo.split('/').last().unwrap(); 422 | let game_version = regex.unwrap().find(&path_str).unwrap().as_str().to_string(); 423 | let out = ModListItem { 424 | name: repo_name.to_string(), 425 | source: Source::Github, 426 | version: game_version, 427 | game_version: None, 428 | category: None, 429 | version_type: "GITHUB".to_string(), 430 | project_id: repo.to_string(), 431 | }; 432 | return Some(out); 433 | } 434 | let Ok(mod_) = mod_ else { 435 | return None; 436 | }; 437 | let Ok(file) = file else { 438 | return None; 439 | }; 440 | debug!(mod_curseforge = ?mod_); 441 | let out = ModListItem { 442 | name: mod_.name, 443 | source: Source::CurseForge, 444 | version: file.id.to_string(), 445 | game_version: Some(file.game_versions.join(", ")), 446 | category: Some(mod_.categories.iter().map(|c| c.name.clone()).collect()), 447 | version_type: "CF".to_string(), 448 | project_id: mod_.slug, 449 | }; 450 | return Some(out); 451 | } 452 | let version_data = version_data.unwrap(); 453 | let project = GetProject::from_id(&version_data.project_id).await?; 454 | 455 | let out = ModListItem { 456 | name: project.get_title(), 457 | source: Source::Modrinth, 458 | game_version: Some( 459 | version_data 460 | .get_game_versions() 461 | .unwrap_or(Vec::new()) 462 | .join(", "), 463 | ), 464 | version: version_data.get_version(), 465 | category: Some(project.get_categories().join(", ")), 466 | version_type: version_data.get_version_type(), 467 | project_id: version_data.project_id, 468 | }; 469 | 470 | Some(out) 471 | }); 472 | handles.push(handle); 473 | } 474 | for handle in handles { 475 | let out = match handle.await { 476 | Ok(out) => out, 477 | Err(_) => continue, 478 | }; 479 | let Some(out) = out else { 480 | continue; 481 | }; 482 | output.push(out); 483 | } 484 | output 485 | } 486 | -------------------------------------------------------------------------------- /tui/src/config.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] // Remove this once you start using the code 2 | 3 | use color_eyre::Result; 4 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 5 | use derive_deref::{Deref, DerefMut}; 6 | use directories::ProjectDirs; 7 | use lazy_static::lazy_static; 8 | use ratatui::style::{Color, Modifier, Style}; 9 | use serde::{Deserialize, de::Deserializer}; 10 | use std::{collections::HashMap, env, path::PathBuf}; 11 | use tracing::error; 12 | use tracing::info; 13 | 14 | use crate::{action::Action, app::Mode}; 15 | 16 | const CONFIG: &str = include_str!("../.config/config.json5"); 17 | 18 | #[derive(Clone, Debug, Deserialize, Default)] 19 | pub struct AppConfig { 20 | #[serde(default)] 21 | pub data_dir: PathBuf, 22 | #[serde(default)] 23 | pub config_dir: PathBuf, 24 | } 25 | 26 | #[derive(Clone, Debug, Default, Deserialize)] 27 | pub struct Config { 28 | #[serde(default, flatten)] 29 | pub config: AppConfig, 30 | #[serde(default)] 31 | pub keybindings: KeyBindings, 32 | #[serde(default)] 33 | pub styles: Styles, 34 | } 35 | 36 | lazy_static! { 37 | pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); 38 | pub static ref DATA_FOLDER: Option = 39 | env::var(format!("{}_DATA", PROJECT_NAME.clone())) 40 | .ok() 41 | .map(PathBuf::from); 42 | pub static ref CONFIG_FOLDER: Option = 43 | env::var(format!("{}_CONFIG", PROJECT_NAME.clone())) 44 | .ok() 45 | .map(PathBuf::from); 46 | } 47 | 48 | impl Config { 49 | pub fn new() -> Result { 50 | let default_config: Config = json5::from_str(CONFIG).unwrap(); 51 | let data_dir = get_data_dir(); 52 | let config_dir = get_config_dir(); 53 | let mut builder = config::Config::builder() 54 | .set_default("data_dir", data_dir.to_str().unwrap())? 55 | .set_default("config_dir", config_dir.to_str().unwrap())?; 56 | 57 | let config_files = [ 58 | ("config.json5", config::FileFormat::Json5), 59 | ("config.json", config::FileFormat::Json), 60 | ("config.yaml", config::FileFormat::Yaml), 61 | ("config.toml", config::FileFormat::Toml), 62 | ("config.ini", config::FileFormat::Ini), 63 | ]; 64 | let mut found_config = false; 65 | info!(config_dir = ?config_dir, "Checking for config files"); 66 | for (file, format) in &config_files { 67 | let source = config::File::from(config_dir.join(file)) 68 | .format(*format) 69 | .required(false); 70 | builder = builder.add_source(source); 71 | if config_dir.join(file).exists() { 72 | found_config = true 73 | } 74 | } 75 | if !found_config { 76 | error!("No configuration file found. Application may not behave as expected"); 77 | } 78 | 79 | let mut cfg: Self = builder.build()?.try_deserialize()?; 80 | 81 | for (mode, default_bindings) in default_config.keybindings.iter() { 82 | let user_bindings = cfg.keybindings.entry(*mode).or_default(); 83 | for (key, cmd) in default_bindings.iter() { 84 | user_bindings 85 | .entry(key.clone()) 86 | .or_insert_with(|| cmd.clone()); 87 | } 88 | } 89 | for (mode, default_styles) in default_config.styles.iter() { 90 | let user_styles = cfg.styles.entry(*mode).or_default(); 91 | for (style_key, style) in default_styles.iter() { 92 | user_styles.entry(style_key.clone()).or_insert(*style); 93 | } 94 | } 95 | 96 | Ok(cfg) 97 | } 98 | } 99 | 100 | pub fn get_data_dir() -> PathBuf { 101 | let directory = if let Some(s) = DATA_FOLDER.clone() { 102 | s 103 | } else if let Some(proj_dirs) = project_directory() { 104 | proj_dirs.data_local_dir().to_path_buf() 105 | } else { 106 | PathBuf::from(".").join(".data") 107 | }; 108 | directory 109 | } 110 | 111 | pub fn get_config_dir() -> PathBuf { 112 | let directory = if let Some(s) = CONFIG_FOLDER.clone() { 113 | s 114 | } else if let Some(proj_dirs) = project_directory() { 115 | proj_dirs.config_local_dir().to_path_buf() 116 | } else { 117 | PathBuf::from(".").join(".config") 118 | }; 119 | directory 120 | } 121 | 122 | fn project_directory() -> Option { 123 | ProjectDirs::from("com", "modder_rs", env!("CARGO_PKG_NAME")) 124 | } 125 | 126 | #[derive(Clone, Debug, Default, Deref, DerefMut)] 127 | pub struct KeyBindings(pub HashMap, Action>>); 128 | 129 | impl<'de> Deserialize<'de> for KeyBindings { 130 | fn deserialize(deserializer: D) -> Result 131 | where 132 | D: Deserializer<'de>, 133 | { 134 | let parsed_map = HashMap::>::deserialize(deserializer)?; 135 | 136 | let keybindings = parsed_map 137 | .into_iter() 138 | .map(|(mode, inner_map)| { 139 | let converted_inner_map = inner_map 140 | .into_iter() 141 | .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)) 142 | .collect(); 143 | (mode, converted_inner_map) 144 | }) 145 | .collect(); 146 | 147 | Ok(KeyBindings(keybindings)) 148 | } 149 | } 150 | 151 | fn parse_key_event(raw: &str) -> Result { 152 | let raw_lower = raw.to_ascii_lowercase(); 153 | let (remaining, modifiers) = extract_modifiers(&raw_lower); 154 | parse_key_code_with_modifiers(remaining, modifiers) 155 | } 156 | 157 | fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { 158 | let mut modifiers = KeyModifiers::empty(); 159 | let mut current = raw; 160 | 161 | loop { 162 | match current { 163 | rest if rest.starts_with("ctrl-") => { 164 | modifiers.insert(KeyModifiers::CONTROL); 165 | current = &rest[5..]; 166 | } 167 | rest if rest.starts_with("alt-") => { 168 | modifiers.insert(KeyModifiers::ALT); 169 | current = &rest[4..]; 170 | } 171 | rest if rest.starts_with("shift-") => { 172 | modifiers.insert(KeyModifiers::SHIFT); 173 | current = &rest[6..]; 174 | } 175 | _ => break, // break out of the loop if no known prefix is detected 176 | }; 177 | } 178 | 179 | (current, modifiers) 180 | } 181 | 182 | fn parse_key_code_with_modifiers( 183 | raw: &str, 184 | mut modifiers: KeyModifiers, 185 | ) -> Result { 186 | let c = match raw { 187 | "esc" => KeyCode::Esc, 188 | "enter" => KeyCode::Enter, 189 | "left" => KeyCode::Left, 190 | "right" => KeyCode::Right, 191 | "up" => KeyCode::Up, 192 | "down" => KeyCode::Down, 193 | "home" => KeyCode::Home, 194 | "end" => KeyCode::End, 195 | "pageup" => KeyCode::PageUp, 196 | "pagedown" => KeyCode::PageDown, 197 | "backtab" => { 198 | modifiers.insert(KeyModifiers::SHIFT); 199 | KeyCode::BackTab 200 | } 201 | "backspace" => KeyCode::Backspace, 202 | "delete" => KeyCode::Delete, 203 | "insert" => KeyCode::Insert, 204 | "f1" => KeyCode::F(1), 205 | "f2" => KeyCode::F(2), 206 | "f3" => KeyCode::F(3), 207 | "f4" => KeyCode::F(4), 208 | "f5" => KeyCode::F(5), 209 | "f6" => KeyCode::F(6), 210 | "f7" => KeyCode::F(7), 211 | "f8" => KeyCode::F(8), 212 | "f9" => KeyCode::F(9), 213 | "f10" => KeyCode::F(10), 214 | "f11" => KeyCode::F(11), 215 | "f12" => KeyCode::F(12), 216 | "space" => KeyCode::Char(' '), 217 | "hyphen" => KeyCode::Char('-'), 218 | "minus" => KeyCode::Char('-'), 219 | "tab" => KeyCode::Tab, 220 | c if c.len() == 1 => { 221 | let mut c = c.chars().next().unwrap(); 222 | if modifiers.contains(KeyModifiers::SHIFT) { 223 | c = c.to_ascii_uppercase(); 224 | } 225 | KeyCode::Char(c) 226 | } 227 | _ => return Err(format!("Unable to parse {raw}")), 228 | }; 229 | Ok(KeyEvent::new(c, modifiers)) 230 | } 231 | 232 | pub fn key_event_to_string(key_event: &KeyEvent) -> String { 233 | let char; 234 | let key_code = match key_event.code { 235 | KeyCode::Backspace => "backspace", 236 | KeyCode::Enter => "enter", 237 | KeyCode::Left => "left", 238 | KeyCode::Right => "right", 239 | KeyCode::Up => "up", 240 | KeyCode::Down => "down", 241 | KeyCode::Home => "home", 242 | KeyCode::End => "end", 243 | KeyCode::PageUp => "pageup", 244 | KeyCode::PageDown => "pagedown", 245 | KeyCode::Tab => "tab", 246 | KeyCode::BackTab => "backtab", 247 | KeyCode::Delete => "delete", 248 | KeyCode::Insert => "insert", 249 | KeyCode::F(c) => { 250 | char = format!("f({c})"); 251 | &char 252 | } 253 | KeyCode::Char(' ') => "space", 254 | KeyCode::Char(c) => { 255 | char = c.to_string(); 256 | &char 257 | } 258 | KeyCode::Esc => "esc", 259 | KeyCode::Null => "", 260 | KeyCode::CapsLock => "", 261 | KeyCode::Menu => "", 262 | KeyCode::ScrollLock => "", 263 | KeyCode::Media(_) => "", 264 | KeyCode::NumLock => "", 265 | KeyCode::PrintScreen => "", 266 | KeyCode::Pause => "", 267 | KeyCode::KeypadBegin => "", 268 | KeyCode::Modifier(_) => "", 269 | }; 270 | 271 | let mut modifiers = Vec::with_capacity(3); 272 | 273 | if key_event.modifiers.intersects(KeyModifiers::CONTROL) { 274 | modifiers.push("ctrl"); 275 | } 276 | 277 | if key_event.modifiers.intersects(KeyModifiers::SHIFT) { 278 | modifiers.push("shift"); 279 | } 280 | 281 | if key_event.modifiers.intersects(KeyModifiers::ALT) { 282 | modifiers.push("alt"); 283 | } 284 | 285 | let mut key = modifiers.join("-"); 286 | 287 | if !key.is_empty() { 288 | key.push('-'); 289 | } 290 | key.push_str(key_code); 291 | 292 | key 293 | } 294 | 295 | pub fn parse_key_sequence(raw: &str) -> Result, String> { 296 | if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { 297 | return Err(format!("Unable to parse `{}`", raw)); 298 | } 299 | let raw = if !raw.contains("><") { 300 | let raw = raw.strip_prefix('<').unwrap_or(raw); 301 | let raw = raw.strip_prefix('>').unwrap_or(raw); 302 | raw 303 | } else { 304 | raw 305 | }; 306 | let sequences = raw 307 | .split("><") 308 | .map(|seq| { 309 | if let Some(s) = seq.strip_prefix('<') { 310 | s 311 | } else if let Some(s) = seq.strip_suffix('>') { 312 | s 313 | } else { 314 | seq 315 | } 316 | }) 317 | .collect::>(); 318 | 319 | sequences.into_iter().map(parse_key_event).collect() 320 | } 321 | 322 | #[derive(Clone, Debug, Default, Deref, DerefMut)] 323 | pub struct Styles(pub HashMap>); 324 | 325 | impl<'de> Deserialize<'de> for Styles { 326 | fn deserialize(deserializer: D) -> Result 327 | where 328 | D: Deserializer<'de>, 329 | { 330 | let parsed_map = HashMap::>::deserialize(deserializer)?; 331 | 332 | let styles = parsed_map 333 | .into_iter() 334 | .map(|(mode, inner_map)| { 335 | let converted_inner_map = inner_map 336 | .into_iter() 337 | .map(|(str, style)| (str, parse_style(&style))) 338 | .collect(); 339 | (mode, converted_inner_map) 340 | }) 341 | .collect(); 342 | 343 | Ok(Styles(styles)) 344 | } 345 | } 346 | 347 | pub fn parse_style(line: &str) -> Style { 348 | let (foreground, background) = 349 | line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len())); 350 | let foreground = process_color_string(foreground); 351 | let background = process_color_string(&background.replace("on ", "")); 352 | 353 | let mut style = Style::default(); 354 | if let Some(fg) = parse_color(&foreground.0) { 355 | style = style.fg(fg); 356 | } 357 | if let Some(bg) = parse_color(&background.0) { 358 | style = style.bg(bg); 359 | } 360 | style = style.add_modifier(foreground.1 | background.1); 361 | style 362 | } 363 | 364 | fn process_color_string(color_str: &str) -> (String, Modifier) { 365 | let color = color_str 366 | .replace("grey", "gray") 367 | .replace("bright ", "") 368 | .replace("bold ", "") 369 | .replace("underline ", "") 370 | .replace("inverse ", ""); 371 | 372 | let mut modifiers = Modifier::empty(); 373 | if color_str.contains("underline") { 374 | modifiers |= Modifier::UNDERLINED; 375 | } 376 | if color_str.contains("bold") { 377 | modifiers |= Modifier::BOLD; 378 | } 379 | if color_str.contains("inverse") { 380 | modifiers |= Modifier::REVERSED; 381 | } 382 | 383 | (color, modifiers) 384 | } 385 | 386 | fn parse_color(s: &str) -> Option { 387 | let s = s.trim_start(); 388 | let s = s.trim_end(); 389 | if s.contains("bright color") { 390 | let s = s.trim_start_matches("bright "); 391 | let c = s 392 | .trim_start_matches("color") 393 | .parse::() 394 | .unwrap_or_default(); 395 | Some(Color::Indexed(c.wrapping_shl(8))) 396 | } else if s.contains("color") { 397 | let c = s 398 | .trim_start_matches("color") 399 | .parse::() 400 | .unwrap_or_default(); 401 | Some(Color::Indexed(c)) 402 | } else if s.contains("gray") { 403 | let c = 232 404 | + s.trim_start_matches("gray") 405 | .parse::() 406 | .unwrap_or_default(); 407 | Some(Color::Indexed(c)) 408 | } else if s.contains("rgb") { 409 | let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; 410 | let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8; 411 | let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8; 412 | let c = 16 + red * 36 + green * 6 + blue; 413 | Some(Color::Indexed(c)) 414 | } else if s == "bold black" { 415 | Some(Color::Indexed(8)) 416 | } else if s == "bold red" { 417 | Some(Color::Indexed(9)) 418 | } else if s == "bold green" { 419 | Some(Color::Indexed(10)) 420 | } else if s == "bold yellow" { 421 | Some(Color::Indexed(11)) 422 | } else if s == "bold blue" { 423 | Some(Color::Indexed(12)) 424 | } else if s == "bold magenta" { 425 | Some(Color::Indexed(13)) 426 | } else if s == "bold cyan" { 427 | Some(Color::Indexed(14)) 428 | } else if s == "bold white" { 429 | Some(Color::Indexed(15)) 430 | } else if s == "black" { 431 | Some(Color::Indexed(0)) 432 | } else if s == "red" { 433 | Some(Color::Indexed(1)) 434 | } else if s == "green" { 435 | Some(Color::Indexed(2)) 436 | } else if s == "yellow" { 437 | Some(Color::Indexed(3)) 438 | } else if s == "blue" { 439 | Some(Color::Indexed(4)) 440 | } else if s == "magenta" { 441 | Some(Color::Indexed(5)) 442 | } else if s == "cyan" { 443 | Some(Color::Indexed(6)) 444 | } else if s == "white" { 445 | Some(Color::Indexed(7)) 446 | } else { 447 | None 448 | } 449 | } 450 | 451 | #[cfg(test)] 452 | mod tests { 453 | use pretty_assertions::assert_eq; 454 | 455 | use super::*; 456 | 457 | #[test] 458 | fn test_parse_style_default() { 459 | let style = parse_style(""); 460 | assert_eq!(style, Style::default()); 461 | } 462 | 463 | #[test] 464 | fn test_parse_style_foreground() { 465 | let style = parse_style("red"); 466 | assert_eq!(style.fg, Some(Color::Indexed(1))); 467 | } 468 | 469 | #[test] 470 | fn test_parse_style_background() { 471 | let style = parse_style("on blue"); 472 | assert_eq!(style.bg, Some(Color::Indexed(4))); 473 | } 474 | 475 | #[test] 476 | fn test_parse_style_modifiers() { 477 | let style = parse_style("underline red on blue"); 478 | assert_eq!(style.fg, Some(Color::Indexed(1))); 479 | assert_eq!(style.bg, Some(Color::Indexed(4))); 480 | } 481 | 482 | #[test] 483 | fn test_process_color_string() { 484 | let (color, modifiers) = process_color_string("underline bold inverse gray"); 485 | assert_eq!(color, "gray"); 486 | assert!(modifiers.contains(Modifier::UNDERLINED)); 487 | assert!(modifiers.contains(Modifier::BOLD)); 488 | assert!(modifiers.contains(Modifier::REVERSED)); 489 | } 490 | 491 | #[test] 492 | fn test_parse_color_rgb() { 493 | let color = parse_color("rgb123"); 494 | let expected = 16 + 36 + 2 * 6 + 3; 495 | assert_eq!(color, Some(Color::Indexed(expected))); 496 | } 497 | 498 | #[test] 499 | fn test_parse_color_unknown() { 500 | let color = parse_color("unknown"); 501 | assert_eq!(color, None); 502 | } 503 | 504 | #[test] 505 | fn test_config() -> Result<()> { 506 | let c = Config::new()?; 507 | assert_eq!( 508 | c.keybindings 509 | .get(&Mode::Home) 510 | .unwrap() 511 | .get(&parse_key_sequence("").unwrap_or_default()) 512 | .unwrap(), 513 | &Action::Quit 514 | ); 515 | Ok(()) 516 | } 517 | 518 | #[test] 519 | fn test_simple_keys() { 520 | assert_eq!( 521 | parse_key_event("a").unwrap(), 522 | KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()) 523 | ); 524 | 525 | assert_eq!( 526 | parse_key_event("enter").unwrap(), 527 | KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()) 528 | ); 529 | 530 | assert_eq!( 531 | parse_key_event("esc").unwrap(), 532 | KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()) 533 | ); 534 | } 535 | 536 | #[test] 537 | fn test_with_modifiers() { 538 | assert_eq!( 539 | parse_key_event("ctrl-a").unwrap(), 540 | KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) 541 | ); 542 | 543 | assert_eq!( 544 | parse_key_event("alt-enter").unwrap(), 545 | KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) 546 | ); 547 | 548 | assert_eq!( 549 | parse_key_event("shift-esc").unwrap(), 550 | KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT) 551 | ); 552 | } 553 | 554 | #[test] 555 | fn test_multiple_modifiers() { 556 | assert_eq!( 557 | parse_key_event("ctrl-alt-a").unwrap(), 558 | KeyEvent::new( 559 | KeyCode::Char('a'), 560 | KeyModifiers::CONTROL | KeyModifiers::ALT 561 | ) 562 | ); 563 | 564 | assert_eq!( 565 | parse_key_event("ctrl-shift-enter").unwrap(), 566 | KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) 567 | ); 568 | } 569 | 570 | #[test] 571 | fn test_reverse_multiple_modifiers() { 572 | assert_eq!( 573 | key_event_to_string(&KeyEvent::new( 574 | KeyCode::Char('a'), 575 | KeyModifiers::CONTROL | KeyModifiers::ALT 576 | )), 577 | "ctrl-alt-a".to_string() 578 | ); 579 | } 580 | 581 | #[test] 582 | fn test_invalid_keys() { 583 | assert!(parse_key_event("invalid-key").is_err()); 584 | assert!(parse_key_event("ctrl-invalid-key").is_err()); 585 | } 586 | 587 | #[test] 588 | fn test_case_insensitivity() { 589 | assert_eq!( 590 | parse_key_event("CTRL-a").unwrap(), 591 | KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) 592 | ); 593 | 594 | assert_eq!( 595 | parse_key_event("AlT-eNtEr").unwrap(), 596 | KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) 597 | ); 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /tui/src/components/toggle.rs: -------------------------------------------------------------------------------- 1 | use super::Component; 2 | use crate::{action::Action, app::Mode, config::Config}; 3 | use color_eyre::Result; 4 | use crossterm::event::KeyCode; 5 | use modder::{ 6 | calc_sha512, 7 | cli::Source, 8 | curseforge_wrapper::{API_KEY, CurseForgeAPI}, 9 | metadata::Metadata, 10 | modrinth_wrapper::modrinth::{GetProject, VersionData}, 11 | }; 12 | use ratatui::{prelude::*, widgets::*}; 13 | use std::{fs, path::PathBuf}; 14 | use style::palette::tailwind::SLATE; 15 | use tokio::sync::mpsc::UnboundedSender; 16 | use tracing::{debug, error}; 17 | use tui_input::Input; 18 | use tui_input::backend::crossterm::EventHandler; 19 | 20 | #[derive(Default)] 21 | pub struct ToggleComponent { 22 | command_tx: Option>, 23 | config: Config, 24 | list: ToggleList, 25 | mode: Mode, 26 | enabled: bool, 27 | state: State, 28 | input: Input, 29 | throbber_state: throbber_widgets_tui::ThrobberState, 30 | dir: PathBuf, 31 | } 32 | 33 | #[derive(Debug, Clone, Default)] 34 | struct ToggleList { 35 | filtered_items: Vec, 36 | list_items: Vec, 37 | state: ListState, 38 | } 39 | 40 | #[derive(Debug, Clone, Default, PartialEq)] 41 | enum State { 42 | #[default] 43 | Normal, 44 | Search, 45 | Toggling, 46 | } 47 | 48 | #[derive(Debug, Clone, Default)] 49 | struct ToggleListItem { 50 | name: String, 51 | source: Source, 52 | project_id: String, 53 | version: String, 54 | game_version: Option, 55 | category: Option, 56 | version_type: String, 57 | enabled: bool, 58 | path: String, 59 | } 60 | 61 | const SELECTED_STYLE: Style = Style::new().bg(SLATE.c800).add_modifier(Modifier::BOLD); 62 | 63 | impl FromIterator for ToggleList { 64 | fn from_iter>(iter: I) -> Self { 65 | let items = iter.into_iter().collect(); 66 | let state = ListState::default(); 67 | Self { 68 | filtered_items: Vec::new(), 69 | list_items: items, 70 | state, 71 | } 72 | } 73 | } 74 | 75 | impl ToggleList { 76 | fn select_none(&mut self) { 77 | self.state.select(None); 78 | } 79 | 80 | fn select_next(&mut self) { 81 | self.state.select_next(); 82 | } 83 | fn select_previous(&mut self) { 84 | self.state.select_previous(); 85 | } 86 | 87 | fn select_first(&mut self) { 88 | self.state.select_first(); 89 | } 90 | 91 | fn select_last(&mut self) { 92 | self.state.select_last(); 93 | } 94 | } 95 | 96 | impl ToggleComponent { 97 | pub async fn new(dir: PathBuf) -> Self { 98 | let dir_clone = dir.clone(); 99 | let items = tokio::spawn(async move { get_mods(dir_clone.clone()).await }).await; 100 | let items = items.unwrap_or(Vec::new()); 101 | 102 | ToggleComponent { 103 | list: ToggleList::from_iter(items), 104 | mode: Mode::Toggle, 105 | enabled: true, 106 | dir, 107 | ..Default::default() 108 | } 109 | } 110 | pub fn toggle_state(&mut self) { 111 | self.state = match self.state { 112 | State::Normal => State::Search, 113 | State::Search => State::Normal, 114 | State::Toggling => State::Normal, 115 | }; 116 | } 117 | } 118 | 119 | impl Component for ToggleComponent { 120 | fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { 121 | self.command_tx = Some(tx); 122 | Ok(()) 123 | } 124 | fn get_mode(&self) -> Mode { 125 | self.mode 126 | } 127 | 128 | fn register_config_handler(&mut self, config: Config) -> Result<()> { 129 | self.config = config; 130 | Ok(()) 131 | } 132 | 133 | fn update(&mut self, action: Action) -> Result> { 134 | match action { 135 | Action::Tick => {} 136 | Action::Render => { 137 | // add any logic here that should run on every render 138 | } 139 | Action::Mode(mode) => { 140 | self.enabled = mode == self.mode; 141 | if self.enabled { 142 | self.list.select_first(); 143 | let dir = self.dir.clone(); 144 | self.list.list_items = 145 | futures::executor::block_on(async move { get_mods(dir).await }); 146 | } 147 | } 148 | _ => {} 149 | } 150 | Ok(None) 151 | } 152 | fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) -> Result> { 153 | if !self.enabled { 154 | return Ok(None); 155 | } 156 | if self.state == State::Search { 157 | match key.code { 158 | KeyCode::Tab | KeyCode::Esc => self.toggle_state(), 159 | KeyCode::Enter => {} 160 | _ => { 161 | self.input.handle_event(&crossterm::event::Event::Key(key)); 162 | let val = self.input.value(); 163 | let filtered_items = self 164 | .list 165 | .list_items 166 | .iter() 167 | .filter(|item| item.name.to_lowercase().contains(&val.to_lowercase())) 168 | .cloned() 169 | .collect(); 170 | self.list.filtered_items = filtered_items; 171 | self.list.state.select_first(); 172 | } 173 | } 174 | return Ok(None); 175 | } 176 | match key.code { 177 | KeyCode::Char('h') | KeyCode::Left => self.list.select_none(), 178 | KeyCode::Char('j') | KeyCode::Down => self.list.select_next(), 179 | KeyCode::Char('k') | KeyCode::Up => self.list.select_previous(), 180 | KeyCode::Char('g') | KeyCode::Home => self.list.select_first(), 181 | KeyCode::Char('G') | KeyCode::End => self.list.select_last(), 182 | KeyCode::Char(' ') => { 183 | let idx = self.list.state.selected().unwrap(); 184 | if self.list.filtered_items.is_empty() { 185 | let mut item = self.list.list_items[idx].clone(); 186 | item.enabled = !item.enabled; 187 | self.list.list_items[idx] = item; 188 | return Ok(None); 189 | } 190 | self.list.filtered_items[idx].enabled = !self.list.filtered_items[idx].enabled; 191 | } 192 | KeyCode::Enter => { 193 | self.state = State::Toggling; 194 | for item in self.list.list_items.iter() { 195 | let filename = item.path.split('/').last().unwrap(); 196 | let predicate = filename.contains("disabled"); 197 | if predicate && item.enabled { 198 | let new_path = item.path.replace(".disabled", ""); 199 | let res = fs::rename(item.path.clone(), new_path); 200 | if res.is_err() { 201 | error!("Failed to rename file: {:?}", res.err()); 202 | } 203 | } 204 | if !predicate && !item.enabled { 205 | let new_path = format!("{}.disabled", item.path); 206 | 207 | let res = fs::rename(item.path.clone(), new_path); 208 | if res.is_err() { 209 | error!("Failed to rename file: {:?}", res.err()); 210 | } 211 | } 212 | } 213 | self.state = State::Normal; 214 | } 215 | KeyCode::Char('q') => return Ok(Some(Action::Quit)), 216 | KeyCode::Esc => { 217 | self.command_tx.clone().unwrap().send(Action::ClearScreen)?; 218 | return Ok(Some(Action::Mode(Mode::Home))); 219 | } 220 | 221 | KeyCode::Char('/') => self.toggle_state(), 222 | _ => {} 223 | }; 224 | Ok(None) 225 | } 226 | 227 | fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { 228 | let items: Vec = if self.list.filtered_items.is_empty() { 229 | self.list.list_items.iter().map(ListItem::from).collect() 230 | } else { 231 | self.list 232 | .filtered_items 233 | .iter() 234 | .map(ListItem::from) 235 | .collect() 236 | }; 237 | let list = List::new(items) 238 | .highlight_style(SELECTED_STYLE) 239 | .highlight_symbol("> ") 240 | .highlight_spacing(HighlightSpacing::Always) 241 | .block( 242 | Block::new() 243 | .borders(Borders::ALL) 244 | .padding(Padding::uniform(1)) 245 | .border_type(BorderType::Rounded) 246 | .title_top(Line::raw("Mods").centered().bold()), 247 | ); 248 | 249 | let [top, center] = 250 | Layout::vertical([Constraint::Min(3), Constraint::Percentage(100)]).areas(area); 251 | let [left, right] = 252 | Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]) 253 | .areas(center); 254 | let [lt, lb] = 255 | Layout::vertical([Constraint::Percentage(100), Constraint::Min(3)]).areas(left); 256 | let top_text = Paragraph::new("List") 257 | .bold() 258 | .block( 259 | Block::default() 260 | .padding(Padding::symmetric(1, 0)) 261 | .border_type(BorderType::Rounded) 262 | .borders(Borders::ALL), 263 | ) 264 | .style(Style::default().fg(Color::White)); 265 | let right_widget = 266 | if self.list.state.selected().is_some() && !self.list.list_items.is_empty() { 267 | let selected = self.list.state.selected().unwrap(); 268 | let idx = selected.min(self.list.list_items.len() - 1); 269 | let item = if self.list.filtered_items.is_empty() { 270 | &self.list.list_items[idx] 271 | } else { 272 | &self.list.filtered_items[idx] 273 | }; 274 | 275 | let name_span = Span::styled( 276 | item.name.clone() + " ", 277 | Style::default().add_modifier(Modifier::BOLD), 278 | ); 279 | let version_span = Span::styled( 280 | item.version.clone(), 281 | Style::default().add_modifier(Modifier::DIM), 282 | ); 283 | let top_line = Line::from(vec![name_span, version_span]); 284 | 285 | let new_link = if item.source == Source::Modrinth { 286 | format!("https://modrinth.com/mod/{}", item.project_id) 287 | } else { 288 | format!("https://github.com/{}", item.project_id) 289 | }; 290 | 291 | let lines = vec![ 292 | top_line, 293 | Line::from(vec![ 294 | Span::styled("\tSource: ", Style::default().add_modifier(Modifier::BOLD)), 295 | Span::raw(item.source.to_string()), 296 | ]), 297 | Line::from(vec![ 298 | Span::styled( 299 | "\tVersion: ", 300 | Style::default() 301 | .fg(Color::Yellow) 302 | .add_modifier(Modifier::BOLD), 303 | ), 304 | Span::raw(&item.version), 305 | ]), 306 | Line::from(vec![ 307 | Span::styled( 308 | "\tURL: ", 309 | Style::default() 310 | .fg(Color::Cyan) 311 | .add_modifier(Modifier::BOLD), 312 | ), 313 | Span::raw(new_link.to_string()), 314 | ]), 315 | Line::from(vec![ 316 | Span::styled( 317 | "\tMod version: ", 318 | Style::default().add_modifier(Modifier::BOLD), 319 | ), 320 | Span::raw(format!("[{}]", item.version)), 321 | ]), 322 | Line::from(vec![ 323 | Span::styled( 324 | "\tGame versions: ", 325 | Style::default().add_modifier(Modifier::BOLD), 326 | ), 327 | Span::raw(item.game_version.clone().unwrap_or_else(|| "-".to_string())), 328 | ]), 329 | Line::from(vec![ 330 | Span::styled("\tLoaders: ", Style::default().add_modifier(Modifier::BOLD)), 331 | Span::raw(item.category.clone().unwrap_or_else(|| "-".to_string())), 332 | ]), 333 | Line::from(vec![ 334 | Span::styled( 335 | "\tCategories: ", 336 | Style::default().add_modifier(Modifier::BOLD), 337 | ), 338 | Span::raw(item.category.clone().unwrap_or_else(|| "-".to_string())), 339 | ]), 340 | Line::from(vec![ 341 | Span::styled( 342 | "\tStatus: ", 343 | Style::default() 344 | .fg(Color::Green) 345 | .add_modifier(Modifier::BOLD), 346 | ), 347 | Span::raw(item.version_type.clone()), 348 | ]), 349 | // Add more fields as needed 350 | ]; 351 | let para = Paragraph::new(lines).alignment(Alignment::Left); 352 | para 353 | } else { 354 | Paragraph::new(Span::raw("No mod selected")).alignment(Alignment::Left) 355 | }; 356 | let loader = throbber_widgets_tui::Throbber::default() 357 | .label("Toggling Mods") 358 | .throbber_set(throbber_widgets_tui::BRAILLE_SIX_DOUBLE); 359 | let right_widget = right_widget.block( 360 | Block::new() 361 | .borders(Borders::ALL) 362 | .padding(Padding::uniform(1)) 363 | .title_top(Line::raw("info").centered().bold()) 364 | .border_type(BorderType::Rounded), 365 | ); 366 | let style = match self.state { 367 | State::Normal => Style::default(), 368 | State::Search => Color::Yellow.into(), 369 | State::Toggling => Style::default(), 370 | }; 371 | let input = Paragraph::new(self.input.value()) 372 | .style(style) 373 | .block(Block::bordered().title("Input")); 374 | match self.state { 375 | State::Toggling => { 376 | frame.render_stateful_widget(loader, lb, &mut self.throbber_state); 377 | } 378 | _ => { 379 | frame.render_widget(input, lb); 380 | } 381 | } 382 | frame.render_widget(top_text, top); 383 | frame.render_widget(right_widget, right); 384 | frame.render_stateful_widget(list, lt, &mut self.list.state); 385 | Ok(()) 386 | } 387 | } 388 | 389 | impl From<&ToggleListItem> for ListItem<'_> { 390 | fn from(value: &ToggleListItem) -> Self { 391 | ListItem::new(value.format()) 392 | } 393 | } 394 | 395 | #[allow(clippy::needless_lifetimes)] 396 | impl<'a> ToggleListItem { 397 | fn format(&self) -> Line<'a> { 398 | let version_type_style = match self.version_type.to_uppercase().as_str() { 399 | "RELEASE" => Style::default().fg(Color::Green), 400 | "BETA" => Style::default().fg(Color::Yellow), 401 | "ALPHA" => Style::default().fg(Color::Red), 402 | "GITHUB" => Style::default().fg(Color::Cyan), 403 | _ => Style::default().fg(Color::Cyan), 404 | }; 405 | let version_type_text = match self.version_type.to_uppercase().as_str() { 406 | "RELEASE" => "RELEASE", 407 | "BETA" => "BETA ", 408 | "ALPHA" => "ALPHA ", 409 | "GITHUB" => "GITHUB ", 410 | _ => "UNKNOWN", 411 | }; 412 | let enabled_span = Span::styled( 413 | if self.enabled { "[x]" } else { "[ ]" }.to_string() + " ", 414 | if self.enabled { 415 | Style::default().fg(Color::Green) 416 | } else { 417 | Style::default() 418 | .add_modifier(Modifier::DIM) 419 | .fg(Color::White) 420 | }, 421 | ); 422 | let span = Span::styled(version_type_text.to_string() + " ", version_type_style); 423 | let id_span = Span::styled( 424 | self.project_id.clone() + " ", 425 | Style::default().add_modifier(Modifier::DIM), 426 | ); 427 | let name = self.name.clone(); 428 | let name_span = Span::styled(name.clone(), Style::default().add_modifier(Modifier::BOLD)); 429 | if !self.enabled { 430 | return Line::from(vec![enabled_span, span, id_span, name_span]) 431 | .style(Style::default().add_modifier(Modifier::DIM)); 432 | } 433 | Line::from(vec![enabled_span, span, id_span, name_span]) 434 | } 435 | } 436 | 437 | async fn get_mods(dir: PathBuf) -> Vec { 438 | let files = fs::read_dir(dir).unwrap(); 439 | 440 | let regex = regex::Regex::new(r#"\b\d+\.\d+(?:\.\d+)?(?:-(?:pre|rc)\d+)?\b"#); 441 | let mut output = Vec::new(); 442 | let mut handles = Vec::new(); 443 | for f in files { 444 | let regex = regex.clone(); 445 | let handle = tokio::spawn(async move { 446 | if f.is_err() { 447 | return None; 448 | } 449 | let f = f.unwrap(); 450 | let path = f.path(); 451 | let extension = path 452 | .extension() 453 | .unwrap_or_default() 454 | .to_str() 455 | .unwrap_or_default(); 456 | 457 | if extension != "jar" && extension != "disabled" { 458 | return None; 459 | } 460 | 461 | let path_str = path.to_str().unwrap_or_default().to_string(); 462 | let hash = calc_sha512(&path_str); 463 | let enabled = !path_str.contains("disabled"); 464 | let version_data = VersionData::from_hash(hash).await; 465 | if version_data.is_err() { 466 | debug!(path = ?path); 467 | let cf = CurseForgeAPI::new(API_KEY.to_string()); 468 | let mod_ = cf.get_mod_from_file(path.clone()).await; 469 | let file = cf.get_version_from_file(path.clone()).await; 470 | if mod_.is_err() || file.is_err() { 471 | debug!(path = ?path); 472 | let metadata = Metadata::get_all_metadata(path_str.clone().into()); 473 | if metadata.is_err() { 474 | error!(version_data = ?version_data, "Failed to get version data for {}", path_str); 475 | return None; 476 | } 477 | let metadata = metadata.unwrap(); 478 | let source = metadata.get("source").unwrap(); 479 | if source.is_empty() { 480 | error!(version_data = ?version_data, "Failed to get version data for {}", path_str); 481 | return None; 482 | } 483 | let repo = metadata.get("repo").unwrap(); 484 | let repo_name = repo.split('/').last().unwrap(); 485 | let game_version = regex.unwrap().find(&path_str).unwrap().as_str().to_string(); 486 | let out = ToggleListItem { 487 | name: repo_name.to_string(), 488 | source: Source::Github, 489 | version: game_version, 490 | game_version: None, 491 | category: None, 492 | version_type: "GITHUB".to_string(), 493 | project_id: repo.to_string(), 494 | enabled, 495 | path: path_str.to_string(), 496 | }; 497 | return Some(out); 498 | } 499 | let Ok(mod_) = mod_ else { 500 | return None; 501 | }; 502 | let Ok(file) = file else { 503 | return None; 504 | }; 505 | debug!(mod_curseforge = ?mod_); 506 | let out = ToggleListItem { 507 | name: mod_.name, 508 | source: Source::CurseForge, 509 | version: file.id.to_string(), 510 | game_version: Some(file.game_versions.join(", ")), 511 | category: Some(mod_.categories.iter().map(|c| c.name.clone()).collect()), 512 | version_type: "CF".to_string(), 513 | project_id: mod_.slug, 514 | enabled, 515 | path: path_str.to_string(), 516 | }; 517 | return Some(out); 518 | } 519 | let version_data = version_data.unwrap(); 520 | let project = GetProject::from_id(&version_data.project_id).await?; 521 | 522 | let out = ToggleListItem { 523 | name: project.get_title(), 524 | source: Source::Modrinth, 525 | game_version: Some( 526 | version_data 527 | .get_game_versions() 528 | .unwrap_or(Vec::new()) 529 | .join(", "), 530 | ), 531 | enabled, 532 | path: path_str.to_string(), 533 | version: version_data.get_version(), 534 | category: Some(project.get_categories().join(", ")), 535 | version_type: version_data.get_version_type(), 536 | project_id: version_data.project_id, 537 | }; 538 | 539 | Some(out) 540 | }); 541 | handles.push(handle); 542 | } 543 | for handle in handles { 544 | let out = match handle.await { 545 | Ok(out) => out, 546 | Err(_) => continue, 547 | }; 548 | let Some(out) = out else { 549 | continue; 550 | }; 551 | output.push(out); 552 | } 553 | output 554 | } 555 | --------------------------------------------------------------------------------