First public commit (i had some old stuff i cant publish)

This commit is contained in:
2026-07-18 17:12:46 -04:00
commit 1888ded1d7
12 changed files with 2887 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
logs/
*.mrpack

1592
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "untitled-minecraft-launcher"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.103"
clap = { version = "4.6.1", features = ["color", "derive"] }
indicatif = { version = "0.18.6", features = ["rayon"] }
rayon = "1.12.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sha2 = { version = "0.10", features = ["std"] }
ureq = { version = "3.3.0", features = ["json"] }
uuid = { version = "1.23.4", features = ["v3"] }
zip = "8.6.0"
[[bin]]
name = "uml"
path = "src/main.rs"

264
src/auth.rs Normal file
View File

@@ -0,0 +1,264 @@
use serde::Deserialize;
use std::{
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
thread::sleep,
time::Duration,
};
use ureq::Agent;
// to anyone trying to make something similar, you need your own client ID, you can get one from like an azure project or something, and then it won't work and the error code from some later xbox or minecraft api will say to go to another link where you fill a form and hope they let you use the api, i managed to get it, but don't hope on it.
const CLIENT_ID: &str = "daa4feeb-ac95-4ee0-b6e2-2041746b9a50";
const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
const DEVICECODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
const XBL_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
const XSTS_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
const MC_LOGIN_URL: &str = "https://api.minecraftservices.com/authentication/login_with_xbox";
const PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
#[derive(Deserialize)]
struct Device {
device_code: String,
user_code: String,
verification_uri: String,
interval: u64,
}
#[derive(Deserialize)]
struct XblResponse {
#[serde(rename = "Token")]
token: String,
#[serde(rename = "DisplayClaims")]
display_claims: DisplayClaims,
}
#[derive(Deserialize)]
struct DisplayClaims {
xui: Vec<Xui>,
}
#[derive(Deserialize)]
struct Xui {
uhs: String,
#[serde(default)]
xid: Option<String>,
}
#[derive(Deserialize)]
struct McTokenResponse {
access_token: String,
}
#[derive(Deserialize)]
struct Profile {
id: String,
name: String,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
error: Option<String>,
}
pub struct Account {
pub name: String,
pub uuid: String,
pub access_token: String,
pub xuid: String,
}
fn token_path() -> anyhow::Result<PathBuf> {
let home = std::env::var("HOME")?;
Ok(Path::new(&home).join(".config").join("uml").join("token"))
}
fn save_refresh(token: &str) -> anyhow::Result<()> {
let path = token_path()?;
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(&path, token)?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
Ok(())
}
fn agent() -> Agent {
Agent::config_builder()
.http_status_as_error(false)
.build()
.new_agent()
}
fn device_code(agent: &Agent) -> anyhow::Result<Device> {
let mut res = agent.post(DEVICECODE_URL).send_form([
("client_id", CLIENT_ID),
("scope", "XboxLive.signin offline_access"),
])?;
if res.status() != 200 {
let body = res.body_mut().read_to_string()?;
anyhow::bail!("device code failed ({}): {body}", res.status());
}
Ok(res.body_mut().read_json()?)
}
fn refresh(agent: &Agent, refresh: &str) -> anyhow::Result<(String, String)> {
let mut res = agent.post(TOKEN_URL).send_form([
("grant_type", "refresh_token"),
("client_id", CLIENT_ID),
("refresh_token", refresh),
])?;
if res.status() != 200 {
let body = res.body_mut().read_to_string()?;
anyhow::bail!("refresh failed ({}): {body}", res.status());
}
let body: TokenResponse = res.body_mut().read_json()?;
let access = body
.access_token
.ok_or_else(|| anyhow::anyhow!("no access token"))?;
let new_refresh = body
.refresh_token
.ok_or_else(|| anyhow::anyhow!("no refresh token"))?;
Ok((access, new_refresh))
}
fn poll(agent: &Agent, device: &Device) -> anyhow::Result<(String, String)> {
println!(
"Go to {} and enter code: {}",
device.verification_uri, device.user_code
);
loop {
sleep(Duration::from_secs(device.interval));
let mut res = agent.post(TOKEN_URL).send_form([
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("client_id", CLIENT_ID),
("device_code", device.device_code.as_str()),
])?;
let body: TokenResponse = res.body_mut().read_json()?;
if let Some(token) = body.access_token {
let refresh = body.refresh_token.ok_or_else(|| {
anyhow::anyhow!("no refresh token — was offline_access in the scope?")
})?;
return Ok((token, refresh));
}
match body.error.as_deref() {
Some("authorization_pending") => continue,
Some("slow_down") => {
sleep(Duration::from_secs(5));
continue;
}
Some(other) => anyhow::bail!("auth failed: {other}"),
None => anyhow::bail!("unexpected response"),
}
}
}
fn xbl(agent: &Agent, ms_token: &str) -> anyhow::Result<XblResponse> {
let mut res = agent.post(XBL_URL).send_json(serde_json::json!({
"Properties": {
"AuthMethod": "RPS",
"SiteName": "user.auth.xboxlive.com",
"RpsTicket": format!("d={ms_token}")
},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT"
}))?;
if res.status() != 200 {
let body = res.body_mut().read_to_string()?;
anyhow::bail!("xbl failed ({}): {body}", res.status());
}
Ok(res.body_mut().read_json()?)
}
fn xsts(agent: &Agent, xbl_token: &str) -> anyhow::Result<XblResponse> {
let mut res = agent.post(XSTS_URL).send_json(serde_json::json!({
"Properties": { "SandboxId": "RETAIL", "UserTokens": [xbl_token] },
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT"
}))?;
if res.status() == 401 {
#[derive(Deserialize)]
struct XErr {
#[serde(rename = "XErr")]
xerr: u64,
}
let e: XErr = res.body_mut().read_json()?;
anyhow::bail!(
"{}",
match e.xerr {
2148916233 => "no Xbox profile — sign in at minecraft.net once".into(),
2148916235 => "Xbox Live unavailable in this region".into(),
2148916237 => "account needs adult verification".into(),
2148916238 => "child account — must be added to a Family".into(),
other => format!("XSTS failed, XErr {other}"),
}
);
}
if res.status() != 200 {
let body = res.body_mut().read_to_string()?;
anyhow::bail!("xsts failed ({}): {body}", res.status());
}
Ok(res.body_mut().read_json()?)
}
fn mc_token(agent: &Agent, uhs: &str, xsts_token: &str) -> anyhow::Result<String> {
let mut res = agent.post(MC_LOGIN_URL).send_json(serde_json::json!({
"identityToken": format!("XBL3.0 x={uhs};{xsts_token}")
}))?;
if res.status() != 200 {
let body = res.body_mut().read_to_string()?;
anyhow::bail!("mc login failed ({}): {body}", res.status());
}
let t: McTokenResponse = res.body_mut().read_json()?;
Ok(t.access_token)
}
fn profile(agent: &Agent, mc_token: &str) -> anyhow::Result<Profile> {
let mut res = agent
.get(PROFILE_URL)
.header("Authorization", &format!("Bearer {mc_token}"))
.call()?;
if res.status() == 404 {
anyhow::bail!("this account doesn't own Minecraft Java");
}
if res.status() != 200 {
let body = res.body_mut().read_to_string()?;
anyhow::bail!("profile failed ({}): {body}", res.status());
}
Ok(res.body_mut().read_json()?)
}
pub fn login() -> anyhow::Result<Account> {
let agent = agent();
let (ms, refresh) = match std::fs::read_to_string(token_path()?) {
Ok(cached) => match refresh(&agent, cached.trim()) {
Ok(pair) => pair,
Err(_) => {
// expired or revoked
let device = device_code(&agent)?;
poll(&agent, &device)?
}
},
Err(_) => {
// no file yet
let device = device_code(&agent)?;
poll(&agent, &device)?
}
};
save_refresh(&refresh)?;
let xbl = xbl(&agent, &ms)?;
let uhs = xbl.display_claims.xui[0].uhs.clone();
let xuid = xbl.display_claims.xui[0].xid.clone().unwrap_or_default();
let xsts = xsts(&agent, &xbl.token)?;
let mc = mc_token(&agent, &uhs, &xsts.token)?;
let p = profile(&agent, &mc)?;
Ok(Account {
name: p.name,
uuid: p.id,
access_token: mc,
xuid,
})
}
// pub fn gen_offline(name: &str) -> Account {
// let uuid = uuid::Uuid::new_v3(
// &uuid::Uuid::NAMESPACE_OID,
// format!("OfflinePlayer:{name}").as_bytes(),
// );
// Account {
// name: name.to_string(),
// uuid: uuid.simple().to_string(),
// access_token: "0".to_string(),
// xuid: String::new(),
// }
// }

93
src/download.rs Normal file
View File

@@ -0,0 +1,93 @@
use crate::meta::{AssetObjects, VersionDetail, wanted};
use indicatif::{ParallelProgressIterator, ProgressBar, ProgressStyle};
use rayon::prelude::*;
use sha2::Digest;
use std::fs::{File, create_dir_all};
use std::io::copy;
use std::path::Path;
use ureq::get;
pub fn download(url: &str, path: &Path) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
create_dir_all(parent)?;
}
let mut res = get(url).call()?;
let mut file = File::create(path)?;
copy(&mut res.body_mut().as_reader(), &mut file)?;
Ok(())
}
pub fn client(detail: &VersionDetail, shared_dir: &Path, version_id: &str) -> anyhow::Result<()> {
let out = shared_dir
.join("versions")
.join(version_id)
.join(format!("{version_id}.jar"));
if !out.exists() {
println!("Downloading client: {}...", detail.downloads.client.url);
download(&detail.downloads.client.url, &out)?;
}
Ok(())
}
pub fn libraries(detail: &VersionDetail, shared_dir: &Path) -> anyhow::Result<()> {
println!("Downloading libraries...");
let libs_dir = shared_dir.join("libraries");
let needed: Vec<_> = detail
.libraries
.iter()
.filter(|l| wanted(l))
.filter(|l| !libs_dir.join(&l.downloads.artifact.path).exists())
.collect();
if !needed.is_empty() {
let pb = ProgressBar::new(needed.len() as u64);
pb.set_style(ProgressStyle::with_template("{bar:40} {pos}/{len} {msg}").unwrap());
for lib in &needed {
let a = &lib.downloads.artifact;
pb.set_message(a.path.clone());
download(&a.url, &libs_dir.join(&a.path))?;
pb.inc(1);
}
pb.finish_and_clear();
}
Ok(())
}
pub fn assets(detail: &VersionDetail, shared_dir: &Path) -> anyhow::Result<()> {
println!("Downloading assets...");
let index: AssetObjects = get(&detail.asset_index.url)
.call()?
.body_mut()
.read_json()?;
let index_path = shared_dir
.join("assets")
.join("indexes")
.join(format!("{}.json", detail.asset_index.id));
if !index_path.exists() {
download(&detail.asset_index.url, &index_path)?;
}
let objects_dir = shared_dir.join("assets").join("objects");
let pb = ProgressBar::new(index.objects.len() as u64);
pb.set_style(ProgressStyle::with_template("{bar:40} {pos}/{len} ({eta})").unwrap());
index
.objects
.par_iter()
.progress_with(pb)
.try_for_each(|(_, obj)| -> anyhow::Result<()> {
let sub = &obj.hash[..2];
let url = format!(
"https://resources.download.minecraft.net/{sub}/{}",
obj.hash
);
let path = objects_dir.join(sub).join(&obj.hash);
if !path.exists() {
download(&url, &path)?;
}
Ok(())
})?;
Ok(())
}
pub fn verify_sha512(path: &Path, expected: &str) -> anyhow::Result<bool> {
let mut hasher = sha2::Sha512::new();
std::io::copy(&mut File::open(path)?, &mut hasher)?;
Ok(format!("{:x}", hasher.finalize()) == expected)
}

89
src/fabric.rs Normal file
View File

@@ -0,0 +1,89 @@
use std::collections::HashSet;
use crate::meta::{Library, VersionDetail};
use anyhow::Context;
use serde::Deserialize;
use ureq::get;
const META: &str = "https://meta.fabricmc.net/v2/versions/loader";
#[derive(Deserialize)]
struct LoaderEntry {
loader: LoaderInfo,
}
#[derive(Deserialize)]
struct LoaderInfo {
version: String,
stable: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
pub main_class: String,
pub libraries: Vec<FabricLibrary>,
}
#[derive(Deserialize)]
pub struct FabricLibrary {
name: String,
url: String,
}
fn loaders(mc_version: &str) -> anyhow::Result<Vec<LoaderEntry>> {
let url = format!("{META}/{mc_version}");
Ok(get(&url).call()?.body_mut().read_json()?)
}
pub fn latest_loader(mc: &str) -> anyhow::Result<String> {
let entries = loaders(mc)?;
entries
.iter()
.find(|e| e.loader.stable)
.or(entries.first())
.map(|e| e.loader.version.clone())
.ok_or_else(|| anyhow::anyhow!("no fabric loader for {mc}"))
}
pub fn check_loader(mc: &str, loader: &str) -> anyhow::Result<()> {
if !loaders(mc)?.iter().any(|e| e.loader.version == loader) {
anyhow::bail!("fabric loader {loader} isn't available for {mc}");
}
Ok(())
}
fn maven_to_path(coord: &str) -> anyhow::Result<String> {
let mut parts = coord.split(':');
let group = parts.next().context("bad coord")?;
let artifact = parts.next().context("bad coord")?;
let version = parts.next().context("bad coord")?;
Ok(format!(
"{}/{artifact}/{version}/{artifact}-{version}.jar",
group.replace('.', "/")
))
}
pub fn fetch_profile(mc: &str, loader: &str) -> anyhow::Result<Profile> {
let url = format!("{META}/{mc}/{loader}/profile/json");
Ok(get(&url).call()?.body_mut().read_json()?)
}
fn coord_key(name: &str) -> &str {
match name.match_indices(':').nth(1) {
Some((i, _)) => &name[..i],
None => name,
}
}
pub fn merge(detail: &mut VersionDetail, profile: Profile) -> anyhow::Result<()> {
let mut libs = Vec::new();
for fl in &profile.libraries {
let path = maven_to_path(&fl.name)?;
let url = format!("{}{path}", fl.url);
libs.push(Library::unconditional(fl.name.clone(), url, path));
}
let keys: HashSet<&str> = libs.iter().map(|l| coord_key(&l.name)).collect();
detail
.libraries
.retain(|l| !keys.contains(coord_key(&l.name)));
libs.append(&mut detail.libraries);
detail.libraries = libs;
detail.main_class = profile.main_class;
Ok(())
}

95
src/instance.rs Normal file
View File

@@ -0,0 +1,95 @@
use anyhow::Ok;
use serde::{Deserialize, Serialize};
use std::{
fs::create_dir_all,
path::{Path, PathBuf},
};
#[derive(Serialize, Deserialize)]
pub struct Instance {
pub version: String,
#[serde(default)]
pub loader: Option<Loader>,
}
#[derive(Serialize, Deserialize)]
pub struct Loader {
pub kind: String,
pub version: String,
}
pub fn create(instances_dir: &Path, name: &str, version: &str) -> anyhow::Result<PathBuf> {
if name.contains('/') {
anyhow::bail!("unusable characters in path.");
}
let directory = instances_dir.join(name);
if directory.join("instance.json").exists() {
anyhow::bail!("instance already exists here!");
}
create_dir_all(&directory)?;
let cfg = Instance {
version: version.to_string(),
loader: None,
};
save(&directory, &cfg)?;
Ok(directory)
}
pub fn save(dir: &Path, cfg: &Instance) -> anyhow::Result<()> {
let json = serde_json::to_string_pretty(cfg)?;
std::fs::write(dir.join("instance.json"), json)?;
Ok(())
}
pub fn load(root: &Path, name: &str) -> anyhow::Result<(Instance, PathBuf)> {
let dir = root.join(name);
let cfg_path = dir.join("instance.json");
if !cfg_path.exists() {
anyhow::bail!("no instance named {name}");
}
let json = std::fs::read_to_string(cfg_path)?;
Ok((serde_json::from_str(&json)?, dir))
}
pub fn list(root: &Path) -> anyhow::Result<Vec<String>> {
if !root.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(root)? {
let entry = entry?;
if entry.path().join("instance.json").exists() {
out.push(entry.file_name().to_string_lossy().to_string());
}
}
out.sort();
Ok(out)
}
pub fn remove(instances_dir: &Path, name: &str, yes: bool) -> anyhow::Result<()> {
let dir = instances_dir.join(&name);
if !dir.exists() {
anyhow::bail!("no instance named {name}");
}
if !yes {
print!("Delete {name} and all its worlds? [y/N] ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut a = String::new();
std::io::stdin().read_line(&mut a)?;
if !a.trim().eq_ignore_ascii_case("y") {
anyhow::bail!("aborted");
}
}
std::fs::remove_dir_all(&dir)?;
println!("Removed {name}.");
Ok(())
}
pub fn sanitize(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || " -_.".contains(c) {
c
} else {
'-'
}
})
.collect::<String>()
.trim_matches(['-', '.', ' '])
.to_string()
}

104
src/launch.rs Normal file
View File

@@ -0,0 +1,104 @@
use crate::{
auth::Account,
meta::{Arg, ArgValue, VersionDetail, rules_allow, wanted},
};
use std::{collections::HashMap, fs::create_dir_all, path::Path, process::Command};
fn flatten(args: &[Arg]) -> Vec<String> {
let mut out = Vec::new();
for arg in args {
match arg {
Arg::Plain(s) => out.push(s.clone()),
Arg::Conditional { rules, value } => {
if !rules_allow(rules) {
continue;
}
match value {
ArgValue::One(s) => out.push(s.clone()),
ArgValue::Many(v) => out.extend(v.iter().cloned()),
}
}
}
}
out
}
fn substitute(tokens: Vec<String>, vars: &HashMap<&str, String>) -> Vec<String> {
tokens
.into_iter()
.map(|mut t| {
for (k, v) in vars {
t = t.replace(k, v);
}
t
})
.collect()
}
pub fn run(
detail: &VersionDetail,
game_dir: &Path,
shared_dir: &Path,
account: &Account,
version_type: &str,
version_id: &str,
) -> anyhow::Result<()> {
let client_jar = shared_dir
.join("versions")
.join(version_id)
.join(format!("{version_id}.jar"));
let mut vars = HashMap::new();
let mut classpath: Vec<String> = detail
.libraries
.iter()
.filter(|l| wanted(l))
.map(|l| {
shared_dir
.join("libraries")
.join(&l.downloads.artifact.path)
.display()
.to_string()
})
.collect();
classpath.push(client_jar.display().to_string());
vars.insert("${classpath}", classpath.join(":"));
vars.insert(
"${natives_directory}",
shared_dir.join("natives").display().to_string(),
);
vars.insert("${auth_player_name}", account.name.clone());
vars.insert("${launcher_name}", "uml".to_string());
vars.insert("${launcher_version}", "1.0".to_string());
vars.insert("${version_name}", version_id.to_string());
vars.insert("${game_directory}", game_dir.display().to_string());
vars.insert(
"${assets_root}",
shared_dir.join("assets").display().to_string(),
);
vars.insert("${assets_index_name}", detail.asset_index.id.clone());
vars.insert("${auth_uuid}", account.uuid.clone());
vars.insert("${auth_access_token}", account.access_token.clone());
vars.insert("${clientid}", String::new());
vars.insert("${auth_xuid}", account.xuid.clone());
vars.insert("${version_type}", version_type.to_string());
let jvm_args = substitute(flatten(&detail.arguments.jvm), &vars);
let game_args = substitute(flatten(&detail.arguments.game), &vars);
for sub in ["java", "jna", "lwjgl", "netty"] {
create_dir_all(shared_dir.join("natives").join(sub))?;
}
let status = Command::new("java") // This is what makes UML, UML! the heart of the program.
.current_dir(game_dir)
.args(&jvm_args)
.arg(&detail.main_class)
.args(&game_args)
.status()?;
if !status.success() {
anyhow::bail!("minecraft exited with {status}");
}
Ok(())
}

196
src/main.rs Normal file
View File

@@ -0,0 +1,196 @@
mod auth; // this is painful, ai helped a bit btw (i try to use ai as little as possible, but this part's just ANNOYING)
mod download;
mod fabric;
mod instance;
mod launch;
mod meta;
mod mrpack;
mod prism;
use crate::{launch::run, meta::fetch_version};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
/// Untitled Minecraft Launcher
#[derive(Parser)]
#[command(name = "uml")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Clone, clap::ValueEnum)]
enum LoaderKind {
Fabric,
}
#[derive(Subcommand)]
enum Command {
/// Launch an instance
#[command(alias = "l")]
Launch {
#[arg(trailing_var_arg = true)]
name: Vec<String>,
// disabled until i redo the account system
// #[arg(long)]
// offline: bool,
},
/// Manage instances
Instances {
#[command(subcommand)]
action: InstanceCmd,
},
}
#[derive(Subcommand)]
enum InstanceCmd {
/// Create a new instance
New {
name: String,
#[arg(long)]
version: String,
},
/// Import a Modrinth modpack
Import {
pack: PathBuf,
#[arg(long)]
name: Option<String>,
#[arg(short = 'y', long)]
yes: bool,
},
/// Import a Prism Launcher instance
ImportPrism {
path: PathBuf,
#[arg(long)]
name: Option<String>,
},
/// Install a mod loader on an instance
Mod {
name: String,
#[arg(long, value_enum, default_value_t = LoaderKind::Fabric)]
loader: LoaderKind,
#[arg(long)]
loader_version: Option<String>,
},
/// Unmod an instance
Unmod { name: String },
/// Remove an instance
Remove {
name: String,
#[arg(short = 'y', long)]
yes: bool,
},
/// List instances
List,
}
fn data_dir() -> anyhow::Result<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return Ok(Path::new(&xdg).join("uml"));
}
}
let home = std::env::var("HOME")?;
Ok(Path::new(&home).join(".local").join("share").join("uml"))
}
fn main() -> anyhow::Result<()> {
let uml_dir = data_dir()?;
let instances_dir = uml_dir.join("instances");
let shared_dir = uml_dir.join("shared");
let cli = Cli::parse();
match cli.command {
Command::Instances { action } => match action {
InstanceCmd::New { name, version } => {
if !meta::version_exists(&version)? {
anyhow::bail!("no such Minecraft version: {version}");
}
instance::create(&instances_dir, &name, &version)?;
}
InstanceCmd::Import { pack, name, yes } => {
mrpack::import(&pack, &instances_dir, name.as_deref(), yes)?;
}
InstanceCmd::ImportPrism { path, name } => {
prism::import(&path, &instances_dir, name.as_deref())?;
}
InstanceCmd::Remove { name, yes } => {
instance::remove(&instances_dir, &name, yes)?;
}
InstanceCmd::List => {
for n in instance::list(&instances_dir)? {
println!("{n}");
}
}
InstanceCmd::Unmod { name } => {
let (mut cfg, dir) = instance::load(&instances_dir, &name)?;
if cfg.loader.is_none() {
anyhow::bail!("{name} has no loader");
}
cfg.loader = None;
instance::save(&dir, &cfg)?;
println!("Removed loader from {name}.");
}
InstanceCmd::Mod {
name,
loader,
loader_version,
} => {
let (mut cfg, dir) = instance::load(&instances_dir, &name)?;
let (kind, version) = match loader {
LoaderKind::Fabric => {
let v = match loader_version {
Some(v) => {
fabric::check_loader(&cfg.version, &v)?;
v
}
None => fabric::latest_loader(&cfg.version)?,
};
("fabric", v)
}
};
cfg.loader = Some(instance::Loader {
kind: kind.to_string(),
version: version.clone(),
});
instance::save(&dir, &cfg)?;
std::fs::create_dir_all(dir.join("mods"))?;
println!("Installed {kind} {version} on {name}.");
}
},
Command::Launch { name } => {
//, offline } => {
let name = name.join(" ");
let (cfg, game_dir) = instance::load(&instances_dir, &name)?;
let game_version = cfg.version;
let (mut detail, version_type) = fetch_version(&game_version)?;
// let account = if !offline {
let account = auth::login()?;
// } else {
// auth::gen_offline("TESTING-USER")
// };
if let Some(loader) = &cfg.loader {
let profile = match loader.kind.as_str() {
"fabric" => fabric::fetch_profile(&game_version, &loader.version)?,
other => anyhow::bail!("unknown loader: {other}"),
};
fabric::merge(&mut detail, profile)?;
}
download::client(&detail, &shared_dir, &game_version)?;
download::libraries(&detail, &shared_dir)?;
download::assets(&detail, &shared_dir)?;
// Congrats! you found the main part of the program!
run(
&detail,
&game_dir,
&shared_dir,
&account,
&version_type,
&game_version,
)?;
}
}
Ok(())
}

179
src/meta.rs Normal file
View File

