Full rewrite #24
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
|
||||||
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||||
|
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||||
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*
|
*
|
||||||
|
@ -20,7 +20,7 @@
|
||||||
|
|
||||||
use console::style;
|
use console::style;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::{collections::HashMap, fmt};
|
use std::{ collections::HashMap, fmt };
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct SearchResponse {
|
pub struct SearchResponse {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
|
||||||
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||||
|
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||||
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*
|
*
|
||||||
|
|
212
src/client.rs
212
src/client.rs
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
|
||||||
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||||
|
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||||
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*
|
*
|
||||||
|
@ -17,3 +17,213 @@
|
||||||
* You should have received a copy of the GNU General Public License along with
|
* You should have received a copy of the GNU General Public License along with
|
||||||
* Hopper. If not, see <https://www.gnu.org/licenses/>.
|
* Hopper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{
|
||||||
|
ModInfo,
|
||||||
|
ModResult,
|
||||||
|
ModVersion,
|
||||||
|
ModVersionFile,
|
||||||
|
SearchResponse,
|
||||||
|
Error as APIError,
|
||||||
|
},
|
||||||
|
config::{
|
||||||
|
Config,
|
||||||
|
},
|
||||||
|
args::{
|
||||||
|
Arguments,
|
||||||
|
Loader,
|
||||||
|
PackageType,
|
||||||
|
Server,
|
||||||
|
SearchArgs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use std::cmp::min;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use curl::easy::{ Easy2, Handler };
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
pub struct HopperClient {
|
||||||
|
config: Config,
|
||||||
|
client: Easy2<Handler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HopperClient {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
Self {
|
||||||
|
config: config,
|
||||||
|
client: Easy2::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_mods(
|
||||||
|
&self,
|
||||||
|
search_args: &SearchArgs,
|
||||||
|
) -> Result<SearchResponse, (String, u32)> {
|
||||||
|
println!("Searching with query “{}”...", search_args.package_name);
|
||||||
|
|
||||||
|
let urls = Vec::new();
|
||||||
|
|
||||||
|
for entry in self.config.sources.drain() {
|
||||||
|
let (source, domain) = entry;
|
||||||
|
urls.push(format!("{}/v2/search", domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut params = vec![("query", search_args.package_name.to_owned())];
|
||||||
|
let mut facets: Vec<String> = Vec::new();
|
||||||
|
if let versions = &search_args.mc_version {
|
||||||
|
let versions_facets = versions
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!("[\"versions:{}\"]", e))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(",");
|
||||||
|
facets.push(format!("{}", versions_facets));
|
||||||
|
}
|
||||||
|
if let Some(package_type) = &search_args.package_type {
|
||||||
|
let project_type = match package_type {
|
||||||
|
PackageType::Mod(_) => "[\"project_type:mod\"]",
|
||||||
|
PackageType::Pack(_) => "[\"project_type:modpack\"]",
|
||||||
|
PackageType::Plugin(_) => "[\"project_type:mod\"]",
|
||||||
|
PackageType::ResourcePack => "[\"project_type:resourcepack\"]",
|
||||||
|
};
|
||||||
|
|
||||||
|
let project_category = match package_type {
|
||||||
|
PackageType::Mod(kind) | PackageType::Pack(kind) => {
|
||||||
|
match kind {
|
||||||
|
Loader::Fabric => "[\"categories:fabric\"]",
|
||||||
|
Loader::Forge => "[\"categories:forge\"]",
|
||||||
|
Loader::Quilt => "[\"categories:quilt\"]",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PackageType::Plugin(kind) => {
|
||||||
|
match kind {
|
||||||
|
Server::Bukkit => "[\"categories:bukkit\"]",
|
||||||
|
Server::Paper => "[\"categories:paper\"]",
|
||||||
|
Server::Purpur => "[\"categories:purpur\"]",
|
||||||
|
Server::Spigot => "[\"categories:spigot\"]",
|
||||||
|
Server::Sponge => "[\"categories:sponge\"]",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let package_type_facet = format!(
|
||||||
|
"{},{}",
|
||||||
|
project_type,
|
||||||
|
project_category,
|
||||||
|
);
|
||||||
|
|
||||||
|
facets.push(package_type_facet);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !facets.is_empty() {
|
||||||
|
params.push(("facets", format!("[{}]", facets.join(","))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Rewrite using curl
|
||||||
|
let url = reqwest::Url::parse_with_params(url.as_str(), ¶ms)?;
|
||||||
|
info!("GET {}", url);
|
||||||
|
let response = self.client.get(url).send().await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
Ok(response.json::<SearchResponse>().await?)
|
||||||
|
} else {
|
||||||
|
Err(response.json::<APIError>().await?.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
pub async fn fetch_mod_info(&self, mod_result: &ModResult) -> anyhow::Result<ModInfo> {
|
||||||
|
let mod_id = &mod_result.project_id;
|
||||||
|
println!(
|
||||||
|
"Fetching mod info for {} (ID: {})...",
|
||||||
|
mod_result.title, mod_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://{}/v2/project/{}",
|
||||||
|
self.config.upstream.server_address, mod_id
|
||||||
|
);
|
||||||
|
info!("GET {}", url);
|
||||||
|
let response = self.client.get(url).send().await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
Ok(response.json::<ModInfo>().await?)
|
||||||
|
} else {
|
||||||
|
Err(response.json::<APIError>().await?.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_mod_version(&self, version_id: &String) -> anyhow::Result<ModVersion> {
|
||||||
|
println!("Fetching mod version {}...", version_id);
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://{}/v2/version/{}",
|
||||||
|
self.config.upstream.server_address, version_id
|
||||||
|
);
|
||||||
|
info!("GET {}", url);
|
||||||
|
let response = self.client.get(url).send().await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
Ok(response.json::<ModVersion>().await?)
|
||||||
|
} else {
|
||||||
|
Err(response.json::<APIError>().await?.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_version_file(
|
||||||
|
&self,
|
||||||
|
args: &Args,
|
||||||
|
file: &ModVersionFile,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
// TODO replace all uses of .unwrap() with proper error codes
|
||||||
|
let filename = &file.filename;
|
||||||
|
|
||||||
|
// TODO make confirmation skippable with flag argument
|
||||||
|
if !args.auto_accept {
|
||||||
|
use dialoguer::Confirm;
|
||||||
|
let prompt = format!("Download to {}?", filename);
|
||||||
|
let confirm = Confirm::new()
|
||||||
|
.with_prompt(prompt)
|
||||||
|
.default(true)
|
||||||
|
.interact()?;
|
||||||
|
if !confirm {
|
||||||
|
println!("Skipping downloading {}...", filename);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let url = &file.url;
|
||||||
|
info!("GET {}", url);
|
||||||
|
let response = self.client.get(url).send().await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(response.json::<APIError>().await?.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_size = response.content_length().unwrap();
|
||||||
|
|
||||||
|
// TODO better colors and styling!
|
||||||
|
// TODO square colored creeper face progress indicator (from top-left clockwise spiral in)
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
let pb = ProgressBar::new(total_size);
|
||||||
|
pb.set_style(ProgressStyle::default_bar().template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})").progress_chars("#>-"));
|
||||||
|
pb.set_message(&format!("Downloading {}", url));
|
||||||
|
|
||||||
|
let filename = &file.filename;
|
||||||
|
let mut file = std::fs::File::create(filename)?;
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
|
||||||
|
// TODO check hashes while streaming
|
||||||
|
while let Some(item) = stream.next().await {
|
||||||
|
let chunk = &item.unwrap();
|
||||||
|
file.write(&chunk)?;
|
||||||
|
let new = min(downloaded + (chunk.len() as u64), total_size);
|
||||||
|
downloaded = new;
|
||||||
|
pb.set_position(new);
|
||||||
|
}
|
||||||
|
|
||||||
|
pb.finish_with_message(&format!("Downloaded {} to {}", url, filename));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
|
||||||
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||||
|
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||||
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*
|
*
|
||||||
|
@ -22,10 +22,12 @@ use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
fs::File,
|
fs::File,
|
||||||
io::Read,
|
io::Read,
|
||||||
|
path::PathBuf,
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use toml::de::ValueDeserializer;
|
use toml::de::ValueDeserializer;
|
||||||
|
use xdg::BaseDirectories;
|
||||||
use yacexits::{
|
use yacexits::{
|
||||||
EX_DATAERR,
|
EX_DATAERR,
|
||||||
EX_UNAVAILABLE,
|
EX_UNAVAILABLE,
|
||||||
|
@ -33,32 +35,38 @@ use yacexits::{
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
hopfiles: Vec<String>,
|
pub hopfiles: Vec<String>,
|
||||||
sources: HashMap<String, String>,
|
pub sources: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_config() -> Result<(), (String, u32)> {
|
pub fn get_config(dirs: BaseDirectories) -> Result<PathBuf, (String, u32)> {
|
||||||
let xdg_dirs = match xdg::BaseDirectories::with_prefix("hopper") {
|
match dirs.place_config_file("config.toml") {
|
||||||
Ok(dirs) => dirs,
|
Ok(file) => Ok(file),
|
||||||
Err(err) => {
|
Err(_) => {
|
||||||
return Err((
|
Err((
|
||||||
format!("{:?}", err),
|
format!("Unable to create configuration file."),
|
||||||
EX_UNAVAILABLE,
|
EX_UNAVAILABLE,
|
||||||
));
|
))
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn read_config(config_path: String) -> Result<Self, (String, u32)> {
|
pub fn read_config(config_path: PathBuf) -> Result<Self, (String, u32)> {
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
let mut config_file = match File::open(&config_path) {
|
let mut config_file = match File::open(&config_path) {
|
||||||
Ok(file) => file,
|
Ok(file) => file,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return Err((
|
return Err((
|
||||||
format!("{}: Permission denied.", &config_path),
|
format!(
|
||||||
|
"{}: Permission denied.",
|
||||||
|
config_path
|
||||||
|
.clone()
|
||||||
|
.into_os_string()
|
||||||
|
.into_string()
|
||||||
|
.unwrap()
|
||||||
|
),
|
||||||
EX_UNAVAILABLE,
|
EX_UNAVAILABLE,
|
||||||
));
|
));
|
||||||
},
|
},
|
||||||
|
|
40
src/main.rs
40
src/main.rs
|
@ -1,7 +1,8 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
|
||||||
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||||
|
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||||
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
||||||
|
* Copyright (c) 2022 [ ] <https://git.tebibyte.media/BlankParenthesis/>
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* This file is part of Hopper.
|
* This file is part of Hopper.
|
||||||
|
@ -24,14 +25,51 @@ mod api;
|
||||||
mod args;
|
mod args;
|
||||||
mod client;
|
mod client;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod hopfile;
|
||||||
|
|
||||||
use api::*;
|
use api::*;
|
||||||
use args::*;
|
use args::*;
|
||||||
use client::*;
|
use client::*;
|
||||||
use config::*;
|
use config::*;
|
||||||
|
use hopfile::*;
|
||||||
|
|
||||||
|
use yacexits::{
|
||||||
|
exit,
|
||||||
|
EX_UNAVAILABLE,
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
async fn rust_main(args: c_main::Args) {
|
async fn rust_main(args: c_main::Args) {
|
||||||
let arguments = Arguments::from_args(args.into_iter());
|
let arguments = Arguments::from_args(args.into_iter());
|
||||||
|
|
||||||
|
let xdg_dirs = match xdg::BaseDirectories::with_prefix("hopper") {
|
||||||
|
Ok(dirs) => dirs,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{:?}", err);
|
||||||
|
exit(EX_UNAVAILABLE);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_path = match get_config(xdg_dirs) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err((err, code)) => {
|
||||||
|
eprintln!("{:?}", err);
|
||||||
|
exit(code);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = match Config::read_config(config_path) {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err((err, code)) => {
|
||||||
|
eprintln!("{:?}", err);
|
||||||
|
exit(code);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match ctx.arguments.command {
|
||||||
|
// Command::Get(search_args) => cmd_get(&ctx, search_args).await,
|
||||||
|
// Command::Init(hopfile_args) => cmd_init(hopfile_args).await,
|
||||||
|
_ => unimplemented!("unimplemented subcommand"),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue