refactor: Way cleaner version + instance management system

This commit is contained in:
LynithDev 2024-03-23 16:39:44 +01:00
parent 629a9c2a44
commit ffab61ebf0
26 changed files with 574 additions and 877 deletions

View file

@ -1,2 +0,0 @@
[alias]
prisma = "run -p prisma-cli --bin prisma --"

View file

@ -1,9 +1,6 @@
[workspace]
resolver = "2"
members = [
"src-tauri",
"crates/*",
]
members = [ "src-tauri", ]
[workspace.package]
license = "AGPL-3.0-only"
@ -20,12 +17,10 @@ tauri-build = { version = "2.0.0-beta", features = [] }
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-http = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust", tag = "0.6.11", default-features = false, features = [ "sqlite" ] }
prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client-rust", tag = "0.6.11", default-features = false, features = [ "sqlite" ] }
uuid = { version = "1.8", features = [ "serde" ] }
serde = { version = "1", features = [ "derive" ] }
tokio = { version = "1", features = [ "full" ] }
serde = { version = "1", features = [ "derive" ] }
chrono = { version = "0.4", features = [ "serde" ] }
serde_json = { version = "1" }
flate2 = { version = "1" }

View file

@ -1,13 +0,0 @@
[package]
name = "prisma-cli"
version = "0.1.0"
license = { workspace = true }
edition = { workspace = true }
repository = { workspace = true }
documentation = { workspace = true }
readme = { workspace = true }
homepage = { workspace = true }
authors = { workspace = true }
[dependencies]
prisma-client-rust-cli = { workspace = true }

View file

@ -1,3 +0,0 @@
fn main() {
prisma_client_rust_cli::run();
}

View file

@ -1,16 +0,0 @@
[package]
name = "poly-prisma"
version = "0.1.0"
license = { workspace = true }
edition = { workspace = true }
repository = { workspace = true }
documentation = { workspace = true }
readme = { workspace = true }
homepage = { workspace = true }
authors = { workspace = true }
[dependencies]
prisma-client-rust = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }

View file

@ -1,2 +0,0 @@
#[allow(warnings, unused)]
pub mod prisma;

View file

@ -11,8 +11,7 @@
"serve": "vite preview",
"tauri": "tauri",
"lint": "eslint . --cache",
"format": "pnpm lint --fix && cargo fmt",
"prisma": "cd src-tauri && cargo prisma"
"format": "pnpm lint --fix && cargo fmt"
},
"dependencies": {
"@fontsource/poppins": "^5.0.12",

View file

@ -14,7 +14,6 @@ authors = { workspace = true }
tauri-build = { workspace = true }
[dependencies]
poly-prisma = { path = "../crates/prisma" }
tauri = { workspace = true }
tauri-plugin-shell = { workspace = true }
tauri-plugin-http = { workspace = true }
@ -26,9 +25,10 @@ tar = { workspace = true }
zip = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
prisma-client-rust = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
sha1 = "0.10.6"
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!

View file

@ -6,7 +6,11 @@ fn main() {
.plugin("auth", InlinedPlugin::new().commands(&["login_msa"]))
.plugin(
"game",
InlinedPlugin::new().commands(&["launch_game", "set_selected_client"]),
InlinedPlugin::new().commands(&[
"create_instance",
"get_instances",
"get_instance"
]),
),
);
}

View file

@ -1,6 +1,7 @@
[default]
description = "Default permissions for the plugin"
permissions = [
"allow-launch-game",
"allow-set-selected-client"
"allow-get-instance",
"allow-get-instances",
"allow-create-instance"
]

View file

@ -1,32 +0,0 @@
-- CreateTable
CREATE TABLE "instance" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT,
"path" TEXT,
"hidden" BOOLEAN,
"time_played" DATETIME NOT NULL,
"last_played" DATETIME NOT NULL,
"date_created" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "java_instance" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT,
"path" TEXT,
"version" TEXT,
"arch" TEXT,
"instance_id" INTEGER
);
-- CreateTable
CREATE TABLE "location" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT,
"path" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"hidden" BOOLEAN,
"date_added" DATETIME,
"instance_id" INTEGER,
CONSTRAINT "location_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instance" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);

View file

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View file

@ -1,100 +0,0 @@
datasource db {
provider = "sqlite"
url = "file:dev.db"
}
generator client {
provider = "cargo prisma"
output = "../../crates/prisma/src/prisma.rs"
}
// todo: replace all `path` fields with a model with a checksum n shit
// todo: replace all `String` fields with an enum if applicable
// represents a single minecraft instance
model Instance {
id Int @id @default(autoincrement())
name String?
path String?
hidden Boolean?
time_played DateTime
last_played DateTime
date_created DateTime @default(now())
mods Mod[]
java_instance JavaInstance @relation(fields: [java_instance_id], references: [id])
java_instance_id Int
@@map("instance")
}
model JavaInstance {
id Int @id @default(autoincrement())
name String?
path String?
version String?
arch String?
instance Instance[]
@@map("java_instance")
}
model Asset {
id Int @id @default(autoincrement())
name String?
path String?
enabled Boolean @default(true)
hidden Boolean?
date_added DateTime?
/// @local
instance_id Int?
instance Instance? @relation(fields: [instance_id], references: [id], onDelete: SetNull)
@@map("asset")
}
model MinecraftManifest {
id String @id
assets String
compliance_level Int
downloads Downloads
@@map("minecraft_manifest")
}
model Downloads {
client Client
@@map("downloads")
}
model Client {
sha1 String @unique
size Int
url String
@@map("client")
}
model JavaVersion {
component String
major_version MajorVersion @default(VERSION_1_21)
}
enum MajorVersion {
VERSION_1_21
VERSION_1_8
}
model Library {
downloads LibraryDownloads
name String
rules Rule[]
extract Extract?
natives Natives?
}

View file

@ -1,270 +1,173 @@
use std::{collections::HashMap, env, fs, path::PathBuf};
use std::{collections::HashMap, fs, path::PathBuf};
use anyhow::anyhow;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use chrono::{DateTime, Local};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use thiserror::Error;
use uuid::Uuid;
use crate::{
utils::{file, http},
PolyError, PolyResult,
};
use crate::PolyResult;
use super::{
clients::vanilla::{vanilla_manifest, VanillaClient, VanillaManifest},
JavaVersion,
};
use super::{minecraft::MinecraftManifest, vanilla};
#[macro_export]
macro_rules! create_manifest {
($manifest_name:ident { $($name:ident: $type:ty),* }) => {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct $manifest_name {
$(pub $name: $type,)*
}
};
}
#[macro_export]
macro_rules! create_client {
($client_name:ident { $($name:ident: $type:ty),* }) => {
#[derive(Debug, Clone)]
pub struct $client_name {
handle: tauri::AppHandle,
details: crate::game::client::GameClientDetails,
$(pub $name: $type,)*
}
};
#[derive(Debug, Serialize, Deserialize)]
pub struct Manifest {
pub id: Uuid,
pub manifest: MinecraftManifest
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "manifest")]
pub enum GameClientType {
Vanilla(VanillaManifest),
// Forge(ForgeManifest),
pub struct Instance {
pub id: Uuid,
pub created_at: DateTime<Local>,
pub name: String,
pub cover: Option<String>,
pub group: Option<Uuid>,
pub client: ClientType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameClientDetails {
pub uuid: Uuid,
pub name: String,
pub version: String,
pub main_class: String,
pub java_version: JavaVersion,
pub startup_args: Vec<String>,
pub client_type: GameClientType,
}
pub fn get_game_client(handle: AppHandle, details: GameClientDetails) -> Box<dyn GameClient> {
Box::new(match details.client_type {
GameClientType::Vanilla(_) => VanillaClient::new(handle, details),
// GameClientType::Forge(_) => Box::new(ForgeClient::new(handle, details)),
})
#[serde(tag = "type")]
pub enum ClientType {
Vanilla
}
#[async_trait]
pub trait GameClient: Send + Sync {
fn new(handle: AppHandle, details: GameClientDetails) -> Self
where
Self: Sized;
pub trait ClientTrait: Send + Sync {
fn from_instance(instance: Instance) -> Self where Self: Sized;
async fn get_all_version_urls() -> PolyResult<HashMap<String, String>>
where
Self: Sized;
async fn get_minecraft_manifest(
version: String,
) -> PolyResult<vanilla_manifest::MinecraftManifest>
where
Self: Sized;
async fn launch(&self) -> PolyResult<()>;
async fn setup(&self) -> PolyResult<()>;
// async fn get_details_from_version(version: String) -> Result<GameClientDetails, Box<dyn Error>> where Self: Sized;
fn get_handle(&self) -> &AppHandle;
fn get_details(&self) -> &GameClientDetails;
fn get_client(&self) -> &GameClientType {
&self.get_details().client_type
}
async fn launch(&self) -> PolyResult<()>;
async fn setup(&self) -> PolyResult<()>;
async fn download_java(&self) -> PolyResult<PathBuf> {
let config_dir = match self.get_handle().path().app_config_dir() {
Ok(dir) => dir,
// SURELY THERES A BETTER WAY TO DO THIS IDK LOLL
Err(err) => {
return Err(PolyError::JavaError(JavaDownloadError::UnsupportedOS(
err.to_string(),
)))
}
};
let java_dir = config_dir.join("java");
if !java_dir.exists() {
match fs::create_dir_all(&java_dir) {
Ok(_) => (),
Err(err) => {
return Err(PolyError::JavaError(JavaDownloadError::PermissionDenied(
err.to_string(),
)))
}
};
}
let java_version = &self.get_details().java_version;
let os = env::consts::OS;
let archive_type = match os {
"windows" => "zip",
"macos" => "tar.gz",
"linux" => "tar.gz",
_ => {
return Err(PolyError::JavaError(JavaDownloadError::UnsupportedOS(
os.to_string(),
)))
}
};
let archive_name = format!("zulu-{}.{}", java_version.to_string(), archive_type);
let archive = java_dir.join(&archive_name);
let dest = java_dir.join(format!("zulu-{}-{}", java_version.to_string(), get_arch()));
if archive.exists() && dest.exists() {
let _ = fs::remove_file(archive.as_path());
} else if archive.exists() && !dest.exists() {
extract(&archive, &dest)?;
} else if !archive.exists() && !dest.exists() {
download(java_version, os, archive_type, &archive).await?;
extract(&archive, &dest)?;
}
if let Ok(mut files) = fs::read_dir(&dest) {
let file = match files.nth(0) {
Some(file) => file.unwrap().path(),
None => {
return Err(PolyError::JavaError(JavaDownloadError::NoJavaVersionFound(
"unable to get the java executable file".to_string(),
)))
}
};
return Ok(file.join("bin").join("java"));
}
Err(PolyError::JavaError(JavaDownloadError::NoJavaVersionFound(
"unable to download the java executable file".to_string(),
)))
}
async fn install_game(&self) -> PolyResult<()>;
async fn install_libraries(&self) -> PolyResult<String>;
async fn install_natives(&self) -> PolyResult<()>;
async fn install_assets(&self) -> PolyResult<()>;
}
#[async_trait]
pub trait ModdedGameClient: GameClient {
async fn install_mods(&self) -> PolyResult<()>;
pub struct ClientManager {
instances: HashMap<Uuid, Instance>,
instances_dir: PathBuf,
manifests: HashMap<Uuid, Manifest>,
manifests_dir: PathBuf,
}
#[derive(Debug, Error)]
pub enum JavaDownloadError {
#[error("failed to find a valid java version: {0}")]
NoJavaVersionFound(String),
#[error("failed creating a java directory (try running at an elevated permission): {0}")]
PermissionDenied(String),
#[error("unable to match your operating system ({0}) with a valid java version")]
UnsupportedOS(String),
#[error("unable to match your architecture ({0}) with a valid java version")]
UnsupportedArch(String),
#[error("failed to extract the java executable: {0}")]
ExtractError(String),
#[error("failed to download the java executable")]
DownloadError(String),
impl ClientManager {
pub fn new(handle: &AppHandle) -> PolyResult<Self> {
let instances_dir = handle.path().app_config_dir()?.join("instances");
let instances = load_and_serialize::<Instance>(&instances_dir)?;
let manifests_dir = handle.path().app_config_dir()?.join("manifests");
let manifests = load_and_serialize::<Manifest>(&manifests_dir)?;
Ok(Self {
instances,
instances_dir,
manifests,
manifests_dir,
})
}
pub fn get_instances(&self) -> Vec<&Instance> {
self.instances.values().collect()
}
pub fn get_instances_owned(&self) -> Vec<Instance> {
self.instances.values().cloned().collect()
}
pub fn get_instance(&self, uuid: Uuid) -> Option<&Instance> {
self.instances.get(&uuid)
}
pub fn get_instance_mut(&mut self, uuid: Uuid) -> Option<&mut Instance> {
self.instances.get_mut(&uuid)
}
pub fn get_manifest(&self, uuid: Uuid) -> Option<&Manifest> {
self.manifests.get(&uuid)
}
pub fn get_manifest_mut(&mut self, uuid: Uuid) -> Option<&mut Manifest> {
self.manifests.get_mut(&uuid)
}
pub async fn create_instance(&mut self,
name: String,
version: String,
cover: Option<String>,
group: Option<Uuid>,
client: ClientType
) -> PolyResult<Uuid> {
// TODO: Implement cahcing
let url = String::from("https://piston-meta.mojang.com/v1/packages/d546f1707a3f2b7d034eece5ea2e311eda875787/1.8.9.json");
let uuid = Uuid::new_v4();
// Save the manifest
let minecraft_manifest = vanilla::retrieve_version_manifest(url).await?;
let manifest = Manifest {
id: uuid,
manifest: minecraft_manifest
};
save(&self.manifests_dir.join(format!("{}.json", uuid)), &manifest)?;
self.manifests.insert(uuid, manifest);
// Save the instance
let instance = Instance {
id: uuid,
created_at: Local::now(),
name,
cover,
group,
client
};
save(&self.instances_dir.join(format!("{}.json", uuid)), &instance)?;
self.instances.insert(uuid, instance);
Ok(uuid)
}
}
fn get_arch() -> String {
let arch = env::consts::ARCH;
match arch {
"x86" => "x86",
"x86_64" => "x64",
"arm" => "aarch32",
"aarch64" => "aarch64",
_ => "unsupported",
}
.to_string()
fn save<T>(path: &PathBuf, object: T) -> PolyResult<()> where T: Serialize {
if !path.exists() {
fs::create_dir_all(path.parent().expect("Couldn't get parent of path whilst saving object. 'client.rs'"))?;
}
let content = serde_json::to_string(&object)?;
fs::write(path, content)?;
Ok(())
}
async fn get_java_versions(
java_version: &JavaVersion,
os: &str,
archive_type: &str,
) -> PolyResult<Value> {
let url = format!(
"
https://api.azul.com/metadata/v1/zulu/packages/\
?java_version={}\
&os={}\
&arch={}\
&archive_type={}\
&java_package_type=jre&javafx_bundled=true&release_status=ga&latest=true",
java_version.to_string(),
os,
get_arch(),
archive_type
);
fn load_and_serialize<T>(dir: &PathBuf) -> PolyResult<HashMap<Uuid, T>> where T: DeserializeOwned {
let mut result = HashMap::<Uuid, T>::new();
let response = match http::create_client()?.get(&url).send().await {
Ok(response) => match response.json::<serde_json::Value>().await {
Ok(json) => json,
Err(err) => {
return Err(PolyError::JavaError(JavaDownloadError::NoJavaVersionFound(
err.to_string(),
)))
}
},
Err(err) => {
return Err(PolyError::JavaError(JavaDownloadError::NoJavaVersionFound(
err.to_string(),
)))
}
};
if !dir.exists() {
fs::create_dir_all(dir)?;
return Ok(result);
}
for file in fs::read_dir(dir)? {
let file = file?;
let file_name = file.file_name();
let file_name = file_name.to_str().ok_or(anyhow!("Couldn't convert OsStr to str"))?.replace(".json", "");
let uuid = match Uuid::parse_str(&file_name) {
Ok(uuid) => uuid,
Err(_) => {
eprintln!("Couldn't parse file name as UUID: '{:?}'", file_name);
continue;
}
};
if !response.is_array() || response.as_array().unwrap().is_empty() {
return Err(PolyError::JavaError(JavaDownloadError::NoJavaVersionFound(
"didn't get the expected java api result".to_string(),
)));
}
let path = file.path();
let content = fs::read_to_string(&path)?;
let parsed = serde_json::from_str::<T>(&content)?;
Ok(response)
}
async fn download(
java_version: &JavaVersion,
os: &str,
archive_type: &str,
archive: &PathBuf,
) -> PolyResult<()> {
let response = get_java_versions(java_version, os, archive_type).await?;
let latest = response.as_array().unwrap().first().unwrap();
let download_url = latest.get("download_url").unwrap().as_str().unwrap();
if let Err(err) = http::download_file(download_url, archive.as_path()).await {
// eprintln!("{}", err);
return Err(PolyError::JavaError(JavaDownloadError::DownloadError(
err.to_string(),
)));
};
Ok(())
}
fn extract(archive: &PathBuf, dest: &PathBuf) -> PolyResult<()> {
if let Err(err) = file::extract_archive(archive.as_path(), dest.as_path()) {
let _ = fs::remove_file(dest.as_path());
return Err(PolyError::JavaError(JavaDownloadError::ExtractError(
err.to_string(),
)));
}
let _ = fs::remove_file(archive.as_path());
Ok(())
result.insert(uuid, parsed);
}
Ok(result)
}

View file

@ -1 +0,0 @@
pub mod vanilla;

View file

@ -1,338 +0,0 @@
use std::collections::HashMap;
use anyhow::anyhow;
use async_trait::async_trait;
use serde_json::Value;
use tauri::{AppHandle, Manager};
use tokio::io::{stdout, AsyncWriteExt};
use crate::{
create_client, create_manifest,
game::client::{GameClient, GameClientDetails, GameClientType},
utils::http,
PolyError, PolyResult,
};
create_manifest!(VanillaManifest {
// minecraft: vanilla_manifest::MinecraftManifest
});
create_client!(VanillaClient {});
#[async_trait]
impl GameClient for VanillaClient {
fn new(handle: AppHandle, details: GameClientDetails) -> Self {
Self {
handle: handle.clone(),
details,
}
}
async fn get_all_version_urls() -> PolyResult<HashMap<String, String>> {
const URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
let request = http::create_client()?
.get(URL)
.send()
.await
.map_err(|err| PolyError::HTTPError(err))?;
let json = request
.json::<Value>()
.await
.map_err(|err| PolyError::HTTPError(err))?;
let versions = json
.get("versions")
.ok_or(PolyError::AnyhowError(anyhow!(
"failed to get versions object"
)))?
.as_array()
.ok_or(PolyError::AnyhowError(anyhow!("invalid versions object")))?;
let mut map = HashMap::new();
for version in versions {
let version = version.as_object().ok_or(anyhow!("Invalid version object"))?;
let id = version
.get("id")
.ok_or(anyhow!("No id object"))?
.as_str()
.ok_or(anyhow!("Invalid id object"))?;
let url = version
.get("url")
.ok_or(anyhow!("No url object"))?
.as_str()
.ok_or(anyhow!("Invalid url object"))?;
map.insert(id.to_string(), url.to_string());
}
Ok(map)
}
async fn get_minecraft_manifest(
version_url: String,
) -> PolyResult<vanilla_manifest::MinecraftManifest> {
let request = http::create_client()?.get(version_url).send().await?;
match request.json::<vanilla_manifest::MinecraftManifest>().await {
Ok(manifest) => Ok(manifest),
Err(e) => Err(e.into()),
}
}
fn get_handle(&self) -> &AppHandle {
&self.handle
}
fn get_details(&self) -> &GameClientDetails {
&self.details
}
fn get_client(&self) -> &GameClientType {
&self.get_details().client_type
}
async fn setup(&self) -> PolyResult<()> {
let libraries_dir = self.get_handle().path().app_config_dir()?.join("libraries");
let versions = Self::get_all_version_urls().await?;
let version = versions
.get(&self.get_details().version)
.ok_or(anyhow!("Version not found"))?
.to_owned();
let manifest = Self::get_minecraft_manifest(version).await?;
let libraries = vanilla_impl::setup_libraries(&manifest.libraries, &libraries_dir).await?;
println!("{:#?}", libraries);
// vanilla_impl::setup_natives().await?;
// vanilla_impl::setup_assets().await?;
Ok(())
}
async fn launch(&self) -> PolyResult<()> {
println!("Launching Vanilla client");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
println!("Vanilla client launched");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
println!("Vanilla client exited");
stdout().flush().await?; // why...
Ok(())
}
}
pub mod vanilla_impl {
use std::{fs, path::PathBuf};
use anyhow::anyhow;
use crate::{utils::http::download_file, PolyResult};
use super::vanilla_manifest::Library;
pub async fn setup_libraries(
libraries: &Vec<Library>,
libraries_folder: &PathBuf,
) -> PolyResult<Vec<String>> {
let mut natives_ret: Vec<&Library> = vec![];
let mut libraries_ret: Vec<String> = vec![];
for library in libraries {
if let Some(_) = library.natives {
natives_ret.push(library);
continue;
}
let artifact = library
.downloads
.artifact
.clone()
.ok_or(anyhow!("No artifact object"))?;
let path = artifact.path;
let url = artifact.url;
// TODO: Add checks for rules + platform
let dest = libraries_folder.join(path);
fs::create_dir_all(dest.parent().ok_or(anyhow!("Couldn't get library parent"))?)?;
if !dest.exists() {
download_file(url.as_str(), &dest).await?;
}
libraries_ret.push(
dest.to_str()
.ok_or(anyhow!("Couldn't get library path"))?
.to_string(),
);
}
Ok(libraries_ret)
}
pub async fn setup_natives() -> PolyResult<()> {
Ok(())
}
pub async fn setup_assets() -> PolyResult<()> {
Ok(())
}
}
// todo move to prisma
pub mod vanilla_manifest {
use serde::Deserialize;
use serde::Serialize;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftManifest {
pub asset_index: AssetIndex,
pub assets: String,
pub compliance_level: i64,
pub downloads: Downloads,
pub id: String,
pub java_version: JavaVersion,
pub libraries: Vec<Library>,
pub logging: Logging,
pub main_class: String,
pub minecraft_arguments: String,
pub minimum_launcher_version: i64,
pub release_time: String,
pub time: String,
#[serde(rename = "type")]
pub type_field: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssetIndex {
pub id: String,
pub sha1: String,
pub size: i64,
pub total_size: i64,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Downloads {
pub client: Client,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Client {
pub sha1: String,
pub size: i64,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JavaVersion {
pub component: String,
pub major_version: i64,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Library {
pub downloads: Downloads2,
pub name: String,
#[serde(default)]
pub rules: Vec<Rule>,
pub extract: Option<Extract>,
pub natives: Option<Natives>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Downloads2 {
pub artifact: Option<Artifact>,
pub classifiers: Option<Classifiers>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Artifact {
pub path: String,
pub sha1: String,
pub size: i64,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Classifiers {
#[serde(rename = "natives-windows-32")]
pub natives_windows_32: Option<NativeLibrary>,
#[serde(rename = "natives-windows-64")]
pub natives_windows_64: Option<NativeLibrary>,
#[serde(rename = "natives-osx")]
pub natives_osx: Option<NativeLibrary>,
#[serde(rename = "natives-linux")]
pub natives_linux: Option<NativeLibrary>,
#[serde(rename = "natives-windows")]
pub natives_windows: Option<NativeLibrary>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeLibrary {
pub path: String,
pub sha1: String,
pub size: i64,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rule {
pub action: String,
pub os: Option<Os>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Os {
pub name: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Extract {
pub exclude: Vec<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Natives {
pub windows: String,
pub linux: Option<String>,
pub osx: Option<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Logging {
pub client: Client2,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Client2 {
pub argument: String,
pub file: File,
#[serde(rename = "type")]
pub type_field: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct File {
pub id: String,
pub sha1: String,
pub size: i64,
pub url: String,
}
}

View file

@ -0,0 +1,157 @@
use std::{fs, path::PathBuf};
use serde_json::Value;
use thiserror::Error;
use crate::{utils::{file, http}, PolyError, PolyResult};
#[derive(Debug, Error)]
pub enum JavaDownloadError {
#[error("failed to find a valid java version: {0}")]
NoJavaVersionFound(String),
#[error("failed creating a java directory (try running at an elevated permission): {0}")]
PermissionDenied(String),
#[error("unable to match your operating system ({0}) with a valid java version")]
UnsupportedOS(String),
#[error("unable to match your architecture ({0}) with a valid java version")]
UnsupportedArch(String),
#[error("failed to extract the java executable: {0}")]
ExtractError(String),
#[error("failed to download the java executable")]
DownloadError(String),
}
pub async fn download_java(dir: &PathBuf, version: u8) -> PolyResult<PathBuf> {
if !dir.exists() {
match fs::create_dir_all(&dir) {
Ok(_) => (),
Err(err) => {
return Err(PolyError::JavaError(JavaDownloadError::PermissionDenied(
err.to_string(),
)))
}
};
}
let os = std::env::consts::OS;
let archive_type = match os {
"windows" => "zip",
"macos" => "tar.gz",
"linux" => "tar.gz",
_ => {
return Err(PolyError::JavaError(JavaDownloadError::UnsupportedOS(
os.to_string(),
)))
}
};
let archive_name = format!("zulu-{}.{}", version.to_string(), archive_type);
let archive = dir.join(&archive_name);
let dest = dir.join(format!("zulu-{}-{}", version.to_string(), get_arch()));
if archive.exists() && dest.exists() {
let _ = fs::remove_file(archive.as_path());
} else if archive.exists() && !dest.exists() {
extract(&archive, &dest)?;
} else if !archive.exists() && !dest.exists() {
download(version, os, archive_type, &archive).await?;
extract(&archive, &dest)?;
}
if let Ok(mut files) = fs::read_dir(&dest) {
let file = match files.nth(0) {
Some(file) => file.unwrap().path(),
None => {
return Err(PolyError::JavaError(JavaDownloadError::NoJavaVersionFound(
"unable to get the java executable file".to_string(),
)))
}
};
return Ok(file.join("bin").join("java"));
}
Err(PolyError::JavaError(JavaDownloadError::NoJavaVersionFound(
"unable to download the java executable file".to_string(),
)))
}
fn get_arch() -> String {
let arch = std::env::consts::ARCH;
match arch {
"x86" => "x86",
"x86_64" => "x64",
"arm" => "aarch32",
"aarch64" => "aarch64",
_ => "unsupported",
}.to_string()
}
async fn download(
java_version: u8,
os: &str,
archive_type: &str,
archive: &PathBuf,
) -> PolyResult<()> {
let response = get_java_versions(java_version, os, archive_type).await?;
let latest = response.as_array().unwrap().first().unwrap();
let download_url = latest.get("download_url").unwrap().as_str().unwrap();
if let Err(err) = http::download_file(download_url, archive.as_path()).await {
return Err(PolyError::JavaError(JavaDownloadError::DownloadError(
err.to_string(),
)));
};
Ok(())
}
fn extract(archive: &PathBuf, dest: &PathBuf) -> PolyResult<()> {
if let Err(err) = file::extract_archive(archive.as_path(), dest.as_path()) {
let _ = fs::remove_file(dest.as_path());
return Err(PolyError::JavaError(JavaDownloadError::ExtractError(
err.to_string(),
)));
}
let _ = fs::remove_file(archive.as_path());
Ok(())
}
async fn get_java_versions(
java_version: u8,
os: &str,
archive_type: &str,
) -> PolyResult<Value> {
let url = format!(
"https://api.azul.com/metadata/v1/zulu/packages/?java_version={}&os={}&arch={}&archive_type={}&java_package_type=jre&javafx_bundled=true&release_status=ga&latest=true",
java_version.to_string(),
os,
get_arch(),
archive_type
);
let response = match http::create_client()?.get(&url).send().await {
Ok(response) => match response.json::<serde_json::Value>().await {
Ok(json) => json,
Err(err) => {
return Err(PolyError::JavaError(JavaDownloadError::NoJavaVersionFound(
err.to_string(),
)))
}
},
Err(err) => {
return Err(PolyError::JavaError(JavaDownloadError::NoJavaVersionFound(
err.to_string(),
)))
}
};
if !response.is_array() || response.as_array().unwrap().is_empty() {
return Err(PolyError::JavaError(JavaDownloadError::NoJavaVersionFound(
"didn't get the expected java api result".to_string(),
)));
}
Ok(response)
}

View file

@ -0,0 +1,156 @@
use serde::{Serialize, Deserialize};
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftManifest {
pub asset_index: AssetIndex,
pub downloads: Downloads,
#[serde(rename = "id")]
pub version: String,
pub java_version: JavaVersion,
pub libraries: Vec<Library>,
pub logging: Logging,
pub main_class: String,
pub minecraft_arguments: String,
pub release_time: String,
#[serde(rename = "type")]
pub release_type: ReleaseType,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReleaseType {
Snapshot,
Release,
OldBeta,
OldAlpha,
}
impl Default for ReleaseType {
fn default() -> Self { ReleaseType::Release }
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssetIndex {
pub id: String,
pub sha1: String,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Downloads {
pub client: Client,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Client {
pub sha1: String,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JavaVersion {
pub major_version: u8,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Library {
pub downloads: Downloads2,
pub name: String,
#[serde(default)]
pub rules: Vec<Rule>,
#[serde(skip_serializing_if = "Option::is_none")]
pub natives: Option<Natives>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Downloads2 {
#[serde(skip_serializing_if = "Option::is_none")]
pub artifact: Option<Artifact>,
#[serde(skip_serializing_if = "Option::is_none")]
pub classifiers: Option<Classifiers>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Artifact {
pub path: String,
pub sha1: String,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Classifiers {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "natives-osx")]
pub natives_osx: Option<Native>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "natives-linux")]
pub natives_linux: Option<Native>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "natives-windows")]
pub natives_windows: Option<Native>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Native {
pub path: String,
pub sha1: String,
pub url: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rule {
pub action: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<Os>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Os {
pub name: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Natives {
pub windows: Option<String>,
pub linux: Option<String>,
pub osx: Option<String>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Logging {
pub client: Client2,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Client2 {
pub argument: String,
pub file: File,
#[serde(rename = "type")]
pub type_field: String,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct File {
pub id: String,
pub sha1: String,
pub url: String,
}

View file

@ -1,83 +1,5 @@
use serde::{Deserialize, Serialize};
use tauri::{plugin::TauriPlugin, Manager};
use tokio::sync::Mutex;
use self::client::get_game_client;
pub mod client;
mod clients;
// todo move to prisma
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum JavaVersion {
V8,
V16,
V17,
}
impl ToString for JavaVersion {
fn to_string(&self) -> String {
match self {
JavaVersion::V8 => "8".to_string(),
JavaVersion::V16 => "16".to_string(),
JavaVersion::V17 => "17".to_string(),
}
}
}
// Tauri plugin for the game module
pub struct GameManagerState {
pub selected_client: Option<Box<dyn client::GameClient>>,
}
unsafe impl Send for GameManagerState {}
unsafe impl Sync for GameManagerState {}
impl Default for GameManagerState {
fn default() -> Self {
GameManagerState {
selected_client: None,
}
}
}
pub fn init() -> TauriPlugin<tauri::Wry> {
tauri::plugin::Builder::new("game")
.setup(|app, _| {
app.manage(Mutex::new(GameManagerState::default()));
Ok(())
})
.invoke_handler(tauri::generate_handler![launch_game, set_selected_client])
.build()
}
#[tauri::command]
async fn launch_game(state: tauri::State<'_, Mutex<GameManagerState>>) -> Result<(), String> {
let state = state.lock().await;
let selected_client = match &state.selected_client {
Some(client) => client,
None => return Err("No client selected".into()),
};
if let Err(err) = selected_client.setup().await {
return Err(format!("An error has occurred during install: {}", err));
}
match selected_client.launch().await {
Ok(_) => Ok(()),
Err(e) => Err(format!("An error has occurred: {}", e)),
}
}
// TODO: Implement setting client by UUID, as currently this is creating a new client and setting that
#[tauri::command]
async fn set_selected_client(
handle: tauri::AppHandle,
state: tauri::State<'_, Mutex<GameManagerState>>,
details: client::GameClientDetails,
) -> Result<(), String> {
let mut state = state.lock().await;
let client = get_game_client(handle, details);
state.selected_client = Some(client);
Ok(())
}
pub mod java;
pub mod vanilla;
pub mod plugin;
pub mod minecraft;

View file

@ -0,0 +1,50 @@
use tauri::{plugin::TauriPlugin, Manager, State};
use tokio::sync::Mutex;
use uuid::Uuid;
use super::client::{ClientManager, ClientType, Instance};
pub struct GameManagerState {
pub client_manager: ClientManager,
}
unsafe impl Send for GameManagerState {}
unsafe impl Sync for GameManagerState {}
pub fn init() -> TauriPlugin<tauri::Wry> {
tauri::plugin::Builder::new("game")
.setup(|app, _| {
app.manage(Mutex::new(GameManagerState {
client_manager: ClientManager::new(app).expect("Failed to initialize client manager"),
}));
Ok(())
})
.invoke_handler(tauri::generate_handler![
create_instance,
get_instances,
])
.build()
}
#[tauri::command]
async fn create_instance(
state: State<'_, Mutex<GameManagerState>>,
name: String,
version: String,
cover: Option<String>,
group: Option<Uuid>,
client: ClientType
) -> Result<Uuid, String> {
let manager = &mut state.lock().await.client_manager;
manager.create_instance(name, version, cover, group, client).await.or_else(|e| {
Err(e.to_string())
})
}
#[tauri::command]
async fn get_instances(state: State<'_, Mutex<GameManagerState>>) -> Result<Vec<Instance>, String> {
let manager = &mut state.lock().await.client_manager;
Ok(manager.get_instances_owned())
}

View file

@ -0,0 +1,16 @@
use std::collections::HashMap;
use crate::{utils::http, PolyResult};
use super::minecraft::MinecraftManifest;
pub async fn retrieve_versions() -> PolyResult<HashMap<String, String>> {
let map = HashMap::new();
Ok(map)
}
pub async fn retrieve_version_manifest(url: String) -> PolyResult<MinecraftManifest> {
let manifest = http::create_client()?.get(url).send().await?.json::<MinecraftManifest>().await?;
Ok(manifest)
}

View file

@ -5,6 +5,10 @@ pub mod constants;
pub mod game;
pub mod utils;
pub struct AppState {
// pub ,
}
/// A standardized [`Error`] type that is used across the launcher
#[derive(Debug, Error)]
pub enum PolyError {
@ -33,24 +37,19 @@ pub enum PolyError {
#[error("failed to manage writing and reading of files: {0}")]
/// Wrapper around [`std::io::Error`] only to be used if a more defined error isn't available (e.g. when using `zip`)
IOError(#[from] std::io::Error),
#[error("failed to query the database: {0}")]
/// Wrapper around [`prisma_client_rust::QueryError`] to handle DB query errors.
DBQueryError(#[from] prisma_client_rust::QueryError),
#[error("failed to establish a connection to the database: {0}")]
/// Wrapper around [`prisma_client_rust::NewClientError`] to handle errors occuring while programmatically connecting to Prisma.
DBConnectionError(#[from] prisma_client_rust::NewClientError),
#[error("failed to establish a HTTP connection: {0}")]
/// Wrapper around [`reqwest::Error`] to handle HTTP errors.
HTTPError(#[from] tauri_plugin_http::reqwest::Error),
#[error("failed to download java: {0}")]
/// Wrapper around [`game::client::JavaDownloadError`] to handle Java executable downloading.
JavaError(#[from] game::client::JavaDownloadError),
JavaError(#[from] game::java::JavaDownloadError),
/// Wrapper around [`zip::result::ZipError`] to handle zip errors.
#[error("failed to manage zip files: {0}")]
ZipError(#[from] zip::result::ZipError),
#[error("failed to authenticate: {0}")]
// Wrapper around [`auth::AuthenticationError`] to handle authentication errors.
/// Wrapper around [`auth::AuthenticationError`] to handle authentication errors.
AuthError(#[from] auth::AuthenticationError),
/// Wrapper around [`anyhow::Error`] to handle generic errors.
#[error(transparent)]
AnyhowError(#[from] anyhow::Error),
}

View file

@ -6,11 +6,12 @@ use std::error::Error;
use polyfrost_launcher::{auth, game};
use tauri::{menu::Menu, App};
fn main() {
#[tokio::main]
async fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(auth::init())
.plugin(game::init())
.plugin(game::plugin::init())
.menu(|handle| Menu::new(handle))
.setup(setup)
.run(tauri::generate_context!())

View file

@ -25,10 +25,10 @@ pub fn extract_archive(archive: &Path, dest: &Path) -> PolyResult<()> {
pub fn extract_zip(archive: &Path, dest: &Path) -> PolyResult<()> {
let file = File::open(archive).map_err(|err| PolyError::IOError(err))?;
let mut archive = zip::ZipArchive::new(file).map_err(|err| PolyError::ZipError(err));
let archive = zip::ZipArchive::new(file).map_err(|err| PolyError::ZipError(err));
archive?
.extract(dest)
.map_err(|err| PolyError::ZipError(err));
.map_err(|err| PolyError::ZipError(err))?;
Ok(())
}
@ -36,6 +36,6 @@ pub fn extract_tar_gz(archive: &Path, dest: &Path) -> PolyResult<()> {
let file = File::open(archive).map_err(|err| PolyError::IOError(err))?;
let tar_gz = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(tar_gz);
archive.unpack(dest).map_err(|err| PolyError::IOError(err));
archive.unpack(dest).map_err(|err| PolyError::IOError(err))?;
Ok(())
}

View file

@ -29,6 +29,6 @@ pub async fn download_file(url: &str, path: &Path) -> PolyResult<()> {
.await
.map_err(|err| PolyError::HTTPError(err))?,
);
io::copy(&mut content, &mut file).map_err(|err| PolyError::IOError(err));
io::copy(&mut content, &mut file).map_err(|err| PolyError::IOError(err))?;
Ok(())
}

View file

@ -18,24 +18,28 @@ function BrowserPage() {
</Button>
<Button
onClick={() => {
invoke('plugin:game|set_selected_client', {
details: {
uuid: uuid.v4(),
name: 'my vanilla instance',
version: '1.8.9',
main_class: 'net.minecraft.client.main.Main',
java_version: 'v8',
startup_args: ['--username', 'player', '--password', 'password'],
client_type: {
type: 'Vanilla',
manifest: {},
},
} as game.GameClientDetails,
});
onClick={async () => {
const result = invoke('plugin:game|get_instances');
console.log(result);
}}
>
Test Button2
Test Button 2
</Button>
<Button
onClick={async () => {
const result = await invoke('plugin:game|create_instance', {
name: 'My epic instance name',
version: '1.8.9',
client: {
type: 'Vanilla',
},
});
console.log(result);
}}
>
Test Button 3
</Button>
</div>
);