@@ -0,0 +1,179 @@
use serde::Deserialize;
use std::collections::HashMap;
use ureq::get;
const MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
#[derive(Deserialize)]
pub struct Manifest {
pub versions: Vec<Version>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct Version {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub url: String,
pub time: String,
pub release_time: String,
pub sha1: String,
pub compliance_level: u32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientDetails {
pub url: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MainDownloads {
pub client: ClientDetails,
}
#[derive(Deserialize)]
pub struct Library {
pub name: String,
pub downloads: LibraryDownloads,
#[serde(default)]
rules: Vec<Rule>,
}
#[derive(Deserialize)]
pub struct LibraryDownloads {
pub artifact: Artifact,
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct Artifact {
pub url: String,
pub sha1: Option<String>,
pub path: String,
}
#[derive(Deserialize)]
pub struct Rule {
action: String,
#[serde(default)]
os: Option<Os>,
#[serde(default)]
features: Option<HashMap<String, bool>>,
}
#[derive(Deserialize)]
struct Os {
#[serde(default)]
name: Option<String>,
#[serde(default)]
arch: Option<String>,
}
#[derive(Deserialize)]
pub struct AssetIndex {
pub id: String,
pub url: String,
}
#[derive(Deserialize)]
pub struct AssetObjects {
pub objects: HashMap<String, AssetObject>,
}
#[derive(Deserialize)]
pub struct AssetObject {
pub hash: String,
}
#[derive(Deserialize)]
pub struct Arguments {
#[serde(default)]
pub game: Vec<Arg>,
#[serde(default)]
pub jvm: Vec<Arg>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VersionDetail {
pub downloads: MainDownloads,
pub libraries: Vec<Library>,
pub asset_index: AssetIndex,
pub arguments: Arguments,
pub main_class: String,
}
impl Library {
pub fn unconditional(name: String, url: String, path: String) -> Self {
Library {
downloads: LibraryDownloads {
artifact: Artifact {
url,
path,
sha1: None,
},
},
rules: Vec::new(),
name: name,
}
}
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum Arg {
Plain(String),
Conditional { rules: Vec<Rule>, value: ArgValue },
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum ArgValue {
One(String),
Many(Vec<String>),
}
fn os_applies(os: &Os) -> bool {
if let Some(name) = &os.name {
if name != "linux" {
return false;
}
}
if let Some(arch) = &os.arch {
if arch != "x86_64" {
return false;
}
}
true
}
pub fn rules_allow(rules: &[Rule]) -> bool {
let mut allowed = rules.is_empty();
for r in rules {
let applies = r.features.is_none() && r.os.as_ref().map_or(true, os_applies);
if applies {
allowed = r.action == "allow";
}
}
allowed
}
pub fn wanted(lib: &Library) -> bool {
rules_allow(&lib.rules)
}
pub fn fetch_version(version_id: &str) -> anyhow::Result<(VersionDetail, String)> {
let manifest: Manifest = get(MANIFEST_URL).call()?.body_mut().read_json()?;
let version = manifest
.versions
.iter()
.find(|v| v.id == version_id)
.ok_or_else(|| anyhow::anyhow!("version {version_id} not found"))?;
let detail: VersionDetail = get(&version.url).call()?.body_mut().read_json()?;
Ok((detail, version.kind.clone()))
}
pub fn version_exists(version_id: &str) -> anyhow::Result<bool> {
let manifest: Manifest = get(MANIFEST_URL).call()?.body_mut().read_json()?;
Ok(manifest.versions.iter().any(|v| v.id == version_id))
}

172
src/mrpack.rs Normal file
View File

@@ -0,0 +1,172 @@
use crate::{
download::download,
download::verify_sha512,
instance::{Loader, create, load, save},
};
use indicatif::{ProgressBar, ProgressStyle};
use serde::Deserialize;
use std::{collections::HashMap, fs::File, io::Write, path::Path};
use zip::ZipArchive;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Index {
format_version: u32,
game: String, // now what is modrinth up to?
version_id: String,
name: String,
files: Vec<PackFile>,
dependencies: HashMap<String, String>,
}
#[derive(Deserialize)]
struct PackFile {
path: String,
hashes: Hashes,
downloads: Vec<String>,
#[serde(default)]
env: Option<Env>,
}
#[derive(Deserialize)]
struct Hashes {
sha512: String,
}
#[derive(Deserialize)]
struct Env {
client: String,
}
fn extract_overrides(
archive: &mut ZipArchive<File>,
prefix: &str,
instance_dir: &Path,
) -> anyhow::Result<()> {
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let name = entry.name().to_string();
let Some(rel) = name.strip_prefix(prefix) else {
continue;
};
if rel.is_empty() || entry.is_dir() {
continue;
}
if rel.contains("..") || rel.starts_with('/') {
anyhow::bail!("suspicious path in pack: {name}");
}
let dest = instance_dir.join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out = File::create(&dest)?;
std::io::copy(&mut entry, &mut out)?;
}
Ok(())
}
pub fn import(
pack: &Path,
instances_dir: &Path,
name: Option<&str>,
yes: bool,
) -> anyhow::Result<()> {
let mut archive = ZipArchive::new(File::open(pack)?)?;
let index_file = archive.by_name("modrinth.index.json")?;
let index: Index = serde_json::from_reader(index_file)?;
if index.format_version != 1 {
anyhow::bail!("unsupported mrpack format version {}", index.format_version);
}
let instance_name = match name {
Some(n) => n.to_string(),
None => crate::instance::sanitize(&index.name),
};
let mc = index
.dependencies
.get("minecraft")
.ok_or_else(|| anyhow::anyhow!("pack has no minecraft version"))?;
let loader = if let Some(v) = index.dependencies.get("fabric-loader") {
Some(Loader {
kind: "fabric".to_string(),
version: v.clone(),
})
} else if index.dependencies.contains_key("forge")
|| index.dependencies.contains_key("neoforge")
{
anyhow::bail!("Forge modpacks are not currently supported.");
} else if index.dependencies.contains_key("quilt-loader") {
anyhow::bail!("Quilt modpacks are not currently supported.");
} else {
println!(
"This modpack either doesn't have a modloader, or uses one that is unsupported. This is fine, but any mods won't load."
);
None
};
let as_name = match name {
Some(n) => format!(" as {n}"),
None => String::new(),
};
let wanted: Vec<_> = index
.files
.iter()
.filter(|f| f.env.as_ref().map_or(true, |e| e.client != "unsupported"))
.collect();
let loader_bit = match &loader {
Some(l) => format!(", which will use {} with {} mods", l.kind, wanted.len()),
None => String::new(),
};
println!(
"You are importing the modpack {}{}, this modpack will use minecraft version {}{}.",
index.name, as_name, mc, loader_bit
);
if !yes {
print!("Continue? [y/N] ");
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !answer.trim().eq_ignore_ascii_case("y") {
anyhow::bail!("aborted");
}
}
println!("Creating instance...");
let instance_dir = create(instances_dir, &instance_name, mc)?;
let (mut cfg, _) = load(instances_dir, &instance_name)?;
cfg.loader = loader;
save(&instance_dir, &cfg)?;
println!("Downloading files...");
let pb = ProgressBar::new(wanted.len() as u64);
pb.set_style(ProgressStyle::with_template("{bar:40} {pos}/{len} {msg}").unwrap());
for f in &wanted {
if f.path.contains("..") || f.path.starts_with('/') {
anyhow::bail!("suspicious path in pack: {}", f.path);
}
pb.set_message(f.path.clone());
let dest = instance_dir.join(&f.path);
if !dest.exists() || !verify_sha512(&dest, &f.hashes.sha512)? {
let url = f
.downloads
.first()
.ok_or_else(|| anyhow::anyhow!("no download url for {}", f.path))?;
download(url, &dest)?;
if !verify_sha512(&dest, &f.hashes.sha512)? {
anyhow::bail!("hash mismatch after download: {}", f.path);
}
}
pb.inc(1);
}
pb.finish_and_clear();
println!("Copying overrides...");
extract_overrides(&mut archive, "overrides/", &instance_dir)?;
extract_overrides(&mut archive, "client-overrides/", &instance_dir)?;
println!("The modpack has (probably) been installed successfully!");
Ok(())
}

81
src/prism.rs Normal file
View File

@@ -0,0 +1,81 @@
use crate::instance::{Loader, create, load, save};
use serde::Deserialize;
use std::path::Path;
#[derive(Deserialize)]
struct MmcPack {
components: Vec<Component>,
}
#[derive(Deserialize)]
struct Component {
uid: String,
#[serde(default)]
version: Option<String>,
}
fn find<'a>(pack: &'a MmcPack, uid: &str) -> Option<&'a str> {
pack.components
.iter()
.find(|c| c.uid == uid)
.and_then(|c| c.version.as_deref())
}
fn copy_dir(src: &Path, dst: &Path) -> anyhow::Result<()> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
std::fs::create_dir_all(&to)?;
copy_dir(&from, &to)?; // recurse into subdirectories
} else {
std::fs::copy(&from, &to)?; // std's single-file copy
}
}
Ok(())
}
pub fn import(prism_dir: &Path, instances_dir: &Path, name: Option<&str>) -> anyhow::Result<()> {
let json = std::fs::read_to_string(prism_dir.join("mmc-pack.json"))?;
let pack: MmcPack = serde_json::from_str(&json)?;
let mc =
find(&pack, "net.minecraft").ok_or_else(|| anyhow::anyhow!("no minecraft component"))?;
let loader = if let Some(v) = find(&pack, "net.fabricmc.fabric-loader") {
Some(Loader {
kind: "fabric".into(),
version: v.to_string(),
})
} else if find(&pack, "net.minecraftforge").is_some() || find(&pack, "net.neoforged").is_some()
{
anyhow::bail!("Forge instances aren't supported yet");
} else if find(&pack, "org.quiltmc.quilt-loader").is_some() {
anyhow::bail!("Quilt instances aren't supported yet");
} else {
None
};
let instance_name = match name {
Some(n) => n.to_string(),
None => prism_dir
.file_name()
.and_then(|s| s.to_str())
.map(crate::instance::sanitize)
.ok_or_else(|| anyhow::anyhow!("bad prism path"))?,
};
let dir = create(instances_dir, &instance_name, mc)?;
let (mut cfg, _) = load(instances_dir, &instance_name)?;
cfg.loader = loader;
save(&dir, &cfg)?;
let src = prism_dir.join("minecraft");
if !src.exists() {
anyhow::bail!("no minecraft in {}", prism_dir.display());
}
copy_dir(&src, &dir)?;
println!("Imported {instance_name} (minecraft {mc})");
Ok(())
}