This commit is contained in:
Pauline 2024-03-27 05:38:58 +01:00
parent 2af86b5fda
commit 022936d35c
No known key found for this signature in database
GPG key ID: 722BB181973296AE
44 changed files with 1412 additions and 1149 deletions

View file

@ -2,7 +2,7 @@
resolver = "2"
members = [
"src-tauri",
"core"
"onelauncher"
]
[workspace.package]
@ -18,14 +18,26 @@ authors = [ "Polyfrost" ]
tauri = { version = "2.0.0-beta", features = [] }
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" }
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tracing = { version = "0.1" }
tracing-subscriber = { version = "0.3", features = [
"chrono",
"env-filter"
] }
tracing-error = { version = "0.2" }
tracing-appender = { version = "0.2" }
uuid = { version = "1.8", features = [ "serde" ] }
tokio = { version = "1", features = [ "full" ] }
serde = { version = "1", features = [ "derive" ] }
chrono = { version = "0.4", features = [ "serde" ] }
reqwest = { version = "0.12", features = [ "json" ] }
reqwest = { version = "0.12", features = [
"json",
"stream",
] }
serde_json = { version = "1" }
flate2 = { version = "1" }
tar = { version = "0.4" }
@ -35,6 +47,18 @@ thiserror = { version = "1" }
anyhow = { version = "1" }
dirs = { version = "5" }
open = { version = "5" }
discord-rich-presence = { version = "0.2" }
winreg = { version = "0.52" }
sha1 = { version = "0.10.6" }
bytes = { version = "1" }
lazy_static = { version = "1" }
once_cell = { version = "1" }
indicatif = { version = "0.17" }
futures = { version = "0.3" }
tokio-stream = { version = "0.1", features = [ "fs" ] }
async-recursion = { version = "1.1" }
notify = { version = "6.1", default-features = false, features = [ "serde" ] }
notify-debouncer-mini = { version = "0.4", default-features = false }
[profile.dev]
# Make compilation faster on macOS

View file

@ -1 +1 @@
# Polyfrost Launcher
# OneLauncher

View file

@ -1,260 +0,0 @@
use std::{collections::HashMap, fs, path::PathBuf};
use anyhow::anyhow;
use async_trait::async_trait;
use chrono::{DateTime, Local};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
use crate::{utils::dirs, PolyResult};
use super::{minecraft::{MinecraftManifest, ReleaseType}, vanilla::{self, VanillaClientProps}};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Version {
id: String,
url: String,
#[serde(default)]
release_type: ReleaseType,
#[serde(default)]
release_time: chrono::DateTime<chrono::Utc>
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub id: Uuid,
pub manifest: MinecraftManifest
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
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)]
#[serde(tag = "type")]
pub enum ClientType {
Vanilla(VanillaClientProps)
}
impl ClientType {
pub async fn get_versions(&self, file: Option<&PathBuf>) -> PolyResult<Vec<Version>> {
match self {
ClientType::Vanilla(_) => vanilla::get_versions(file).await
}
}
}
pub fn get_impl<'a>(client: &'a ClientType, instance: &'a Instance, manifest: &'a Manifest) -> Box<dyn ClientTrait<'a> + 'a> {
Box::new(match client {
ClientType::Vanilla(_) => vanilla::VanillaClient::new(instance, manifest)
})
}
#[async_trait]
pub trait ClientTrait<'a>: Send + Sync {
fn new(instance: &'a Instance, manifest: &'a Manifest) -> Self where Self: Sized;
async fn launch(&self) -> PolyResult<()>;
async fn setup(&self) -> PolyResult<()>;
async fn install_game(&self) -> PolyResult<PathBuf>;
async fn install_libraries(&self) -> PolyResult<String>;
async fn install_natives(&self) -> PolyResult<()>;
async fn install_assets(&self) -> PolyResult<()>;
fn get_instance(&self) -> &'a Instance where Self: Sized;
fn get_manifest(&self) -> &'a Manifest where Self: Sized;
}
#[macro_export]
macro_rules! create_game_client {
($props:ident { $($props_name:ident: $props_type:ty),* } $client:ident { $($name:ident: $type:ty),* }) => {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct $props {
$(pub $props_name: $props_type),*
}
#[derive(Debug, Clone)]
pub struct $client<'a> {
pub instance: &'a crate::game::client::Instance,
pub manifest: &'a crate::game::client::Manifest,
$(pub $name: $type),*
}
};
}
#[macro_export]
macro_rules! impl_game_client {
() => {
fn get_instance(&self) -> &'a crate::game::client::Instance where Self: Sized {
self.instance
}
fn get_manifest(&self) -> &'a crate::game::client::Manifest where Self: Sized {
self.manifest
}
};
}
#[derive(Debug, Error)]
pub enum ClientManagerError {
#[error("Instance not found")]
InstanceNotFound,
#[error("Manifest not found")]
ManifestNotFound,
}
pub struct ClientManager {
instances: HashMap<Uuid, Instance>,
manifests: HashMap<Uuid, Manifest>,
}
impl ClientManager {
pub fn new() -> PolyResult<Self> {
let instances = load_and_serialize::<Instance>(
&dirs::instances_dir()?
)?;
let manifests = load_and_serialize::<Manifest>(
&dirs::manifests_dir()?
)?;
Ok(Self {
instances,
manifests,
})
}
pub fn get_impl_uuid<'a>(&'a self, uuid: Uuid) -> PolyResult<Box<dyn ClientTrait<'a> + 'a>> {
let instance = self.get_instance(uuid)?;
let manifest = self.get_manifest(uuid)?;
Ok(get_impl(&instance.client, &instance, &manifest))
}
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) -> PolyResult<&Instance> {
self.instances.get(&uuid).ok_or(ClientManagerError::InstanceNotFound.into())
}
pub fn get_instance_mut(&mut self, uuid: Uuid) -> PolyResult<&mut Instance> {
self.instances.get_mut(&uuid).ok_or(ClientManagerError::InstanceNotFound.into())
}
pub fn get_manifest(&self, uuid: Uuid) -> PolyResult<&Manifest> {
self.manifests.get(&uuid).ok_or(ClientManagerError::ManifestNotFound.into())
}
pub fn get_manifest_mut(&mut self, uuid: Uuid) -> PolyResult<&mut Manifest> {
self.manifests.get_mut(&uuid).ok_or(ClientManagerError::ManifestNotFound.into())
}
pub async fn create_instance(&mut self,
name: String,
version: String,
cover: Option<String>,
group: Option<Uuid>,
client: ClientType
) -> PolyResult<Uuid> {
let version_cache = dirs::app_config_dir()?.join("versions_cache.json");
let versions = client.get_versions(Some(&version_cache)).await?;
let url = &versions.iter().find(|v| v.id == version).ok_or(anyhow!("Version not found"))?.url;
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(&dirs::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(&dirs::instances_dir()?.join(format!("{}.json", uuid)), &instance)?;
self.instances.insert(uuid, instance);
Ok(uuid)
}
}
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(())
}
fn load_and_serialize<T>(dir: &PathBuf) -> PolyResult<HashMap<Uuid, T>> where T: DeserializeOwned {
let mut result = HashMap::<Uuid, T>::new();
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;
}
};
let path = file.path();
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(_) => {
eprintln!("Couldn't read file: '{:?}'", path);
continue;
}
};
let parsed = match serde_json::from_str::<T>(&content) {
Ok(parsed) => parsed,
Err(_) => {
eprintln!("Couldn't parse file content as JSON: '{:?}'", content);
continue;
}
};
result.insert(uuid, parsed);
}
Ok(result)
}

View file

@ -1,157 +0,0 @@
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

@ -1,224 +0,0 @@
use core::fmt;
use std::{collections::HashMap, marker::PhantomData};
use serde::{de::{self, SeqAccess, Visitor}, Deserialize, Deserializer, Serialize};
use serde_json::Value;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftManifest {
pub asset_index: AssetIndex,
pub downloads: Downloads,
pub java_version: JavaVersion,
pub libraries: Vec<Library>,
pub logging: Logging,
pub main_class: String,
pub release_time: String,
#[serde(rename = "id")]
pub version: String,
#[serde(rename = "type")]
pub release_type: ReleaseType,
#[serde(skip_serializing_if = "Option::is_none")]
pub minecraft_arguments: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<ModernArguments>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModernArguments {
game: Vec<ModernArgumentsItem>,
jvm: Vec<ModernArgumentsItem>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(untagged)]
pub enum ModernArgumentsItem {
Simple(String),
Rule(ArgumentRule),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArgumentRule {
#[serde(skip_serializing_if = "Vec::is_empty")]
rules: Vec<Rule>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(deserialize_with = "string_or_seq")]
value: Vec<String>,
}
#[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: LibraryDownload,
pub name: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub rules: Vec<Rule>,
#[serde(skip_serializing_if = "Option::is_none")]
pub natives: Option<HashMap<String, String>>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LibraryDownload {
#[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<Artifact>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "natives-linux")]
pub natives_linux: Option<Artifact>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "natives-windows")]
pub natives_windows: Option<Artifact>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rule {
pub action: RuleAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<Os>,
#[serde(skip_serializing_if = "Option::is_none")]
pub features: Option<HashMap<String, bool>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RuleAction {
#[serde(rename = "allow")]
Allow,
#[serde(rename = "disallow")]
Disallow,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Os {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: 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,
}
fn string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
struct StringOrVec(PhantomData<Vec<String>>);
impl<'de> Visitor<'de> for StringOrVec {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("string or list of strings")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(vec![value.into()])
}
fn visit_seq<S>(self, seq: S) -> Result<Self::Value, S::Error>
where
S: SeqAccess<'de>,
{
Deserialize::deserialize(de::value::SeqAccessDeserializer::new(seq))
}
}
deserializer.deserialize_any(StringOrVec(PhantomData))
}

View file

@ -1,117 +0,0 @@
use std::{fs, path::PathBuf};
use anyhow::anyhow;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::{constants::MINECRAFT_VERSIONS_MANIFEST, create_game_client, impl_game_client, utils::{dirs, file, http}, PolyResult};
use super::{client::{ClientTrait, Instance, Manifest, Version}, minecraft::MinecraftManifest};
create_game_client! {
VanillaClientProps {}
VanillaClient {}
}
#[async_trait]
impl<'a> ClientTrait<'a> for VanillaClient<'a> {
impl_game_client!();
fn new(instance: &'a Instance, manifest: &'a Manifest) -> Self {
VanillaClient {
instance,
manifest
}
}
async fn launch(&self) -> PolyResult<()> {
Ok(())
}
async fn setup(&self) -> PolyResult<()> {
Ok(())
}
async fn install_game(&self) -> PolyResult<PathBuf> {
let manifest = &self.manifest.manifest;
let file = dirs::clients_dir()?.join(format!("{}.jar", manifest.version));
if !file.exists() {
fs::create_dir_all(&file)?;
let artifact = &manifest.downloads.client;
http::download_file(&artifact.url, &file).await?;
let file_hash = file::file_sha1(&file)?;
println!("Downloaded: '{}' | '{}'", file_hash, artifact.sha1);
if file_hash != artifact.sha1 {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "Hashes do not match").into());
}
}
Ok(file)
}
async fn install_libraries(&self) -> PolyResult<String> {
Ok("".to_string())
}
async fn install_natives(&self) -> PolyResult<()> {
Ok(())
}
async fn install_assets(&self) -> PolyResult<()> {
Ok(())
}
}
#[derive(Clone, Serialize, Deserialize)]
struct VersionList {
versions: Vec<Version>
}
#[derive(Serialize, Deserialize)]
struct CachedVersions {
last_updated: i64,
versions: Vec<Version>,
}
pub async fn get_versions(file: Option<&PathBuf>) -> PolyResult<Vec<Version>> {
if let Some(file) = file {
if file.exists() && file.is_file() {
if let Some(versions) = get_cached_versions(file).await? {
return Ok(versions);
}
}
}
let response = http::create_client()?.get(MINECRAFT_VERSIONS_MANIFEST).send().await?;
let response = response.json::<VersionList>().await?;
if let Some(file) = file {
fs::write(file, serde_json::to_string(&CachedVersions {
last_updated: chrono::Utc::now().timestamp(),
versions: response.versions.clone()
})?)?;
}
Ok(response.versions)
}
async fn get_cached_versions(file: &PathBuf) -> PolyResult<Option<Vec<Version>>> {
let cached = serde_json::from_str::<CachedVersions>(&fs::read_to_string(file)?)?;
let head_request = http::create_client()?.head(MINECRAFT_VERSIONS_MANIFEST).send().await?;
let last_updated = head_request.headers().get("Last-Modified").ok_or(anyhow!("Last-Modified header not found"))?;
let last_updated = last_updated.to_str().map_err(|_| anyhow!("Failed to convert Last-Modified header to string"))?;
let last_updated = chrono::DateTime::parse_from_rfc2822(last_updated)?;
if cached.last_updated < last_updated.timestamp() {
Ok(None)
} else {
Ok(Some(cached.versions))
}
}
pub async fn retrieve_version_manifest(url: &str) -> PolyResult<MinecraftManifest> {
let manifest = http::create_client()?.get(url).send().await?.json::<MinecraftManifest>().await?;
Ok(manifest)
}

View file

@ -1,75 +0,0 @@
use thiserror::Error;
pub mod constants;
pub mod utils;
pub mod game;
pub mod auth;
/// A standardized [`Error`] type that is used across the launcher
#[derive(Debug, Error)]
pub enum PolyError {
/// Wrapper around [`tokio::sync::AcquireError`] to handle sempahore errors.
#[error("failed to manage a tokio semaphore: {0}")]
TokioError(#[from] tokio::sync::AcquireError),
/// Wrapper around [`flate2::CompressError`] to handle flate compression errors.
#[error("failed to compress a file with flate: {0}")]
FlateError(#[from] flate2::CompressError),
/// Wrapper around [`flate2::DecompressError`] to handle flate decompression errors.
#[error("failed to decompress a file with flate: {0}")]
DeflateError(#[from] flate2::DecompressError),
/// Wrapper around [`uuid::Error`] to handle UUID parsing errors.
#[error("failed to parse uuids: {0}")]
UUIDError(#[from] uuid::Error),
/// Wrapper around [`serde_json::Error`] to handle Serde JSON parsing errors.
#[error("failed to manage serialization of json: {0}")]
SerdeError(#[from] serde_json::Error),
/// Wrapper around [`std::io::Error`] only to be used if a more defined error isn't available (e.g. when using `zip`)
#[error("failed to manage writing and reading of files: {0}")]
IOError(#[from] std::io::Error),
/// Wrapper around [`reqwest::Error`] to handle HTTP errors.
#[error("failed to establish a HTTP connection: {0}")]
HTTPError(#[from] reqwest::Error),
/// Wrapper around [`zip::result::ZipError`] to handle zip errors.
#[error("failed to manage zip files: {0}")]
ZipError(#[from] zip::result::ZipError),
/// Wrapper around [`anyhow::Error`] to handle generic errors.
#[error(transparent)]
AnyhowError(#[from] anyhow::Error),
/// Wrapper around [`chrono::ParseError`] to handle date parsing errors.
#[error("failed to parse a date: {0}")]
ChronoError(#[from] chrono::ParseError),
/// Wrapper around [`game::java::JavaDownloadError`] to handle Java executable downloading.
#[error("failed to download java: {0}")]
JavaError(#[from] game::java::JavaDownloadError),
/// Wrapper around [`auth::AuthenticationError`] to handle authentication errors.
#[error("failed to authenticate: {0}")]
AuthError(#[from] auth::AuthenticationError),
/// Wrapper around [`utils::dirs::DirectoryError`] to handle directory errors.
#[error("failed to get directory: {0}")]
DirectoryError(#[from] utils::dirs::DirectoryError),
/// Wrapper around [`game::client::ClientManagerError`] to handle directory errors.
#[error("failed to manage clients: {0}")]
ClientManagerError(#[from] game::client::ClientManagerError),
}
impl From<PolyError> for String {
fn from(value: PolyError) -> Self {
value.to_string()
}
}
/// Alias for a [`Result`] with the error type [`PolyError`].
pub type PolyResult<T> = anyhow::Result<T, PolyError>;

View file

@ -1,37 +0,0 @@
use std::path::PathBuf;
use thiserror::Error;
use crate::{constants, PolyResult};
#[derive(Debug, Error)]
pub enum DirectoryError {
#[error("failed to get the user's config directory")]
ConfigDir,
}
pub fn app_config_dir() -> PolyResult<PathBuf> {
dirs::config_dir()
.and_then(|dir| Some(dir.join(constants::APP_CONFIG_DIR)))
.ok_or(DirectoryError::ConfigDir.into())
}
pub fn libraries_dir() -> PolyResult<PathBuf> {
Ok(app_config_dir()?.join("libraries"))
}
pub fn java_dir() -> PolyResult<PathBuf> {
Ok(app_config_dir()?.join("java"))
}
pub fn instances_dir() -> PolyResult<PathBuf> {
Ok(app_config_dir()?.join("instances"))
}
pub fn manifests_dir() -> PolyResult<PathBuf> {
Ok(app_config_dir()?.join("manifests"))
}
pub fn clients_dir() -> PolyResult<PathBuf> {
Ok(app_config_dir()?.join("clients"))
}

View file

@ -1,53 +0,0 @@
use anyhow::anyhow;
use sha1::{Digest, Sha1};
use std::{fs::File, io::BufReader, path::{Path, PathBuf}};
use crate::{PolyError, PolyResult};
pub fn file_sha1(file: &PathBuf) -> PolyResult<String> {
let file = File::open(file)?;
let reader = BufReader::new(file);
let mut hasher = Sha1::new();
hasher.update(&reader.buffer());
let hash = hasher.finalize();
Ok(format!("{:x}", hash))
}
pub fn extract_archive(archive: &Path, dest: &Path) -> PolyResult<()> {
let ext = match archive.extension() {
Some(ext) => ext.to_str().unwrap(),
None => {
return Err(PolyError::AnyhowError(anyhow!(
"unsupported operating system"
)))
}
};
match ext {
"zip" => extract_zip(archive, dest),
"gz" => extract_tar_gz(archive, dest),
_ => Err(PolyError::AnyhowError(anyhow!(
"unsupported file extension {:?}",
ext
))),
}
}
pub fn extract_zip(archive: &Path, dest: &Path) -> PolyResult<()> {
let file = File::open(archive).map_err(|err| PolyError::IOError(err))?;
let archive = zip::ZipArchive::new(file).map_err(|err| PolyError::ZipError(err));
archive?
.extract(dest)
.map_err(|err| PolyError::ZipError(err))?;
Ok(())
}
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))?;
Ok(())
}

View file

@ -1,34 +0,0 @@
use std::{
fs::File,
io::{self, Cursor},
path::Path,
};
use reqwest::{Client, ClientBuilder};
use crate::{constants::USER_AGENT, PolyError, PolyResult};
pub fn create_client() -> PolyResult<Client> {
ClientBuilder::new()
.user_agent(USER_AGENT)
.build()
.map_err(|err| PolyError::HTTPError(err))
}
// can we not make mapping the errors automatic, isnt that the whole point
pub async fn download_file(url: &str, path: &Path) -> PolyResult<()> {
let response = create_client()?
.get(url)
.send()
.await
.map_err(|err| PolyError::HTTPError(err))?;
let mut file = File::create(path).map_err(|err| PolyError::IOError(err))?;
let mut content = Cursor::new(
response
.bytes()
.await
.map_err(|err| PolyError::HTTPError(err))?,
);
io::copy(&mut content, &mut file).map_err(|err| PolyError::IOError(err))?;
Ok(())
}

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
<title>Polyfrost Launcher</title>
<title>OneLauncher</title>
</head>
<body>

View file

@ -1,7 +1,7 @@
[package]
name = "launcher-core"
name = "onelauncher"
version = "0.0.0"
description = "The core for our launcher"
description = "The core of OneLauncher"
license = { workspace = true }
edition = { workspace = true }
repository = { workspace = true }
@ -10,7 +10,19 @@ readme = { workspace = true }
homepage = { workspace = true }
authors = { workspace = true }
[features]
tauri = [ "dep:tauri" ]
cli = [ "dep:indicatif" ]
[dependencies]
tauri = { workspace = true, optional = true }
indicatif = { workspace = true, optional = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-error = { workspace = true }
tracing-appender = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@ -25,5 +37,10 @@ chrono = { workspace = true }
reqwest = { workspace = true }
dirs = { workspace = true }
open = { workspace = true }
sha1 = { workspace = true }
discord-rich-presence = { workspace = true }
notify = { workspace = true }
notify-debouncer-mini = { workspace = true }
sha1 = "0.10.6"
[target.'cfg(windows)'.dependencies]
winreg = { workspace = true }

View file

@ -4,16 +4,19 @@ use std::{
};
use serde_json::json;
use thiserror::Error;
use crate::{constants::{self, CLIENT_ID}, utils::http::create_client, PolyError, PolyResult};
use crate::{
constants::{self, CLIENT_ID},
utils::http::create_client,
ErrorKind,
};
use super::{Account, AuthenticationError, AuthenticationMethod};
// TODO: store auth keys with https://beta.tauri.app/features/stronghold/
// TODO: store auth key public in prisma for easy access
#[derive(Debug, Error)]
#[derive(Debug, thiserror::Error)]
pub enum MicrosoftAuthError {
#[error("failed to get Minecraft access token")]
MSAccessToken,
@ -23,7 +26,9 @@ pub enum MicrosoftAuthError {
XBLToken,
#[error("failed to get XBL user hash")]
XBLUserHash,
#[error("this account doesn't have an xbox account, sign in through minecraft.net and try again")]
#[error(
"this account doesn't have an xbox account, sign in through minecraft.net and try again"
)]
NoXBoxAccount,
#[error("this account is from a country where xbox live is not available")]
XBoxLiveBlocked,
@ -37,7 +42,7 @@ pub enum MicrosoftAuthError {
pub struct MicrosoftAuthenticationMethod;
impl AuthenticationMethod for MicrosoftAuthenticationMethod {
async fn auth<F>(stage: F) -> PolyResult<Account>
async fn auth<F>(stage: F) -> crate::Result<Account>
where
F: Fn(String, u8, bool) -> (),
{
@ -63,7 +68,7 @@ impl AuthenticationMethod for MicrosoftAuthenticationMethod {
}
}
async fn auth_minecraft(xsts_token: String, user_hash: String) -> PolyResult<String> {
async fn auth_minecraft(xsts_token: String, user_hash: String) -> crate::Result<String> {
let response = create_client()?
.post("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(&json!({
@ -76,16 +81,17 @@ async fn auth_minecraft(xsts_token: String, user_hash: String) -> PolyResult<Str
let access_token = match response.get("access_token") {
Some(token) => token.as_str().unwrap(),
None => {
return Err(PolyError::AuthError(AuthenticationError::MicrosoftError(
return Err(ErrorKind::AuthError(AuthenticationError::MicrosoftError(
MicrosoftAuthError::MSAccessToken,
)))
))
.into())
}
};
Ok(access_token.to_string())
}
async fn auth_xsts(token: String) -> PolyResult<String> {
async fn auth_xsts(token: String) -> crate::Result<String> {
let response = create_client()?
.post("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(&json!({
@ -108,29 +114,34 @@ async fn auth_xsts(token: String) -> PolyResult<String> {
match error_code {
2148916233 => {
return Err(PolyError::AuthError(AuthenticationError::MicrosoftError(
return Err(ErrorKind::AuthError(AuthenticationError::MicrosoftError(
MicrosoftAuthError::NoXBoxAccount,
)))
))
.into())
}
2148916235 => {
return Err(PolyError::AuthError(AuthenticationError::MicrosoftError(
return Err(ErrorKind::AuthError(AuthenticationError::MicrosoftError(
MicrosoftAuthError::XBoxLiveBlocked,
)))
))
.into())
}
2148916236 | 2148916237 => {
return Err(PolyError::AuthError(AuthenticationError::MicrosoftError(
return Err(ErrorKind::AuthError(AuthenticationError::MicrosoftError(
MicrosoftAuthError::AdultVerification,
)))
))
.into())
}
2148916238 => {
return Err(PolyError::AuthError(AuthenticationError::MicrosoftError(
return Err(ErrorKind::AuthError(AuthenticationError::MicrosoftError(
MicrosoftAuthError::ChildAccount,
)))
))
.into())
}
_ => {
return Err(PolyError::AuthError(AuthenticationError::MicrosoftError(
return Err(ErrorKind::AuthError(AuthenticationError::MicrosoftError(
MicrosoftAuthError::UnknownError(error_code),
)))
))
.into())
}
}
}
@ -139,16 +150,17 @@ async fn auth_xsts(token: String) -> PolyResult<String> {
let xsts_token = match response.get("Token") {
Some(token) => token.as_str().unwrap(),
None => {
return Err(PolyError::AuthError(AuthenticationError::MicrosoftError(
return Err(ErrorKind::AuthError(AuthenticationError::MicrosoftError(
MicrosoftAuthError::XSTSToken,
)))
))
.into())
}
};
Ok(xsts_token.to_string())
}
async fn auth_xbl(code: String) -> PolyResult<(String, String)> {
async fn auth_xbl(code: String) -> crate::Result<(String, String)> {
let response = create_client()?
.post("https://user.auth.xboxlive.com/user/authenticate")
.json(&json!({
@ -167,9 +179,10 @@ async fn auth_xbl(code: String) -> PolyResult<(String, String)> {
let xbl_token = match response.get("Token") {
Some(token) => token.as_str().unwrap(),
None => {
return Err(PolyError::AuthError(AuthenticationError::MicrosoftError(
return Err(ErrorKind::AuthError(AuthenticationError::MicrosoftError(
MicrosoftAuthError::XBLToken,
)))
))
.into())
}
};
@ -180,21 +193,25 @@ async fn auth_xbl(code: String) -> PolyResult<(String, String)> {
}) {
Some(hash) => hash.as_str().unwrap(),
None => {
return Err(PolyError::AuthError(AuthenticationError::MicrosoftError(
return Err(ErrorKind::AuthError(AuthenticationError::MicrosoftError(
MicrosoftAuthError::XBLUserHash,
)))
))
.into())
}
};
Ok((xbl_token.to_string(), user_hash.to_string()))
}
async fn msa_code_to_token(code: String) -> PolyResult<String> {
async fn msa_code_to_token(code: String) -> crate::Result<String> {
let token = create_client()?
.post("https://login.live.com/oauth20_token.srf")
.form(&[
("client_id", CLIENT_ID),
("redirect_uri", &format!("http://localhost:{}/", constants::MSA_PORT)),
(
"redirect_uri",
&format!("http://localhost:{}/", constants::MSA_PORT),
),
("code", &code),
("grant_type", "authorization_code"),
])
@ -206,19 +223,22 @@ async fn msa_code_to_token(code: String) -> PolyResult<String> {
Ok(token["access_token"].as_str().unwrap().to_string())
}
async fn msa_code() -> PolyResult<String> {
async fn msa_code() -> crate::Result<String> {
let url = format!(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id={}&response_type=code&redirect_uri=http://localhost:{}/&response_mode=query&scope=XboxLive.signin%20offline_access&prompt=consent",
CLIENT_ID,
constants::MSA_PORT
);
open::that(url)?;
open::that(url)?;
let mut token: String = String::new();
let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], constants::MSA_PORT)))?;
println!("Started local server for MSA on port {}", constants::MSA_PORT);
println!(
"Started local server for MSA on port {}",
constants::MSA_PORT
);
for conn in listener.incoming() {
match conn {

View file

@ -1,12 +1,11 @@
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{utils::http, PolyError, PolyResult};
use crate::{utils::http, ErrorKind};
pub mod microsoft_auth;
#[derive(Error, Debug)]
#[derive(thiserror::Error, Debug)]
pub enum AuthenticationError {
#[error(transparent)]
MicrosoftError(#[from] microsoft_auth::MicrosoftAuthError),
@ -31,11 +30,11 @@ pub struct AccountSkin {
pub trait AuthenticationMethod {
/// Authenticate with a given method. Stage is a function that takes a string and a u8.
/// The string is the message to display to the user, and the u8 is the progress of the authentication.
async fn auth<F>(stage: F) -> PolyResult<Account>
async fn auth<F>(stage: F) -> crate::Result<Account>
where
F: Fn(String, u8, bool) -> ();
async fn get_profile(access_token: String) -> PolyResult<Account> {
async fn get_profile(access_token: String) -> crate::Result<Account> {
let response = http::create_client()?
.get("https://api.minecraftservices.com/minecraft/profile")
.header("Authorization", format!("Bearer {}", access_token))
@ -44,7 +43,7 @@ pub trait AuthenticationMethod {
let response = response.json::<serde_json::Value>().await?;
if let Some(error) = response.get("error") {
return Err(PolyError::AnyhowError(anyhow!(error.to_string())));
return Err(ErrorKind::AnyhowError(anyhow!(error.to_string())).into());
}
let account = serde_json::from_value::<Account>(response)?;

View file

@ -1,3 +1,7 @@
// Basic Metadata
pub const NAME: &str = "OneLauncher";
pub const VERSION: &str = "0.1.0";
// Authentication and HTTP (TODO: env?)
pub const APP_CONFIG_DIR: &str = "org.polyfrost.launcher";
pub const USER_AGENT: &str = "OneLauncher/1.0.0 (polyfrost.org)";
@ -7,6 +11,7 @@ pub const CLIENT_ID: &str = "9419b7ee-1448-4d1b-b52a-550d8f36ab56";
pub const MSA_PORT: u16 = 13523;
// Minecraft
pub const MINECRAFT_VERSIONS_MANIFEST: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
pub const MINECRAFT_VERSIONS_MANIFEST: &str =
"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
// Java Downloading
// Java Downloading

117
onelauncher/src/error.rs Normal file
View file

@ -0,0 +1,117 @@
use tracing_error::InstrumentError;
/// A standardized [`thiserror::Error`] type that is used across the launcher
#[derive(thiserror::Error, Debug)]
pub enum ErrorKind {
/// Wrapper around [`tokio::sync::AcquireError`] to handle sempahore errors.
#[error("failed to manage a tokio semaphore: {0}")]
AcquireError(#[from] tokio::sync::AcquireError),
/// Wrapper around [`tokio::sync::oneshot::error::RecvError`] to handle recieve errors.
#[error("tokio recieve error: {0}")]
RecvError(#[from] tokio::sync::oneshot::error::RecvError),
/// Wrapper around [`tokio::task::JoinError`] to handle tokio join handle errors.
#[error("tokio join handle error: {0}")]
JoinError(#[from] tokio::task::JoinError),
/// Wrapper around [`notify::Error`] to handle file watching errors.
#[error("failed to watch file: {0}")]
NotifyError(#[from] notify::Error),
/// Wrapper around [`flate2::CompressError`] to handle flate compression errors.
#[error("failed to compress a file with flate: {0}")]
FlateError(#[from] flate2::CompressError),
/// Wrapper around [`flate2::DecompressError`] to handle flate decompression errors.
#[error("failed to decompress a file with flate: {0}")]
DeflateError(#[from] flate2::DecompressError),
/// Wrapper around [`uuid::Error`] to handle UUID parsing errors.
#[error("failed to parse uuids: {0}")]
UUIDError(#[from] uuid::Error),
/// Wrapper around [`serde_json::Error`] to handle Serde JSON parsing errors.
#[error("failed to manage serialization of json: {0}")]
SerdeError(#[from] serde_json::Error),
/// Wrapper around [`std::io::Error`] only to be used if a more defined error isn't available (e.g. when using `zip`)
#[error("failed to manage writing and reading of files: {0}")]
IOError(#[from] std::io::Error),
/// Wrapper around [`reqwest::Error`] to handle HTTP errors.
#[error("failed to establish a HTTP connection: {0}")]
HTTPError(#[from] reqwest::Error),
/// Wrapper around [`zip::result::ZipError`] to handle zip errors.
#[error("failed to manage zip files: {0}")]
ZipError(#[from] zip::result::ZipError),
/// Wrapper around [`anyhow::Error`] to handle generic errors.
#[error(transparent)]
AnyhowError(#[from] anyhow::Error),
/// Wrapper around [`chrono::ParseError`] to handle date parsing errors.
#[error("failed to parse a date: {0}")]
ChronoError(#[from] chrono::ParseError),
/// Wrapper around [`crate::game::java::JavaDownloadError`] to handle Java executable downloading.
#[error("failed to download java: {0}")]
JavaError(#[from] crate::game::java::JavaDownloadError),
/// Wrapper around [`crate::auth::AuthenticationError`] to handle authentication errors.
#[error("failed to authenticate: {0}")]
AuthError(#[from] crate::auth::AuthenticationError),
/// Wrapper around [`crate::utils::dirs::DirectoryError`] to handle directory errors.
#[error("failed to get directory: {0}")]
DirectoryError(#[from] crate::utils::dirs::DirectoryError),
/// Wrapper around [`crate::game::client::ClientManagerError`] to handle directory errors.
#[error("failed to manage clients: {0}")]
ClientManagerError(#[from] crate::game::client::ClientManagerError),
#[cfg(feature = "tauri")]
#[error("failed to launch tauri: {0}")]
TauriError(#[from] tauri::Error),
}
#[derive(Debug)]
pub struct Error {
source: tracing_error::TracedError<ErrorKind>,
}
impl From<Error> for String {
fn from(value: Error) -> Self {
value.to_string()
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source.source()
}
}
impl std::fmt::Display for Error {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(fmt, "{}", self.source)
}
}
impl<E: Into<ErrorKind>> From<E> for Error {
fn from(source: E) -> Self {
Self {
source: Into::<ErrorKind>::into(source).in_current_span(),
}
}
}
impl ErrorKind {
pub fn as_error(self) -> Error {
self.into()
}
}
/// Alias for a [`core::result::Result`] with the error type [`Error`].
pub type Result<T> = anyhow::Result<T, Error>;

View file

@ -0,0 +1,304 @@
use std::{collections::HashMap, fs, path::PathBuf};
use anyhow::anyhow;
use async_trait::async_trait;
use chrono::{DateTime, Local};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use uuid::Uuid;
use crate::utils::dirs;
use super::{
minecraft::{MinecraftManifest, ReleaseType},
vanilla::{self, VanillaClientProps},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Version {
id: String,
url: String,
#[serde(default)]
release_type: ReleaseType,
#[serde(default)]
release_time: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub id: Uuid,
pub manifest: MinecraftManifest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
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)]
#[serde(tag = "type")]
pub enum ClientType {
Vanilla(VanillaClientProps),
}
impl ClientType {
pub async fn get_versions(&self, file: Option<&PathBuf>) -> crate::Result<Vec<Version>> {
match self {
ClientType::Vanilla(_) => vanilla::get_versions(file).await,
}
}
}
pub fn get_impl<'a>(
client: &'a ClientType,
instance: &'a Instance,
manifest: &'a Manifest,
) -> Box<dyn ClientTrait<'a> + 'a> {
Box::new(match client {
ClientType::Vanilla(_) => vanilla::VanillaClient::new(instance, manifest),
})
}
#[async_trait]
pub trait ClientTrait<'a>: Send + Sync {
fn new(instance: &'a Instance, manifest: &'a Manifest) -> Self
where
Self: Sized;
async fn launch(&self) -> crate::Result<()>;
async fn setup(&self) -> crate::Result<()>;
async fn install_game(&self) -> crate::Result<PathBuf>;
async fn install_libraries(&self) -> crate::Result<String>;
async fn install_natives(&self) -> crate::Result<()>;
async fn install_assets(&self) -> crate::Result<()>;
fn get_instance(&self) -> &'a Instance
where
Self: Sized;
fn get_manifest(&self) -> &'a Manifest
where
Self: Sized;
}
#[macro_export]
macro_rules! create_game_client {
($props:ident { $($props_name:ident: $props_type:ty),* } $client:ident { $($name:ident: $type:ty),* }) => {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct $props {
$(pub $props_name: $props_type),*
}
#[derive(Debug, Clone)]
pub struct $client<'a> {
pub instance: &'a crate::game::client::Instance,
pub manifest: &'a crate::game::client::Manifest,
$(pub $name: $type),*
}
};
}
#[macro_export]
macro_rules! impl_game_client {
() => {
fn get_instance(&self) -> &'a crate::game::client::Instance
where
Self: Sized,
{
self.instance
}
fn get_manifest(&self) -> &'a crate::game::client::Manifest
where
Self: Sized,
{
self.manifest
}
};
}
#[derive(Debug, thiserror::Error)]
pub enum ClientManagerError {
#[error("Instance not found")]
InstanceNotFound,
#[error("Manifest not found")]
ManifestNotFound,
}
pub struct ClientManager {
instances: HashMap<Uuid, Instance>,
manifests: HashMap<Uuid, Manifest>,
}
impl ClientManager {
pub fn new() -> crate::Result<Self> {
let instances = load_and_serialize::<Instance>(&dirs::instances_dir()?)?;
let manifests = load_and_serialize::<Manifest>(&dirs::manifests_dir()?)?;
Ok(Self {
instances,
manifests,
})
}
pub fn get_impl_uuid<'a>(&'a self, uuid: Uuid) -> crate::Result<Box<dyn ClientTrait<'a> + 'a>> {
let instance = self.get_instance(uuid)?;
let manifest = self.get_manifest(uuid)?;
Ok(get_impl(&instance.client, &instance, &manifest))
}
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) -> crate::Result<&Instance> {
self.instances
.get(&uuid)
.ok_or(ClientManagerError::InstanceNotFound.into())
}
pub fn get_instance_mut(&mut self, uuid: Uuid) -> crate::Result<&mut Instance> {
self.instances
.get_mut(&uuid)
.ok_or(ClientManagerError::InstanceNotFound.into())
}
pub fn get_manifest(&self, uuid: Uuid) -> crate::Result<&Manifest> {
self.manifests
.get(&uuid)
.ok_or(ClientManagerError::ManifestNotFound.into())
}
pub fn get_manifest_mut(&mut self, uuid: Uuid) -> crate::Result<&mut Manifest> {
self.manifests
.get_mut(&uuid)
.ok_or(ClientManagerError::ManifestNotFound.into())
}
pub async fn create_instance(
&mut self,
name: String,
version: String,
cover: Option<String>,
group: Option<Uuid>,
client: ClientType,
) -> crate::Result<Uuid> {
let version_cache = dirs::app_config_dir()?.join("versions_cache.json");
let versions = client.get_versions(Some(&version_cache)).await?;
let url = &versions
.iter()
.find(|v| v.id == version)
.ok_or(anyhow!("Version not found"))?
.url;
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(
&dirs::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(
&dirs::instances_dir()?.join(format!("{}.json", uuid)),
&instance,
)?;
self.instances.insert(uuid, instance);
Ok(uuid)
}
}
fn save<T>(path: &PathBuf, object: T) -> crate::Result<()>
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(())
}
fn load_and_serialize<T>(dir: &PathBuf) -> crate::Result<HashMap<Uuid, T>>
where
T: DeserializeOwned,
{
let mut result = HashMap::<Uuid, T>::new();
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;
}
};
let path = file.path();
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(_) => {
eprintln!("Couldn't read file: '{:?}'", path);
continue;
}
};
let parsed = match serde_json::from_str::<T>(&content) {
Ok(parsed) => parsed,
Err(_) => {
eprintln!("Couldn't parse file content as JSON: '{:?}'", content);
continue;
}
};
result.insert(uuid, parsed);
}
Ok(result)
}

View file

@ -0,0 +1,159 @@
use std::{fs, path::PathBuf};
use serde_json::Value;
use thiserror::Error;
use crate::{
utils::{file, http},
ErrorKind,
};
#[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) -> crate::Result<PathBuf> {
if !dir.exists() {
match fs::create_dir_all(&dir) {
Ok(_) => (),
Err(err) => {
return Err(ErrorKind::JavaError(JavaDownloadError::PermissionDenied(
err.to_string(),
))
.into())
}
};
}
let os = std::env::consts::OS;
let archive_type = match os {
"windows" => "zip",
"macos" => "tar.gz",
"linux" => "tar.gz",
_ => {
return Err(
ErrorKind::JavaError(JavaDownloadError::UnsupportedOS(os.to_string())).into(),
)
}
};
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(ErrorKind::JavaError(JavaDownloadError::NoJavaVersionFound(
"unable to get the java executable file".to_string(),
))
.into())
}
};
return Ok(file.join("bin").join("java"));
}
Err(ErrorKind::JavaError(JavaDownloadError::NoJavaVersionFound(
"unable to download the java executable file".to_string(),
))
.into())
}
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,
) -> crate::Result<()> {
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(ErrorKind::JavaError(JavaDownloadError::DownloadError(err.to_string())).into());
};
Ok(())
}
fn extract(archive: &PathBuf, dest: &PathBuf) -> crate::Result<()> {
if let Err(err) = file::extract_archive(archive.as_path(), dest.as_path()) {
let _ = fs::remove_file(dest.as_path());
return Err(ErrorKind::JavaError(JavaDownloadError::ExtractError(err.to_string())).into());
}
let _ = fs::remove_file(archive.as_path());
Ok(())
}
async fn get_java_versions(java_version: u8, os: &str, archive_type: &str) -> crate::Result<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(ErrorKind::JavaError(JavaDownloadError::NoJavaVersionFound(
err.to_string(),
))
.into())
}
},
Err(err) => {
return Err(ErrorKind::JavaError(JavaDownloadError::NoJavaVersionFound(
err.to_string(),
))
.into())
}
};
if !response.is_array() || response.as_array().unwrap().is_empty() {
return Err(ErrorKind::JavaError(JavaDownloadError::NoJavaVersionFound(
"didn't get the expected java api crate::Result".to_string(),
))
.into());
}
Ok(response)
}

View file

@ -0,0 +1,209 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[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 arguments: Arguments,
pub release_time: String,
#[serde(rename = "type")]
pub release_type: ReleaseType,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(untagged)]
pub enum Arguments {
// TODO: https://serde.rs/string-or-struct.html
MinecraftArguments(String),
Arguments {
game: Vec<ModernArgumentsItem>,
jvm: Vec<ModernArgumentsItem>,
},
}
impl Default for Arguments {
fn default() -> Self {
Arguments::MinecraftArguments(String::new())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(untagged)]
pub enum ModernArgumentsItem {
Simple(String),
Rule {
rules: Vec<Rule>,
value: ArgumentRuleValue,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(untagged)]
pub enum ArgumentRuleValue {
String(String),
List(Vec<String>),
}
#[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(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rule {
pub action: RuleAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<Os>,
#[serde(skip_serializing_if = "Option::is_none")]
pub features: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(untagged)]
pub enum RuleAction {
Allow,
Disallow,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Os {
pub name: Option<String>,
pub arch: Option<String>,
pub version: Option<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,4 +1,4 @@
pub mod client;
pub mod java;
pub mod minecraft;
pub mod vanilla;
pub mod minecraft;

View file

@ -0,0 +1,144 @@
use super::{
client::{ClientTrait, Instance, Manifest, Version},
minecraft::MinecraftManifest,
};
use crate::{
constants::MINECRAFT_VERSIONS_MANIFEST,
create_game_client, impl_game_client,
utils::{dirs, file, http},
};
use anyhow::anyhow;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::{fs, path::PathBuf};
create_game_client! {
VanillaClientProps {}
VanillaClient {}
}
#[async_trait]
impl<'a> ClientTrait<'a> for VanillaClient<'a> {
impl_game_client!();
fn new(instance: &'a Instance, manifest: &'a Manifest) -> Self {
VanillaClient { instance, manifest }
}
async fn launch(&self) -> crate::Result<()> {
Ok(())
}
async fn setup(&self) -> crate::Result<()> {
Ok(())
}
async fn install_game(&self) -> crate::Result<PathBuf> {
let manifest = &self.manifest.manifest;
let file = dirs::clients_dir()?.join(format!("{}.jar", manifest.version));
if !file.exists() {
fs::create_dir_all(&file)?;
let artifact = &manifest.downloads.client;
http::download_file(&artifact.url, &file).await?;
let file_hash = file::file_sha1(&file)?;
println!("Downloaded: '{}' | '{}'", file_hash, artifact.sha1);
if file_hash != artifact.sha1 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Hashes do not match",
)
.into());
}
}
Ok(file)
}
async fn install_libraries(&self) -> crate::Result<String> {
Ok("".to_string())
}
async fn install_natives(&self) -> crate::Result<()> {
Ok(())
}
async fn install_assets(&self) -> crate::Result<()> {
Ok(())
}
}
#[derive(Clone, Serialize, Deserialize)]
struct VersionList {
versions: Vec<Version>,
}
#[derive(Serialize, Deserialize)]
struct CachedVersions {
last_updated: i64,
versions: Vec<Version>,
}
pub async fn get_versions(file: Option<&PathBuf>) -> crate::Result<Vec<Version>> {
if let Some(file) = file {
if file.exists() && file.is_file() {
if let Some(versions) = get_cached_versions(file).await? {
return Ok(versions);
}
}
}
let response = http::create_client()?
.get(MINECRAFT_VERSIONS_MANIFEST)
.send()
.await?;
let response = response.json::<VersionList>().await?;
if let Some(file) = file {
fs::write(
file,
serde_json::to_string(&CachedVersions {
last_updated: chrono::Utc::now().timestamp(),
versions: response.versions.clone(),
})?,
)?;
}
Ok(response.versions)
}
async fn get_cached_versions(file: &PathBuf) -> crate::Result<Option<Vec<Version>>> {
let cached = serde_json::from_str::<CachedVersions>(&fs::read_to_string(file)?)?;
let head_request = http::create_client()?
.head(MINECRAFT_VERSIONS_MANIFEST)
.send()
.await?;
let last_updated = head_request
.headers()
.get("Last-Modified")
.ok_or(anyhow!("Last-Modified header not found"))?;
let last_updated = last_updated
.to_str()
.map_err(|_| anyhow!("Failed to convert Last-Modified header to string"))?;
let last_updated = chrono::DateTime::parse_from_rfc2822(last_updated)?;
if cached.last_updated < last_updated.timestamp() {
Ok(None)
} else {
Ok(Some(cached.versions))
}
}
pub async fn retrieve_version_manifest(url: &str) -> crate::Result<MinecraftManifest> {
let manifest = http::create_client()?
.get(url)
.send()
.await?
.json::<MinecraftManifest>()
.await?;
Ok(manifest)
}

12
onelauncher/src/lib.rs Normal file
View file

@ -0,0 +1,12 @@
#![warn(unused_import_braces)]
#![deny(unused_must_use)]
pub mod auth;
pub mod constants;
pub mod error;
pub mod game;
pub mod logger;
pub mod utils;
pub use error::*;
pub use logger::start_logger;

54
onelauncher/src/logger.rs Normal file
View file

@ -0,0 +1,54 @@
use tracing_appender::non_blocking::WorkerGuard;
#[cfg(debug_assertions)]
pub fn start_logger() -> Option<WorkerGuard> {
use tracing_subscriber::prelude::*;
let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("onelauncher=info,onelauncher_gui=info")
});
let subscriber = tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(filter)
.with(tracing_error::ErrorLayer::default());
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
None
}
#[cfg(not(debug_assertions))]
pub fn start_logger() -> Option<WorkerGuard> {
use crate::game::client::DirectoryInfo;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::fmt::time::ChronoLocal;
use tracing_subscriber::prelude::*;
let logs_dir = if let Some(directory) = DirectoryInfo::logs_dir() {
directory
} else {
eprintln!("failed to start logger");
return None;
};
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("onelauncher=info"));
let file_appender = RollingFileAppender::new(Rotation::DAILY, logs_dir, "onelauncher.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let subscriber = tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false)
.with_timer(ChronoLocal::rfc3339()),
)
.with(filter)
.with(tracing_error::ErrorLayer::default());
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
Some(guard);
}

View file

@ -0,0 +1,35 @@
use std::path::PathBuf;
use crate::constants;
#[derive(Debug, thiserror::Error)]
pub enum DirectoryError {
#[error("failed to get the user's config directory")]
ConfigDir,
}
pub fn app_config_dir() -> crate::Result<PathBuf> {
dirs::config_dir()
.and_then(|dir| Some(dir.join(constants::APP_CONFIG_DIR)))
.ok_or(DirectoryError::ConfigDir.into())
}
pub fn libraries_dir() -> crate::Result<PathBuf> {
Ok(app_config_dir()?.join("libraries"))
}
pub fn java_dir() -> crate::Result<PathBuf> {
Ok(app_config_dir()?.join("java"))
}
pub fn instances_dir() -> crate::Result<PathBuf> {
Ok(app_config_dir()?.join("instances"))
}
pub fn manifests_dir() -> crate::Result<PathBuf> {
Ok(app_config_dir()?.join("manifests"))
}
pub fn clients_dir() -> crate::Result<PathBuf> {
Ok(app_config_dir()?.join("clients"))
}

View file

@ -0,0 +1,52 @@
use anyhow::anyhow;
use sha1::{Digest, Sha1};
use std::{
fs::File,
io::BufReader,
path::{Path, PathBuf},
};
use crate::ErrorKind;
pub fn file_sha1(file: &PathBuf) -> crate::Result<String> {
let file = File::open(file)?;
let reader = BufReader::new(file);
let mut hasher = Sha1::new();
hasher.update(&reader.buffer());
let hash = hasher.finalize();
Ok(format!("{:x}", hash))
}
pub fn extract_archive(archive: &Path, dest: &Path) -> crate::Result<()> {
let ext = match archive.extension() {
Some(ext) => ext.to_str().unwrap(),
None => return Err(ErrorKind::AnyhowError(anyhow!("unsupported operating system")).into()),
};
match ext {
"zip" => extract_zip(archive, dest),
"gz" => extract_tar_gz(archive, dest),
_ => Err(ErrorKind::AnyhowError(anyhow!("unsupported file extension {:?}", ext)).into()),
}
}
pub fn extract_zip(archive: &Path, dest: &Path) -> crate::Result<()> {
let file = File::open(archive).map_err(|err| ErrorKind::IOError(err))?;
let archive = zip::ZipArchive::new(file).map_err(|err| ErrorKind::ZipError(err));
archive?
.extract(dest)
.map_err(|err| ErrorKind::ZipError(err))?;
Ok(())
}
pub fn extract_tar_gz(archive: &Path, dest: &Path) -> crate::Result<()> {
let file = File::open(archive).map_err(|err| ErrorKind::IOError(err))?;
let tar_gz = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(tar_gz);
archive
.unpack(dest)
.map_err(|err| ErrorKind::IOError(err))?;
Ok(())
}

View file

@ -0,0 +1,34 @@
use std::{
fs::File,
io::{self, Cursor},
path::Path,
};
use reqwest::{Client, ClientBuilder};
use crate::{constants::USER_AGENT, ErrorKind};
pub fn create_client() -> crate::Result<Client> {
Ok(ClientBuilder::new()
.user_agent(USER_AGENT)
.build()
.map_err(|err| ErrorKind::HTTPError(err))?)
}
// can we not make mapping the errors automatic, isnt that the whole point
pub async fn download_file(url: &str, path: &Path) -> crate::Result<()> {
let response = create_client()?
.get(url)
.send()
.await
.map_err(|err| ErrorKind::HTTPError(err))?;
let mut file = File::create(path).map_err(|err| ErrorKind::IOError(err))?;
let mut content = Cursor::new(
response
.bytes()
.await
.map_err(|err| ErrorKind::HTTPError(err))?,
);
io::copy(&mut content, &mut file).map_err(|err| ErrorKind::IOError(err))?;
Ok(())
}

View file

@ -1,3 +1,3 @@
pub mod dirs;
pub mod file;
pub mod http;
pub mod dirs;

View file

@ -15,7 +15,6 @@
},
"dependencies": {
"@fontsource/poppins": "^5.0.12",
"@sentry/vite-plugin": "^2.16.0",
"@solidjs/router": "^0.13.1",
"@tauri-apps/api": "^2.0.0-beta",
"@tauri-apps/plugin-shell": "github:tauri-apps/tauri-plugin-shell#v2",

View file

@ -1,5 +1,5 @@
[package]
name = "polyfrost-launcher"
name = "onelauncher_gui"
version = "0.0.0"
description = "A Tauri App"
license = { workspace = true }
@ -14,10 +14,12 @@ authors = { workspace = true }
tauri-build = { workspace = true }
[dependencies]
launcher-core = { path = "../core" }
onelauncher = { path = "../onelauncher", features = [ "tauri" ] }
tauri = { workspace = true }
tauri-plugin-shell = { workspace = true }
tauri-plugin-http = { workspace = true }
tauri-plugin-window-state = { workspace = true }
tauri-plugin-single-instance = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@ -27,6 +29,9 @@ anyhow = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
tracing = { workspace = true }
tracing-error = { workspace = true }
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!
custom-protocol = [ "tauri/custom-protocol" ]

View file

@ -1,18 +1,15 @@
use tauri_build::{Attributes, InlinedPlugin};
fn main() {
let _ = tauri_build::try_build(
Attributes::new()
.plugin(
"launcher-core",
InlinedPlugin::new().commands(&[
"login_msa",
"refresh_client_manager",
"create_instance",
"get_instances",
"get_instance",
"get_manifest"
]),
),
);
let _ = tauri_build::try_build(Attributes::new().plugin(
"onelauncher",
InlinedPlugin::new().commands(&[
"login_msa",
"refresh_client_manager",
"create_instance",
"get_instances",
"get_instance",
"get_manifest",
]),
));
}

View file

@ -7,7 +7,7 @@
"*"
],
"permissions": [
"launcher-core:default",
"onelauncher:default",
"window:allow-close",
"window:allow-destroy",
"window:allow-start-dragging",

View file

@ -1,4 +1,6 @@
use launcher_core::auth::{microsoft_auth::MicrosoftAuthenticationMethod, Account, AuthenticationMethod};
use launcher_core::auth::{
microsoft_auth::MicrosoftAuthenticationMethod, Account, AuthenticationMethod,
};
use tauri::Manager;
#[tauri::command]
@ -11,4 +13,4 @@ pub async fn login_msa<R: tauri::Runtime>(app: tauri::AppHandle<R>) -> Result<Ac
Ok(account) => Ok(account),
Err(err) => Err(err.to_string()),
}
}
}

50
src-tauri/src/api/game.rs Normal file
View file

@ -0,0 +1,50 @@
use launcher_core::game::client::{ClientType, Instance, Manifest};
use tauri::State;
use tokio::sync::Mutex;
use uuid::Uuid;
use super::GameManagerState;
#[tauri::command]
pub async fn create_instance(
state: State<'_, Mutex<GameManagerState>>,
name: String,
version: String,
client: ClientType,
cover: Option<String>,
group: Option<Uuid>,
) -> Result<Uuid, String> {
let manager = &mut state.lock().await.client_manager;
let uuid = manager
.create_instance(name, version, cover, group, client)
.await?;
Ok(uuid)
}
#[tauri::command]
pub 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())
}
#[tauri::command]
pub async fn get_instance(
state: State<'_, Mutex<GameManagerState>>,
uuid: Uuid,
) -> Result<Instance, String> {
let manager = &mut state.lock().await.client_manager;
let instance = manager.get_instance(uuid)?;
Ok(instance.clone())
}
#[tauri::command]
pub async fn get_manifest(
state: State<'_, Mutex<GameManagerState>>,
uuid: Uuid,
) -> Result<Manifest, String> {
let manager = &mut state.lock().await.client_manager;
let manifest = manager.get_manifest(uuid)?;
Ok(manifest.clone())
}

41
src-tauri/src/api/mod.rs Normal file
View file

@ -0,0 +1,41 @@
use launcher_core::game::client::ClientManager;
use tauri::{plugin::TauriPlugin, Manager, State};
use tokio::sync::Mutex;
mod auth;
mod game;
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("onelauncher")
.setup(|app, _| {
app.manage(Mutex::new(GameManagerState {
client_manager: ClientManager::new().expect("Failed to initialize client manager"),
}));
Ok(())
})
.invoke_handler(tauri::generate_handler![
auth::login_msa,
game::create_instance,
game::get_instances,
game::get_instance,
game::get_manifest,
refresh_client_manager,
])
.build()
}
#[tauri::command]
#[tracing::instrument(skip_all)]
async fn refresh_client_manager(state: State<'_, Mutex<GameManagerState>>) -> Result<(), String> {
let mut state = state.lock().await;
state.client_manager = ClientManager::new()?;
Ok(())
}

View file

@ -1,3 +0,0 @@
// Basic Metadata
pub const NAME: &str = "OneLauncher";
pub const VERSION: &str = "0.1.0";

18
src-tauri/src/error.rs Normal file
View file

@ -0,0 +1,18 @@
use tracing_error::ExtractSpanTrace;
pub fn display_tracing_error(err: &launcher_core::Error) {
match get_span_trace(err) {
Some(span_trace) => {
tracing::error!(error = %err, span_trace = %span_trace);
}
None => {
tracing::error!(error = %err);
}
}
}
pub fn get_span_trace<'a>(
error: &'a (dyn std::error::Error + 'static),
) -> Option<&'a tracing_error::SpanTrace> {
error.source().and_then(|e| e.span_trace())
}

View file

@ -1,48 +0,0 @@
use launcher_core::game::client::{ClientType, Instance, Manifest};
use tauri::State;
use tokio::sync::Mutex;
use uuid::Uuid;
use super::GameManagerState;
#[tauri::command]
pub async fn create_instance(
state: State<'_, Mutex<GameManagerState>>,
name: String,
version: String,
client: ClientType,
cover: Option<String>,
group: Option<Uuid>,
) -> Result<Uuid, String> {
let manager = &mut state.lock().await.client_manager;
let uuid = manager.create_instance(name, version, cover, group, client).await?;
Ok(uuid)
}
#[tauri::command]
pub 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())
}
#[tauri::command]
pub async fn get_instance(
state: State<'_, Mutex<GameManagerState>>,
uuid: Uuid
) -> Result<Instance, String> {
let manager = &mut state.lock().await.client_manager;
let instance = manager.get_instance(uuid)?;
Ok(instance.clone())
}
#[tauri::command]
pub async fn get_manifest(
state: State<'_, Mutex<GameManagerState>>,
uuid: Uuid
) -> Result<Manifest, String> {
let manager = &mut state.lock().await.client_manager;
let manifest = manager.get_manifest(uuid)?;
Ok(manifest.clone())
}

View file

@ -1,42 +0,0 @@
use launcher_core::game::client::ClientManager;
use tauri::{plugin::TauriPlugin, Manager, State};
use tokio::sync::Mutex;
mod auth;
mod game;
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("launcher-core")
.setup(|app, _| {
app.manage(Mutex::new(GameManagerState {
client_manager: ClientManager::new().expect("Failed to initialize client manager"),
}));
Ok(())
})
.invoke_handler(tauri::generate_handler![
auth::login_msa,
game::create_instance,
game::get_instances,
game::get_instance,
game::get_manifest,
refresh_client_manager,
])
.build()
}
#[tauri::command]
async fn refresh_client_manager(
state: State<'_, Mutex<GameManagerState>>
) -> Result<(), String> {
let mut state = state.lock().await;
state.client_manager = ClientManager::new()?;
Ok(())
}

View file

@ -1,2 +1,2 @@
pub mod constants;
pub mod launcher_core;
pub mod api;
pub mod error;

View file

@ -3,14 +3,30 @@
use std::error::Error;
use polyfrost_launcher::launcher_core;
use polyfrost_launcher::api;
use tauri::{menu::Menu, App};
#[derive(Clone, serde::Serialize)]
struct Payload {
args: Vec<String>,
cwd: String,
}
#[tokio::main]
async fn main() {
let _log_guard = launcher_core::start_logger();
tracing::info!("initialized tracing subscriber. loading OneLauncher");
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(launcher_core::init())
.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
println!("{}, {argv:?}, {cwd}", app.package_info().name);
app.emit_all("single-instance", Payload { args: argv, cwd })
.unwrap();
}))
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(api::init())
.menu(|handle| Menu::new(handle))
.setup(setup)
.run(tauri::generate_context!())

View file

@ -1,16 +1,16 @@
{
"productName": "polyfrost-launcher",
"productName": "onelauncher",
"version": "0.0.0",
"identifier": "org.polyfrost.launcher",
"app": {
"withGlobalTauri": true,
"security": {
"csp": null,
"csp": "default-src 'self'; connect-src https://modrinth.com https://*.modrinth.com https://polyfrost.org https://*.polyfrost.org https://*.cloudflare.com https://api.mclo.gs; img-src tauri: https: data: blob: 'unsafe-inline' asset: https://asset.localhost; script-src https://*.cloudflare.com 'self'; frame-src https://*.cloudflare.com https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'; style-src unsafe-inline 'self'",
"capabilities": []
},
"windows": [
{
"title": "polyfrost-launcher",
"title": "OneLauncher",
"width": 1280,
"height": 802,
"minWidth": 1080,
@ -31,6 +31,7 @@
"bundle": {
"active": true,
"targets": "all",
"category": "Game",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@ -38,7 +39,13 @@
"icons/icon.icns",
"icons/icon.ico"
],
"copyright": "Polyfrost"
"copyright": "Polyfrost",
"fileAssociations": [
{
"ext": ["mrpack"],
"description": ""
}
]
},
"plugins": {}
}

View file

@ -1,13 +1,13 @@
import { invoke } from '@tauri-apps/api/core';
export async function getInstance(uuid: string): Promise<Core.Instance> {
return await invoke<Core.Instance>('plugin:launcher-core|get_instance', {
return await invoke<Core.Instance>('plugin:onelauncher|get_instance', {
uuid,
});
}
export async function getManifest(uuid: string): Promise<Core.Manifest> {
return await invoke<Core.Manifest>('plugin:launcher-core|get_manifest', {
return await invoke<Core.Manifest>('plugin:onelauncher|get_manifest', {
uuid,
});
}
@ -39,12 +39,12 @@ export async function getGroupedInstances(): Promise<Map<string, Core.InstanceWi
}
export async function getInstances(): Promise<Core.Instance[]> {
return await invoke<Core.Instance[]>('plugin:launcher-core|get_instances');
return await invoke<Core.Instance[]>('plugin:onelauncher|get_instances');
}
export async function createInstance(instance: Omit<Core.Instance, 'id' | 'createdAt'> & { version: string }): Promise<void> {
return await invoke(
'plugin:launcher-core|create_instance',
'plugin:onelauncher|create_instance',
{
name: instance.name,
version: instance.version,
@ -61,5 +61,5 @@ export async function createInstance(instance: Omit<Core.Instance, 'id' | 'creat
}
export async function refreshClientManager(): Promise<void> {
return await invoke('plugin:launcher-core|refresh_client_manager');
return await invoke('plugin:onelauncher|refresh_client_manager');
}

View file

@ -1,14 +1,9 @@
import process from 'node:process';
import type { Plugin } from 'vite';
import type { Plugin, UserConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import solid from 'vite-plugin-solid';
// idk if we are going to want to use sentry since we would have to selfhost
// we also need to consider self hosting a translation service
// and figuring out a way to implement analytics into this
import { sentryVitePlugin } from '@sentry/vite-plugin';
// hopefully once we merge into nexus monorepo this can be one standard vite config
const devtoolsPlugin: Plugin = {
name: 'devtools-plugin',
transformIndexHtml(html) {
@ -26,12 +21,19 @@ const devtoolsPlugin: Plugin = {
export default defineConfig(async ({ mode }) => {
process.env = { ...process.env, ...loadEnv(mode, process.cwd(), '') };
const config = {
const config: UserConfig = {
plugins: [
solid(),
devtoolsPlugin,
],
envPrefix: ['VITE_', 'TAURI_'],
build: {
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_DEBUG,
},
clearScreen: false,
server: {
port: 8001,
@ -42,14 +44,5 @@ export default defineConfig(async ({ mode }) => {
},
};
if (process.env.SENTRY_AUTH_TOKEN) {
config.plugins.push(sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
url: 'https://sentry.polyfrost.org',
org: 'polyfrost',
project: 'launcher',
}));
}
return config;
});