Merge pull request #6 from saturnclientmc/working-launcher-auth

Working launcher and auth
This commit is contained in:
Kr4ight
2026-04-04 20:24:37 +02:00
committed by GitHub
23 changed files with 2904 additions and 327 deletions
+1344 -36
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -22,4 +22,10 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
directories = "6.0"
once_cell = "1.21"
lyceris = "1.1.3"
rayon = "1.10.0"
reqwest = "0.12.9"
futures = "0.3"
tokio = { version = "1.51.0", features = ["fs"] }
+2 -33
View File
@@ -1,12 +1,6 @@
use tauri::{AppHandle, Emitter, State}; use tauri::State;
use crate::{GameVersion, LauncherSettings, Mod, SharedState}; use crate::{LauncherSettings, Mod, SharedState};
#[tauri::command]
pub fn get_versions(state: State<SharedState>) -> Vec<GameVersion> {
let state = state.lock().unwrap();
state.versions.clone()
}
#[tauri::command] #[tauri::command]
pub fn get_installed_mods(state: State<SharedState>, version_id: String) -> Vec<Mod> { pub fn get_installed_mods(state: State<SharedState>, version_id: String) -> Vec<Mod> {
@@ -47,28 +41,3 @@ pub fn update_settings(state: State<SharedState>, new_settings: LauncherSettings
let mut state = state.lock().unwrap(); let mut state = state.lock().unwrap();
state.settings = new_settings; state.settings = new_settings;
} }
#[tauri::command]
pub fn launch_game(app: AppHandle, version_id: String) -> Result<(), String> {
std::thread::spawn(move || {
app.emit("is-launching", "true").unwrap();
app.emit("launch-status", "Preparing...").unwrap();
std::thread::sleep(std::time::Duration::from_millis(500));
app.emit("launch-status", "Verifying assets...").unwrap();
std::thread::sleep(std::time::Duration::from_millis(700));
app.emit("launch-status", "Applying Saturn optimizations...")
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(700));
app.emit("launch-status", "Starting game...").unwrap();
std::thread::sleep(std::time::Duration::from_millis(1000));
app.emit("launch-status", "Game launched!").unwrap();
app.emit("is-launching", "false").unwrap();
});
Ok(())
}
+175
View File
@@ -0,0 +1,175 @@
use std::{fs, path::PathBuf};
use reqwest::Client;
use tauri::{command, AppHandle, Emitter, Manager, Url};
use lyceris::auth::microsoft::{authenticate, create_link, refresh, validate, MinecraftAccount};
use crate::launcher::LAUNCHER_DIR;
fn accounts_path() -> PathBuf {
LAUNCHER_DIR.data_dir().join("accounts.json")
}
fn load_accounts() -> Vec<MinecraftAccount> {
let path = accounts_path();
if !path.exists() {
return vec![];
}
let data = fs::read_to_string(path).unwrap_or_default();
serde_json::from_str(&data).unwrap_or_default()
}
fn save_accounts(accounts: &[MinecraftAccount]) -> Result<(), String> {
let path = accounts_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let data = serde_json::to_string_pretty(accounts).map_err(|e| e.to_string())?;
fs::write(path, data).map_err(|e| e.to_string())
}
fn extract_code(url: &str) -> Option<String> {
Url::parse(url)
.ok()?
.query_pairs()
.find(|(key, _)| key == "code")
.map(|(_, value)| value.to_string())
}
#[command]
pub fn auth_create_link(app: AppHandle) -> Result<String, String> {
let link = create_link().map_err(|e| e.to_string())?;
let url = link.parse::<Url>().map_err(|e| e.to_string())?;
let app_handle = app.clone();
tauri::WebviewWindowBuilder::new(&app, "auth", tauri::WebviewUrl::External(url))
.title("Microsoft Login")
.inner_size(500.0, 700.0)
.resizable(false)
.on_navigation(move |nav_url| {
let url_str = nav_url.to_string();
// Check for auth code
if url_str.contains("code=") {
if let Some(code) = extract_code(&url_str) {
println!("Auth code received: {}", code);
let _ = app_handle.emit("auth-code", code);
if let Some(win) = app_handle.get_webview_window("auth") {
let _ = win.close();
}
}
}
// Always allow navigation
true
})
.build()
.map_err(|e| e.to_string())?;
Ok(link)
}
#[command]
pub async fn auth_login(code: String) -> Result<MinecraftAccount, String> {
let client = Client::new();
let account = authenticate(code, &client)
.await
.map_err(|e| e.to_string())?;
let mut accounts = load_accounts();
// Replace if same UUID exists
accounts.retain(|a| a.uuid != account.uuid);
accounts.push(account.clone());
save_accounts(&accounts)?;
Ok(account)
}
#[command]
pub async fn auth_refresh(uuid: String) -> Result<MinecraftAccount, String> {
let client = Client::new();
let mut accounts = load_accounts();
let account = accounts
.iter()
.find(|a| a.uuid == uuid)
.cloned()
.ok_or("Account not found")?;
let refreshed = refresh(account.refresh_token, &client)
.await
.map_err(|e| e.to_string())?;
accounts.retain(|a| a.uuid != uuid);
accounts.push(refreshed.clone());
save_accounts(&accounts)?;
Ok(refreshed)
}
#[command]
pub fn auth_list() -> Result<Vec<MinecraftAccount>, String> {
Ok(load_accounts())
}
#[command]
pub fn auth_remove(uuid: String) -> Result<(), String> {
let mut accounts = load_accounts();
accounts.retain(|a| a.uuid != uuid);
save_accounts(&accounts)
}
#[command]
pub fn auth_validate(uuid: String) -> Result<bool, String> {
let accounts = load_accounts();
let account = accounts
.iter()
.find(|a| a.uuid == uuid)
.ok_or("Account not found")?;
Ok(validate(account.exp))
}
#[command]
pub async fn auth_get_valid(uuid: String) -> Result<MinecraftAccount, String> {
let client = Client::new();
let mut accounts = load_accounts();
let account = accounts
.iter()
.find(|a| a.uuid == uuid)
.cloned()
.ok_or("Account not found")?;
if validate(account.exp) {
return Ok(account);
}
let refreshed = refresh(account.refresh_token, &client)
.await
.map_err(|e| e.to_string())?;
accounts.retain(|a| a.uuid != uuid);
accounts.push(refreshed.clone());
save_accounts(&accounts)?;
Ok(refreshed)
}
+193
View File
@@ -0,0 +1,193 @@
use futures::{stream, StreamExt};
use reqwest::{Client, IntoUrl};
use std::{
path::Path,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
time::Duration,
};
use tokio::{
fs::File,
io::{AsyncWriteExt, BufWriter},
};
use lyceris::{
error::Error,
minecraft::{
emitter::{Emitter, Event},
install::FileType,
},
util::retry::retry,
};
/// Downloads a file from the specified URL and saves it to the given destination.
///
/// This function performs an asynchronous HTTP GET request to the provided URL,
/// streams the response body, and writes the content to a file at the specified
/// destination. It also provides progress updates through a callback function.
///
/// # Parameters
///
/// - `url`: The URL of the file to download. It can be any type that implements
/// the `IntoUrl` trait, such as a string slice or a `String`.
/// - `destination`: A `PathBuf` representing the path where the downloaded file
/// will be saved.
/// - `emitter`: An optional emitter for logging progress.
///
/// # Returns
///
/// This function returns a `Result<u64, Error>`. On success, it returns
/// `Ok(u64)`, where `u64` is the total size of the downloaded file. If an error
/// occurs during the download process, it returns an `Err` containing an `Error`
/// that describes the failure.
///
/// # Errors
///
/// The function can fail in several ways, including but not limited to:
/// - Network errors when making the HTTP request.
/// - Non-success HTTP status codes (e.g., 404 Not Found).
/// - Errors when creating or writing to the file.
pub async fn download<P: AsRef<Path>>(
url: impl IntoUrl,
destination: P,
emitter: Option<&Emitter>,
client: Option<&Client>,
) -> lyceris::Result<u64> {
let default_client = Client::default();
let client = client.unwrap_or(&default_client);
let response = client.get(url).send().await?;
if !response.status().is_success() {
return Err(Error::Download(response.status().to_string()));
}
let total_size = response.content_length().unwrap_or(0);
let mut downloaded: u64 = 0;
// 🔥 Assume dirs already created (move this OUTSIDE for max speed)
if let Some(parent) = destination.as_ref().parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let file = File::create(&destination).await?;
let mut writer = BufWriter::with_capacity(64 * 1024, file); // 🔥 BIG WIN
let mut stream = response.bytes_stream();
let mut last_emit = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
downloaded += chunk.len() as u64;
writer.write_all(&chunk).await?;
// 🔥 Emit less frequently (huge improvement)
if downloaded - last_emit > 256 * 1024 || downloaded == total_size {
if let Some(emitter) = emitter {
emitter
.emit(
Event::SingleDownloadProgress,
(
destination.as_ref().to_string_lossy().into_owned(),
downloaded,
total_size,
),
)
.await;
}
last_emit = downloaded;
}
}
writer.flush().await?;
Ok(total_size)
}
/// Downloads multiple files from the specified URLs and saves them to the given destinations.
///
/// This function takes a vector of tuples, where each tuple contains a URL and a destination path.
/// It downloads all files in parallel and provides progress updates through a callback function.
///
/// # Parameters
///
/// - `downloads`: A vector of tuples containing the URLs and their corresponding destination paths.
/// - `emitter`: An optional emitter for logging progress.
///
/// # Returns
///
/// This function returns a `Result<(), Error>`. On success, it returns `Ok(())`. If an error occurs
/// during the download process, it returns an `Err` containing an `Error` that describes the failure.
pub async fn download_multiple<U, P>(
downloads: Vec<(U, P, FileType)>,
emitter: Option<&Emitter>,
client: Option<&Client>,
) -> lyceris::Result<()>
where
U: IntoUrl + Send, // URL type that implements IntoUrl
P: AsRef<Path> + Send + 'static, // Path type
{
let total_files = downloads.len();
let total_downloaded = Arc::new(AtomicUsize::new(0));
let tasks = downloads.into_iter().map(|(url, destination, file_type)| {
let total_downloaded = total_downloaded.clone();
async move {
let result = retry(
|| async { download(url.as_str(), destination.as_ref(), emitter, client).await },
Result::is_ok,
3,
Duration::from_millis(300), // 🔥 faster retry
)
.await;
match result {
Ok(_) => {
let downloaded = total_downloaded.fetch_add(1, Ordering::Relaxed) + 1;
if downloaded % 25 == 0 || downloaded == total_files {
if let Some(emitter) = emitter {
emitter
.emit(
Event::MultipleDownloadProgress,
(
destination.as_ref().to_string_lossy().into_owned(),
downloaded as u64,
total_files as u64,
file_type.to_string(),
),
)
.await;
}
}
Ok::<(), Error>(())
}
Err(e) => Err(e),
}
}
});
let mut stream = stream::iter(tasks).buffer_unordered(get_optimal_concurrency());
while let Some(result) = stream.next().await {
result?;
}
Ok(())
}
fn get_optimal_concurrency() -> usize {
// Good defaults for Minecraft-scale downloads
match std::thread::available_parallelism() {
Ok(n) => (n.get() * 16).clamp(32, 128),
Err(_) => 64,
}
}
+589
View File
@@ -0,0 +1,589 @@
use futures::stream::FuturesUnordered;
/// This module handles the installation of Minecraft, including downloading
/// necessary files and managing the Java runtime environment.
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use std::{
env::consts::{ARCH, OS},
fs::{self, create_dir_all},
path::{Path, PathBuf, MAIN_SEPARATOR_STR},
process::Command,
};
use lyceris::{
error::Error,
http::fetch::fetch,
json::{
java::{JavaFileManifest, JavaManifest},
version::{
asset_index::AssetIndex,
manifest::VersionManifest,
meta::vanilla::{self, JavaVersion, VersionMeta},
},
},
minecraft::{
emitter::{Emit, Event},
install::FileType,
CLASSPATH_SEPARATOR, JAVA_MANIFEST_ENDPOINT, RESOURCES_ENDPOINT, VERSION_MANIFEST_ENDPOINT,
},
util::{
extract::{extract_file, read_file_from_jar},
json::{read_json, write_json},
},
};
use lyceris::minecraft::{
config::Config,
emitter::Emitter,
loader::Loader,
parse::{parse_lib_path, ParseRule},
};
use crate::launcher::downloader::{download, download_multiple};
/// Represents a file to be downloaded, including its metadata.
#[derive(Clone)]
struct DownloadFile {
file_name: String,
url: String,
path: PathBuf,
r#type: FileType,
}
/// Installs the specified version of Minecraft by downloading necessary files
/// and setting up the environment.
///
/// # Parameters
/// - `config`: The configuration for the installation process.
/// - `emitter`: An optional emitter for logging progress.
///
/// # Returns
/// A result indicating success or failure of the installation process.
pub async fn install<T: Loader>(
config: &Config<T>,
emitter: Option<&Emitter>,
) -> lyceris::Result<()> {
emitter.emit(Event::Console, "Fetching manifest...").await;
let (manifest, java_manifest) = tokio::join!(
fetch(VERSION_MANIFEST_ENDPOINT, config.client.as_ref()),
fetch(JAVA_MANIFEST_ENDPOINT, config.client.as_ref())
);
let manifest = manifest?;
let java_manifest = java_manifest?;
let version_json_path = config.get_version_json_path();
let mut meta: VersionMeta = if !version_json_path.exists() {
emitter
.emit(Event::Console, "Fetching version metadata")
.await;
let mut meta =
fetch_version_meta(&manifest, &config.version, config.client.as_ref()).await?;
if let Some(loader) = &config.loader {
emitter.emit(Event::Console, "Resolving loader").await;
meta = loader.merge(&config.into_vanilla(), meta, emitter).await?;
}
write_json(&version_json_path, &meta).await?;
meta
} else {
read_json(&version_json_path).await?
};
emitter.emit(Event::Console, "Fetching assets index").await;
let asset_index_path = config
.get_indexes_path()
.join(format!("{}.json", &meta.asset_index.id));
let asset_index: AssetIndex = if !asset_index_path.exists() {
let asset_index = fetch(&meta.asset_index.url, config.client.as_ref()).await?;
write_json(&asset_index_path, &asset_index).await?;
asset_index
} else {
read_json(&asset_index_path).await?
};
let natives_path = config.get_natives_path().join(&config.version);
if !natives_path.is_dir() {
// todo async
create_dir_all(&natives_path)?;
}
emitter.emit(Event::Console, "Configuring files").await;
let check_natives = fs::read_dir(&natives_path)?.count() == 0;
let mut to_be_extracted = Vec::with_capacity(10);
let default_java_version = JavaVersion::default();
let java_version = meta.java_version.as_ref().unwrap_or(&default_java_version);
let runtime_path = config.get_runtime_path().join(&java_version.component);
let java_url = get_java_url(&java_manifest, java_version)?;
let java_files: JavaFileManifest = fetch(java_url, config.client.as_ref()).await?;
let file_map = build_file_map(
&asset_index,
&meta,
&java_files,
&runtime_path,
config,
check_natives,
&mut to_be_extracted,
)?;
emitter.emit(Event::Console, "Downloading files").await;
download_necessary(
file_map,
&config.game_dir,
asset_index.map_to_resources.unwrap_or_default()
|| asset_index.r#virtual.unwrap_or_default(),
emitter,
config.client.as_ref(),
)
.await?;
if !to_be_extracted.is_empty() {
create_dir_all(&natives_path)?;
let tasks = FuturesUnordered::new();
for extract in to_be_extracted {
let emitter = emitter.cloned();
let client = config.client.clone();
let natives_path = natives_path.clone();
tasks.push(tauri::async_runtime::spawn(async move {
if let Some(path) = extract.path {
let path = PathBuf::from(path);
download(&extract.url, &path, emitter.as_ref(), client.as_ref()).await?;
extract_file(&path, &natives_path)?;
Ok::<_, Error>(())
} else {
Ok(())
}
}));
}
for task in tasks {
task.await
.map_err(|e| lyceris::Error::Fail(e.to_string()))??;
}
}
execute_processors_if_exists(&mut meta, config).await?;
Ok(())
}
/// Fetches the version metadata for the specified version from the manifest.
///
/// # Parameters
/// - `manifest`: The version manifest containing available versions.
/// - `version`: The version to fetch metadata for.
/// - `client`: An optional HTTP client for making requests.
///
/// # Returns
/// The version metadata for the specified version.
async fn fetch_version_meta(
manifest: &VersionManifest,
version: &str,
client: Option<&reqwest::Client>,
) -> lyceris::Result<VersionMeta> {
let version_url = manifest
.versions
.iter()
.find(|v| v.id == version)
.ok_or_else(|| Error::UnknownVersion("Vanilla".to_string()))?
.url
.clone();
fetch(&version_url, client).await
}
/// Gets the download URL for the specified Java version based on the operating system and architecture.
///
/// # Parameters
/// - `java_manifest`: The manifest containing Java version information.
/// - `java_version`: The specific Java version to retrieve the URL for.
///
/// # Returns
/// The download URL for the specified Java version.
fn get_java_url(
java_manifest: &JavaManifest,
java_version: &JavaVersion,
) -> lyceris::Result<String> {
let os = if OS == "macos" { "mac-os" } else { OS };
let arch = match ARCH {
"x86" => {
if os == "linux" {
"i386"
} else {
"x86"
}
}
"x86_64" => "x64",
"aarch64" => "arm64",
_ => return Err(Error::UnsupportedArchitecture),
};
let os_arch = if (os == "linux" && arch != "i386")
|| (os == "mac-os" && (arch != "arm64" || java_version.major_version == 8))
{
os.to_string()
} else {
format!("{}-{}", os, arch)
};
java_manifest
.get(&os_arch)
.ok_or_else(|| Error::NotFound("Java map by operating system".to_string()))?
.get(&java_version.component)
.ok_or_else(|| Error::UnknownVersion("Java version".to_string()))?
.first()
.ok_or_else(|| Error::NotFound("Java gamecore".to_string()))
.map(|entry| &entry.manifest.url)
.cloned()
}
/// Builds a map of files to be downloaded based on the asset index, version metadata, and Java files.
///
/// # Parameters
/// - `asset_index`: The asset index containing file information.
/// - `meta`: The version metadata.
/// - `java_files`: The Java file manifest.
/// - `runtime_path`: The path to the Java runtime.
/// - `config`: The configuration for the installation process.
/// - `check_natives`: A flag indicating whether to check for native files.
/// - `to_be_extracted`: A mutable vector to store files that need to be extracted.
///
/// # Returns
/// A vector of `DownloadFile` representing the files to be downloaded.
fn build_file_map(
asset_index: &AssetIndex,
meta: &VersionMeta,
java_files: &JavaFileManifest,
runtime_path: &Path,
config: &Config<impl Loader>,
check_natives: bool,
to_be_extracted: &mut Vec<vanilla::File>,
) -> lyceris::Result<Vec<DownloadFile>> {
let version_jar_path = config.get_version_jar_path();
let version_download = if !version_jar_path.exists() {
Some(DownloadFile {
file_name: version_jar_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
r#type: FileType::Library,
path: version_jar_path,
url: meta.downloads.client.url.clone(),
})
} else {
None
};
let asset_files = asset_index
.objects
.iter()
.map(|(key, meta)| {
let assets_path = config.get_assets_path();
let hash = &meta.hash;
DownloadFile {
file_name: key.clone(),
url: format!("{}/{}/{}", RESOURCES_ENDPOINT, &hash[0..2], hash),
path: assets_path.join("objects").join(&hash[0..2]).join(hash),
r#type: FileType::Asset {
is_map: asset_index.map_to_resources.unwrap_or_default(),
is_virtual: asset_index.r#virtual.unwrap_or_default(),
},
}
})
.collect::<Vec<_>>();
let library_files = meta
.libraries
.iter()
.filter_map(|lib| {
if !lib.rules.parse_rule() {
return None;
}
let downloads = lib.downloads.as_ref()?;
if check_natives {
if let Some(classifiers) = &downloads.classifiers {
let classifier = match OS {
"windows" => &classifiers.natives_windows,
"linux" => &classifiers.natives_linux,
"macos" => &classifiers.natives_macos,
_ => return None,
};
if let Some(classifier) = classifier {
if let Some(classifier_path) = &classifier.path {
let path = config
.game_dir
.join("libraries")
.join(classifier_path.replace("/", MAIN_SEPARATOR_STR));
let url = classifier.url.clone();
let sha1 = classifier.sha1.clone();
to_be_extracted.push(vanilla::File {
path: Some(path.to_string_lossy().into_owned()),
sha1: sha1.clone(),
size: classifier.size,
url: url.clone(),
});
return Some(DownloadFile {
file_name: PathBuf::from(url.clone())
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
url,
path,
r#type: FileType::Library,
});
}
}
}
}
let artifact = downloads.artifact.as_ref()?;
Some(DownloadFile {
file_name: PathBuf::from(artifact.url.clone())
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
url: artifact.url.clone(),
path: config
.game_dir
.join("libraries")
.join(artifact.path.as_ref()?.replace("/", MAIN_SEPARATOR_STR)),
r#type: FileType::Library,
})
})
.collect::<Vec<_>>();
let java_files = java_files
.files
.iter()
.filter_map(|(name, file)| {
let path = runtime_path.join(name.replace("/", MAIN_SEPARATOR_STR));
file.downloads.as_ref().map(|downloads| DownloadFile {
file_name: name
.split(MAIN_SEPARATOR_STR)
.last()
.unwrap_or(name)
.to_string(),
path,
url: downloads.raw.url.clone(),
r#type: FileType::Java,
})
})
.collect::<Vec<_>>();
Ok([
version_download.into_iter().collect::<Vec<_>>(),
asset_files,
library_files,
java_files,
]
.concat())
}
/// Executes any processors defined in the version metadata, if they exist.
///
/// # Parameters
/// - `meta`: The version metadata containing processor information.
/// - `config`: The configuration for the installation process.
///
/// # Returns
/// A result indicating success or failure of the processor execution.
async fn execute_processors_if_exists(
meta: &mut VersionMeta,
config: &Config<impl Loader>,
) -> lyceris::Result<()> {
if let Some(ref mut processors) = meta.processors {
let data = meta
.data
.as_ref()
.ok_or_else(|| Error::NotFound("Forge Installer Data".to_string()))?;
let libraries_path = config.get_libraries_path();
for processor in processors {
if let Some(sides) = &processor.sides {
if !sides.contains(&"client".to_string()) {
continue;
}
}
if processor.success {
continue;
}
let classpath = processor
.classpath
.iter()
.filter_map(|arg| {
Some(
libraries_path
.join(parse_lib_path(arg).ok()?)
.to_string_lossy()
.into_owned(),
)
})
.collect::<Vec<String>>()
.join(CLASSPATH_SEPARATOR);
let main_class = read_file_from_jar(
&libraries_path
.join(parse_lib_path(&processor.jar)?)
.to_string_lossy()
.into_owned(),
"META-INF/MANIFEST.MF",
)?
.lines()
.find(|line| line.starts_with("Main-Class:"))
.ok_or_else(|| Error::NotFound("Main-Class of processor".to_string()))?
.split(":")
.last()
.ok_or_else(|| Error::NotFound("Main-Class of processor".to_string()))?
.trim()
.to_string();
let args = processor
.args
.iter()
.map(|arg| {
let trimmed_arg = &arg[1..arg.len() - 1];
if arg.starts_with('{') {
if let Some(entry) = data.get(trimmed_arg) {
if entry.client.starts_with('[') {
if let Ok(parsed_path) =
parse_lib_path(&entry.client[1..entry.client.len() - 1])
{
return libraries_path
.join(parsed_path)
.to_string_lossy()
.into_owned();
}
}
return entry.client.clone();
}
} else if arg.starts_with('[') {
if let Ok(parsed_path) = parse_lib_path(trimmed_arg) {
return libraries_path
.join(parsed_path)
.to_string_lossy()
.into_owned();
}
}
arg.clone()
})
.collect::<Vec<_>>();
let child = Command::new(
config
.get_java_path(
meta.java_version
.as_ref()
.unwrap_or(&JavaVersion::default()),
)
.await?,
)
.arg("-cp")
.arg(format!(
"{}{}{}",
classpath,
CLASSPATH_SEPARATOR,
libraries_path
.join(parse_lib_path(&processor.jar)?)
.to_string_lossy()
.into_owned()
))
.arg(main_class)
.args(args)
.output()?;
if child.status.success() {
processor.success = true;
} else {
return Err(Error::Fail(format!(
"Processor failed: {}",
String::from_utf8_lossy(&child.stderr)
)));
}
}
}
write_json(&config.get_version_json_path(), &meta).await?;
Ok(())
}
/// Downloads the necessary files based on the provided file list.
///
/// # Parameters
/// - `files`: A vector of files to be downloaded.
/// - `game_dir`: The directory where the game is installed.
/// - `legacy`: A flag indicating whether to handle legacy assets.
/// - `emitter`: An optional emitter for logging progress.
/// - `client`: An optional HTTP client for making requests.
///
/// # Returns
/// A result indicating success or failure of the download process.
async fn download_necessary(
files: Vec<DownloadFile>,
game_dir: &Path,
legacy: bool,
emitter: Option<&Emitter>,
client: Option<&reqwest::Client>,
) -> lyceris::Result<()> {
let broken_ones: Vec<(String, PathBuf, FileType)> = files
.par_iter()
.filter_map(|file| {
if file.url.is_empty() {
return None;
}
if !file.path.exists() {
return Some((file.url.clone(), file.path.clone(), file.r#type.clone()));
}
None
})
.collect();
download_multiple(broken_ones, emitter, client).await?;
if legacy {
files.par_iter().try_for_each(|file| {
if let FileType::Asset { is_virtual, is_map } = file.r#type {
let target_path = if is_virtual {
game_dir
.join("assets")
.join("virtual")
.join("legacy")
.join(&file.file_name)
} else if is_map {
game_dir.join("resources").join(&file.file_name)
} else {
return None::<()>;
};
if let Some(parent) = target_path.parent() {
if !parent.is_dir() {
fs::create_dir_all(parent).ok();
}
}
if !target_path.exists() {
fs::copy(&file.path, &target_path).ok();
}
return None;
}
None
});
}
Ok(())
}
+98
View File
@@ -0,0 +1,98 @@
pub mod auth;
pub mod downloader;
pub mod install;
use directories::ProjectDirs;
use lyceris::{
auth::microsoft::MinecraftAccount,
minecraft::{
config::ConfigBuilder,
emitter::{Emitter, Event},
launch::launch,
loader::fabric::Fabric,
},
};
use once_cell::sync::Lazy;
use crate::GameVersion;
pub static LAUNCHER_DIR: Lazy<ProjectDirs> = Lazy::new(|| {
ProjectDirs::from("org", "SaturnLauncher", "SaturnLauncher")
.expect("Failed to create project directories")
});
#[tauri::command]
pub async fn launch_game(version: GameVersion, account: MinecraftAccount) -> Result<(), String> {
// Emitter uses `EventEmitter` inside of it
// and it uses tokio::Mutex for locking.
// That causes emitter methods to be async.
let emitter = Emitter::default();
// Single download progress event send when
// a file is being downloaded.
emitter
.on(
Event::SingleDownloadProgress,
|(path, current, total): (String, u64, u64)| {
println!("Downloading {} - {}/{}", path, current, total);
},
)
.await;
// Multiple download progress event send when
// multiple files are being downloaded.
// Java, libraries and assets are downloaded in parallel and
// this event is triggered for each file.
emitter
.on(
Event::MultipleDownloadProgress,
|(_, current, total, _): (String, u64, u64, String)| {
println!("Downloading {}/{}", current, total);
},
)
.await;
// Console event send when a line is printed to the console.
// It uses a seperated tokio thread to handle this operation.
emitter
.on(Event::Console, |line: String| {
println!("Line: {}", line);
})
.await;
let current_dir = LAUNCHER_DIR.data_dir();
let config = ConfigBuilder::new(
current_dir.join(&version.id),
version.id.clone(),
lyceris::auth::AuthMethod::Microsoft {
username: account.username,
xuid: account.xuid,
uuid: account.uuid,
access_token: account.access_token,
refresh_token: account.refresh_token,
},
)
.loader(Fabric(version.loader_version).into())
.build();
println!("Starting installation");
// Install method also checks for broken files
// and downloads them again if they are broken.
install::install(&config, Some(&emitter))
.await
.map_err(|e| e.to_string())?;
println!("Finished installing");
// This method never downloads any file and just runs the game.
launch(&config, Some(&emitter))
.await
.map_err(|e| e.to_string())?
.wait()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
+2 -23
View File
@@ -1,4 +1,5 @@
pub mod commands; pub mod commands;
pub mod launcher;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Mutex; use std::sync::Mutex;
@@ -20,8 +21,7 @@ pub struct Mod {
pub struct GameVersion { pub struct GameVersion {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub release_date: String, pub loader_version: String,
pub r#type: String, // "release" | "snapshot"
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@@ -32,7 +32,6 @@ pub struct LauncherSettings {
// --- App State --- // --- App State ---
pub struct AppState { pub struct AppState {
pub versions: Vec<GameVersion>,
pub mods: Vec<Mod>, pub mods: Vec<Mod>,
pub settings: LauncherSettings, pub settings: LauncherSettings,
} }
@@ -40,26 +39,6 @@ pub struct AppState {
impl Default for AppState { impl Default for AppState {
fn default() -> Self { fn default() -> Self {
Self { Self {
versions: vec![
GameVersion {
id: "1.20.1".into(),
name: "Fabric 1.20.1".into(),
release_date: "2023-06-07".into(),
r#type: "release".into(),
},
GameVersion {
id: "1.20.4".into(),
name: "Fabric 1.20.4".into(),
release_date: "2023-12-07".into(),
r#type: "release".into(),
},
GameVersion {
id: "1.21".into(),
name: "Fabric 1.21".into(),
release_date: "2024-06-13".into(),
r#type: "release".into(),
},
],
mods: vec![ mods: vec![
Mod { Mod {
id: "sodium".into(), id: "sodium".into(),
+11 -2
View File
@@ -10,13 +10,22 @@ fn main() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.manage(Mutex::new(AppState::default())) .manage(Mutex::new(AppState::default()))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_versions, // Saturn Launcher Commands
get_installed_mods, get_installed_mods,
toggle_mod, toggle_mod,
install_mod, install_mod,
get_settings, get_settings,
update_settings, update_settings,
launch_game // Launcher Commands
saturn_launcher_lib::launcher::launch_game,
// Auth Commands
saturn_launcher_lib::launcher::auth::auth_create_link,
saturn_launcher_lib::launcher::auth::auth_login,
saturn_launcher_lib::launcher::auth::auth_refresh,
saturn_launcher_lib::launcher::auth::auth_list,
saturn_launcher_lib::launcher::auth::auth_remove,
saturn_launcher_lib::launcher::auth::auth_validate,
saturn_launcher_lib::launcher::auth::auth_get_valid
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+44 -192
View File
@@ -1,51 +1,31 @@
import React, { useState, useEffect } from "react"; import { useEffect, useRef, useState } from "react";
import { import Sidebar from "./components/layout/Sidebar";
Play, import TopBar from "./components/layout/TopBar";
Puzzle,
Settings,
ChevronDown,
Check,
User,
LogOut,
} from "lucide-react";
import { getVersions } from "./services/LauncherService";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import Logo from "@/assets/logo.png";
// Utility for merging tailwind classes
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Components
import PlaySection from "./components/PlaySection"; import PlaySection from "./components/PlaySection";
import ModsSection from "./components/ModsSection"; import ModsSection from "./components/ModsSection";
import SettingsSection from "./components/SettingsSection"; import SettingsSection from "./components/SettingsSection";
import { GameVersion } from "./services/Types"; import { versions } from "./lib/launcher";
import { GameVersion } from "./lib/types";
import { MinecraftAccount } from "./lib/auth";
type Section = "play" | "mods" | "settings"; type Section = "play" | "mods" | "settings";
const App: React.FC = () => { export default function App() {
const [activeSection, setActiveSection] = useState<Section>("play"); const [active, setActive] = useState<Section>("play");
const [versions, setVersions] = useState<GameVersion[]>([]);
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>( const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(
null, null,
); );
const [isVersionDropdownOpen, setIsVersionDropdownOpen] = useState(false); const [isVersionDropdownOpen, setIsVersionDropdownOpen] = useState(false);
const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false); const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false);
const versionRef = React.useRef<HTMLDivElement | null>(null); const versionRef = useRef<HTMLDivElement | null>(null);
const accountRef = React.useRef<HTMLDivElement | null>(null); const accountRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { const [accounts, setAccounts] = useState<MinecraftAccount[]>([]);
const fetchVersions = async () => { const [activeAccount, setActiveAccount] = useState<MinecraftAccount | null>(
const v = await getVersions(); null,
setVersions(v); );
if (v.length > 0) setSelectedVersion(v[0]);
};
fetchVersions();
}, []);
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
@@ -63,175 +43,47 @@ const App: React.FC = () => {
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
}; };
}, []); }, []);
useEffect(() => {
setSelectedVersion(versions[0]);
}, []);
return ( return (
<div className="flex h-screen w-screen bg-saturn-bg text-saturn-text-primary overflow-hidden font-sans"> <div className="flex h-screen">
{/* Sidebar */} <Sidebar active={active} setActive={setActive} />
<aside className="w-16 border-r border-saturn-border flex flex-col items-center py-6 gap-6 bg-saturn-panel/50">
<div>
<img src={Logo} alt="Saturn Logo" className="w-10 h-10" />
</div>
<nav className="flex flex-col gap-4 h-full"> <main className="flex-1 flex flex-col">
<button <TopBar
onClick={() => setActiveSection("play")} versions={versions}
className={cn("sidebar-icon", activeSection === "play" && "active")} selectedVersion={selectedVersion}
title="Play" setSelectedVersion={setSelectedVersion}
> isVersionDropdownOpen={isVersionDropdownOpen}
<Play setIsVersionDropdownOpen={setIsVersionDropdownOpen}
size={22} isAccountDropdownOpen={isAccountDropdownOpen}
fill={activeSection === "play" ? "currentColor" : "none"} setIsAccountDropdownOpen={setIsAccountDropdownOpen}
/> versionRef={versionRef}
</button> accountRef={accountRef}
accounts={accounts}
setAccounts={setAccounts}
activeAccount={activeAccount}
setActiveAccount={setActiveAccount}
/>
<button <div className="flex-1 p-8 overflow-y-scroll">
onClick={() => setActiveSection("mods")} {active === "play" && (
className={cn("sidebar-icon", activeSection === "mods" && "active")} <PlaySection version={selectedVersion} account={activeAccount} />
title="Mods"
>
<Puzzle
size={22}
fill={activeSection === "mods" ? "currentColor" : "none"}
/>
</button>
<button
onClick={() => setActiveSection("settings")}
className={cn(
"sidebar-icon",
activeSection === "settings" && "active",
)}
title="Settings"
>
<Settings
size={22}
fill={activeSection === "settings" ? "currentColor" : "none"}
/>
</button>
</nav>
</aside>
{/* Main Content Area */}
<main className="flex-1 flex flex-col min-w-0">
{/* Top Bar */}
<header className="h-14 border-b border-saturn-border flex items-center justify-between px-6 bg-saturn-panel/30 backdrop-blur-md z-100 py-10">
{/* Version Selector */}
<div ref={versionRef} className="relative">
<div className="relative group overflow-visible inline-block">
{/* Glow */}
<div className="absolute -inset-1 bg-linear-to-r from-blue-600 to-cyan-500 rounded-md blur opacity-25 group-hover:opacity-50 transition duration-500 pointer-events-none" />
<button
onClick={() => {
setIsVersionDropdownOpen((prev) => !prev);
setIsAccountDropdownOpen(false);
}}
className="relative z-10 flex items-center gap-2 px-3 py-1.5 rounded-md btn-primary text-sm font-medium"
>
<span>{selectedVersion?.name || "Loading..."}</span>
<ChevronDown
size={14}
className={cn(
"transition-transform",
isVersionDropdownOpen && "rotate-180",
)}
/>
</button>
</div>
<div
className={cn(
"absolute top-full left-0 mt-2 w-48 bg-saturn-panel border border-saturn-border rounded-lg shadow-2xl z-50 py-1 overflow-hidden transition-all duration-200 origin-top",
isVersionDropdownOpen
? "opacity-100 scale-100 translate-y-0 pointer-events-auto"
: "opacity-0 scale-95 -translate-y-2 pointer-events-none",
)}
>
{versions.map((v) => (
<button
key={v.id}
onClick={() => {
setSelectedVersion(v);
setIsVersionDropdownOpen(false);
}}
className="w-full flex items-center justify-between px-4 py-2 text-sm hover:bg-saturn-accent/10 hover:text-saturn-accent transition-colors"
>
<span>{v.name}</span>
{selectedVersion?.id === v.id && <Check size={14} />}
</button>
))}
</div>
</div>
{/* Account Selector */}
<div ref={accountRef} className="relative group overflow-visible">
{/* Glow */}
<div className="absolute -inset-1 bg-linear-to-r from-blue-600 to-cyan-500 rounded-lg blur opacity-25 group-hover:opacity-50 transition pointer-events-none duration-700" />
<button
className="relative z-10 flex items-center gap-3 pl-20 pr-4 py-2 rounded-lg btn-primary"
onClick={() => setIsAccountDropdownOpen(!isAccountDropdownOpen)}
>
{/* Avatar */}
<div className="absolute -top-2 left-4 group-hover:scale-110 transition-transform duration-700 w-12 overflow-hidden z-20">
<img
src="https://render.crafty.gg/3d/bust/Kr4ight"
className="w-full h-full object-cover"
/>
</div>
<div className="leading-tight">
<p className="text-[10px] text-white/80 uppercase tracking-wide">
Playing as
</p>
<p className="text-sm font-bold text-white">SaturnDev</p>
</div>
</button>
<div
className={cn(
"absolute top-full right-0 mt-2 w-48 bg-saturn-panel border border-saturn-border rounded-lg shadow-2xl z-50 py-1 overflow-hidden transition-all duration-200 origin-top",
isAccountDropdownOpen
? "opacity-100 scale-100 translate-y-0 pointer-events-auto"
: "opacity-0 scale-95 -translate-y-2 pointer-events-none",
)}
>
<div className="px-4 py-3 border-b border-saturn-border">
<p className="text-sm font-bold">SaturnDev</p>
<p className="text-xs text-saturn-text-secondary truncate">
dev@saturnclient.com
</p>
</div>
<button className="w-full flex items-center gap-2 px-4 py-2 text-sm hover:bg-white/5 transition-colors">
<User size={14} />
<span>Switch Account</span>
</button>
<button className="w-full flex items-center gap-2 px-4 py-2 text-sm hover:bg-red-500/10 text-red-400 transition-colors">
<LogOut size={14} />
<span>Logout</span>
</button>
</div>
</div>
</header>
{/* Content Sections */}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-8">
{activeSection === "play" && selectedVersion && (
<PlaySection version={selectedVersion} />
)} )}
{activeSection === "mods" && selectedVersion && ( {active === "mods" && selectedVersion && (
<ModsSection version={selectedVersion} /> <ModsSection version={selectedVersion} />
)} )}
{activeSection === "settings" && <SettingsSection />} {active === "settings" && <SettingsSection />}
</div> </div>
</main> </main>
</div> </div>
); );
}; }
export default App;
+4 -13
View File
@@ -11,14 +11,14 @@ import {
import { import {
getInstalledMods, getInstalledMods,
discoverMods, discoverMods,
getVersions,
toggleMod, toggleMod,
installMod, installMod,
} from "../services/LauncherService"; } from "@/lib/saturn";
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { GameVersion, Mod } from "@/services/Types"; import { GameVersion, Mod } from "@/lib/types";
import { versions } from "@/lib/launcher";
function cn(...inputs: ClassValue[]) { function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@@ -40,7 +40,6 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
const [selectedVersionsForInstall, setSelectedVersionsForInstall] = useState< const [selectedVersionsForInstall, setSelectedVersionsForInstall] = useState<
string[] string[]
>([]); >([]);
const [allVersions, setAllVersions] = useState<GameVersion[]>([]);
useEffect(() => { useEffect(() => {
const loadMods = async () => { const loadMods = async () => {
@@ -55,14 +54,6 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
loadMods(); loadMods();
}, [activeTab, version.id, searchQuery]); }, [activeTab, version.id, searchQuery]);
useEffect(() => {
const loadAllVersions = async () => {
const v = await getVersions();
setAllVersions(v);
};
loadAllVersions();
}, []);
const handleToggleMod = async (modId: string) => { const handleToggleMod = async (modId: string) => {
await toggleMod(modId); await toggleMod(modId);
const m = await getInstalledMods(version.id); const m = await getInstalledMods(version.id);
@@ -243,7 +234,7 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
</div> </div>
<div className="p-6 space-y-3 max-h-64 overflow-y-auto"> <div className="p-6 space-y-3 max-h-64 overflow-y-auto">
{allVersions.map((v) => { {versions.map((v) => {
const isSupported = const isSupported =
selectedModForInstall.supported_versions.includes(v.id); selectedModForInstall.supported_versions.includes(v.id);
const isSelected = selectedVersionsForInstall.includes(v.id); const isSelected = selectedVersionsForInstall.includes(v.id);
+16 -8
View File
@@ -1,15 +1,19 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Play, Loader2 } from "lucide-react"; import { Play, Loader2 } from "lucide-react";
import { launchGame } from "../services/LauncherService"; // import { launchGame } from "../services/SaturnApi";
import Bg from "@/assets/bg.png"; import Bg from "@/assets/bg.png";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { GameVersion } from "@/services/Types"; import { GameVersion } from "@/lib/types";
import { launch } from "@/lib/launcher";
import { MinecraftAccount } from "@/lib/auth";
import { cn } from "@/lib/utils";
interface PlaySectionProps { interface PlaySectionProps {
version: GameVersion; version: GameVersion | null;
account: MinecraftAccount | null;
} }
const PlaySection: React.FC<PlaySectionProps> = ({ version }) => { const PlaySection: React.FC<PlaySectionProps> = ({ version, account }) => {
const [isLaunching, setIsLaunching] = useState(false); const [isLaunching, setIsLaunching] = useState(false);
const [launchStatus, setLaunchStatus] = useState(""); const [launchStatus, setLaunchStatus] = useState("");
@@ -43,9 +47,13 @@ const PlaySection: React.FC<PlaySectionProps> = ({ version }) => {
<div className="absolute -inset-1 bg-linear-to-r from-blue-600 to-cyan-500 rounded-lg blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div> <div className="absolute -inset-1 bg-linear-to-r from-blue-600 to-cyan-500 rounded-lg blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div>
<button <button
onClick={() => launchGame(version.id)} onClick={() => version && account && launch(version, account)}
disabled={isLaunching} disabled={isLaunching || !version || !account}
className="relative btn-primary px-16 py-4 text-xl flex items-center gap-3 min-w-60 justify-center" className={cn(
"relative btn-primary px-16 py-4 text-xl flex items-center gap-3 min-w-60 justify-center",
(isLaunching || !version || !account) &&
"cursor-not-allowed opacity-70",
)}
> >
{isLaunching ? ( {isLaunching ? (
<> <>
@@ -55,7 +63,7 @@ const PlaySection: React.FC<PlaySectionProps> = ({ version }) => {
) : ( ) : (
<> <>
<Play size={24} fill="currentColor" /> <Play size={24} fill="currentColor" />
<span>LAUNCH {version.id}</span> <span>LAUNCH {version?.id}</span>
</> </>
)} )}
</button> </button>
+3 -4
View File
@@ -7,9 +7,9 @@ import {
Info, Info,
AlertTriangle, AlertTriangle,
} from "lucide-react"; } from "lucide-react";
import { getSettings, updateSettings } from "../services/LauncherService"; import { getSettings, updateSettings } from "../lib/saturn";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { LauncherSettings } from "@/services/Types"; import { LauncherSettings } from "@/lib/types";
const SettingsSection: React.FC = () => { const SettingsSection: React.FC = () => {
const [settings, setSettings] = useState<LauncherSettings | null>(null); const [settings, setSettings] = useState<LauncherSettings | null>(null);
@@ -118,8 +118,7 @@ const SettingsSection: React.FC = () => {
<div className="relative w-full mt-2 h-6"> <div className="relative w-full mt-2 h-6">
{[1024, 4096, 8192, 12288, 16384].map((value) => { {[1024, 4096, 8192, 12288, 16384].map((value) => {
const percent = const percent =
(((value - 1024) / (16384 - 1024)) * 100 - 50) * 0.97 + (((value - 1024) / (16384 - 1024)) * 100 - 50) * 0.97 + 50;
50;
return ( return (
<span <span
+177
View File
@@ -0,0 +1,177 @@
import { useEffect } from "react";
import { User, LogOut } from "lucide-react";
import { cn } from "@/lib/utils";
import { Props } from "./props";
import {
authList,
authRemove,
authCreateLink,
authLogin,
MinecraftAccount,
} from "@/lib/auth";
import { listen } from "@tauri-apps/api/event";
function UserComponent({
username,
uuid,
onClick,
}: {
username: string;
uuid: string;
onClick?: () => void;
}) {
return (
<div
className={cn(
"flex gap-2.5 border-b border-saturn-border p-3 pb-2 cursor-default",
onClick && "cursor-pointer hover:bg-white/5 transition-colors",
)}
onClick={onClick}
>
<img
src={"https://render.crafty.gg/2d/head/" + uuid}
className="w-10 h-10 object-cover shrink-0"
/>
<div className="min-w-0 select-none">
<p className="text-sm font-bold truncate">{username}</p>
<p className="text-xs text-saturn-text-secondary truncate">{uuid}</p>
</div>
</div>
);
}
export default function AccountDropdown({
isAccountDropdownOpen,
setIsAccountDropdownOpen,
accountRef,
activeAccount,
setActiveAccount,
accounts,
setAccounts,
}: Props) {
// Load accounts
useEffect(() => {
loadAccounts();
}, []);
async function loadAccounts() {
const accs = await authList();
setAccounts(accs);
if (accs.length && !activeAccount) {
setActiveAccount(accs[0]); // default
}
}
// Switch account
function switchAccount(acc: MinecraftAccount) {
setActiveAccount(acc);
setIsAccountDropdownOpen(false);
}
// Logout (remove account)
async function logout() {
if (!activeAccount) return;
await authRemove(activeAccount.uuid);
const updated = await authList();
setAccounts(updated);
setActiveAccount(updated[0] || null);
}
// Add account (open Microsoft login)
async function addAccount() {
const url = await authCreateLink();
console.log("Opening auth URL:", url);
listen<string>("auth-code", async (event) => {
const code = event.payload;
await authLogin(code);
await loadAccounts();
});
}
const otherAccounts = accounts.filter((a) => a.uuid !== activeAccount?.uuid);
return (
<div ref={accountRef} className="relative group overflow-visible">
{/* Glow */}
<div className="absolute -inset-1 bg-linear-to-r from-blue-600 to-cyan-500 rounded-lg blur opacity-25 group-hover:opacity-50 transition pointer-events-none duration-700" />
{/* Button */}
<button
className="relative z-10 flex items-center gap-3 pl-20 pr-4 py-2 rounded-lg btn-primary"
onClick={() => setIsAccountDropdownOpen((prev: boolean) => !prev)}
>
<div className="absolute -top-2 left-4 group-hover:scale-110 transition-transform duration-700 w-12 overflow-hidden z-20">
{activeAccount && (
<img
src={`https://render.crafty.gg/3d/bust/${activeAccount.uuid}`}
className="w-full h-full object-cover"
/>
)}
{!activeAccount && (
<img
src={`https://render.crafty.gg/3d/bust/MHF_Steve`}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="leading-tight">
<p className="text-[10px] text-white/80 uppercase tracking-wide">
Playing as
</p>
<p className="text-sm font-bold text-white">
{activeAccount?.username || "No Account"}
</p>
</div>
</button>
{/* Dropdown */}
<div
className={cn(
"absolute top-full right-0 mt-2 w-56 bg-saturn-panel border border-saturn-border rounded-lg shadow-2xl z-50 py-1 overflow-hidden transition-all duration-200 origin-top",
isAccountDropdownOpen
? "opacity-100 scale-100 translate-y-0 pointer-events-auto"
: "opacity-0 scale-95 -translate-y-2 pointer-events-none",
)}
>
{/* Current account */}
{activeAccount && (
<UserComponent
username={activeAccount.username}
uuid={activeAccount.uuid}
/>
)}
{/* Other accounts */}
{otherAccounts.map((acc) => (
<UserComponent
key={acc.uuid}
username={acc.username}
uuid={acc.uuid}
onClick={() => switchAccount(acc)}
/>
))}
{/* Actions */}
<button
onClick={addAccount}
className="w-full flex items-center gap-2 px-4 py-2 text-sm hover:bg-white/5 transition-colors"
>
<User size={14} />
<span>Add Account</span>
</button>
<button
onClick={logout}
className="w-full flex items-center gap-2 px-4 py-2 text-sm hover:bg-red-500/10 text-red-400 transition-colors"
>
<LogOut size={14} />
<span>Logout</span>
</button>
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { Play, Puzzle, Settings } from "lucide-react";
import Logo from "@/assets/logo.png";
import { cn } from "@/lib/utils";
export default function Sidebar({ active, setActive }: any) {
return (
<aside className="w-16 border-r border-saturn-border flex flex-col items-center py-6 gap-6 bg-saturn-panel/50">
<div>
<img src={Logo} alt="Saturn Logo" className="w-10 h-10" />
</div>
<nav className="flex flex-col gap-4 h-full">
<button
onClick={() => setActive("play")}
className={cn("sidebar-icon", active === "play" && "active")}
>
<Play size={22} fill={active === "play" ? "currentColor" : "none"} />
</button>
<button
onClick={() => setActive("mods")}
className={cn("sidebar-icon", active === "mods" && "active")}
>
<Puzzle
size={22}
fill={active === "mods" ? "currentColor" : "none"}
/>
</button>
<button
onClick={() => setActive("settings")}
className={cn("sidebar-icon", active === "settings" && "active")}
>
<Settings
size={22}
fill={active === "settings" ? "currentColor" : "none"}
/>
</button>
</nav>
</aside>
);
}
+13
View File
@@ -0,0 +1,13 @@
import VersionDropdown from "./VersionDropdown";
import AccountDropdown from "./AccountDropdown";
import { Props } from "./props";
export default function TopBar(props: Props) {
return (
<header className="h-14 border-b border-saturn-border flex items-center justify-between px-6 bg-saturn-panel/30 backdrop-blur-md z-100 py-10">
<VersionDropdown {...props} />
<AccountDropdown {...props} />
</header>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { ChevronDown, Check } from "lucide-react";
import { GameVersion } from "@/lib/types";
import { cn } from "@/lib/utils";
import { Props } from "./props";
export default function VersionDropdown({
versions,
selectedVersion,
setSelectedVersion,
isVersionDropdownOpen,
setIsVersionDropdownOpen,
setIsAccountDropdownOpen,
versionRef,
}: Props) {
return (
<div ref={versionRef} className="relative">
<div className="relative group overflow-visible inline-block">
<div className="absolute -inset-1 bg-linear-to-r from-blue-600 to-cyan-500 rounded-md blur opacity-25 group-hover:opacity-50 transition duration-500 pointer-events-none" />
<button
onClick={() => {
setIsVersionDropdownOpen((prev: boolean) => !prev);
setIsAccountDropdownOpen(false);
}}
className="relative z-10 flex items-center gap-2 px-3 py-1.5 rounded-md btn-primary text-sm font-medium"
>
<span>{selectedVersion?.name || "Loading..."}</span>
<ChevronDown
size={14}
className={cn(
"transition-transform",
isVersionDropdownOpen && "rotate-180",
)}
/>
</button>
</div>
<div
className={cn(
"absolute top-full left-0 mt-2 w-48 bg-saturn-panel border border-saturn-border rounded-lg shadow-2xl z-50 py-1 overflow-hidden transition-all duration-200 origin-top",
isVersionDropdownOpen
? "opacity-100 scale-100 translate-y-0 pointer-events-auto"
: "opacity-0 scale-95 -translate-y-2 pointer-events-none",
)}
>
{versions.map((v: GameVersion) => (
<button
key={v.id}
onClick={() => {
setSelectedVersion(v);
setIsVersionDropdownOpen(false);
}}
className="w-full flex items-center justify-between px-4 py-2 text-sm hover:bg-saturn-accent/10 hover:text-saturn-accent transition-colors"
>
<span>{v.name}</span>
{selectedVersion?.id === v.id && <Check size={14} />}
</button>
))}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { MinecraftAccount } from "@/lib/auth";
import { GameVersion } from "@/lib/types";
export type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
export interface Props {
versions: GameVersion[];
selectedVersion: GameVersion | null;
setSelectedVersion: SetState<GameVersion | null>;
isVersionDropdownOpen: boolean;
setIsVersionDropdownOpen: SetState<boolean>;
isAccountDropdownOpen: boolean;
setIsAccountDropdownOpen: SetState<boolean>;
versionRef: React.RefObject<HTMLDivElement | null>;
accountRef: React.RefObject<HTMLDivElement | null>;
accounts: MinecraftAccount[];
setAccounts: SetState<MinecraftAccount[]>;
activeAccount: MinecraftAccount | null;
setActiveAccount: SetState<MinecraftAccount | null>;
}
+60
View File
@@ -0,0 +1,60 @@
import { invoke } from "@tauri-apps/api/core";
export interface MinecraftAccount {
xuid: string;
exp: number;
uuid: string;
username: string;
access_token: string;
refresh_token: string;
client_id: string;
}
/**
* Create Microsoft login URL
*/
export async function authCreateLink(): Promise<string> {
return await invoke("auth_create_link");
}
/**
* Exchange auth code for account
*/
export async function authLogin(code: string): Promise<MinecraftAccount> {
return await invoke("auth_login", { code });
}
/**
* Refresh account by UUID
*/
export async function authRefresh(uuid: string): Promise<MinecraftAccount> {
return await invoke("auth_refresh", { uuid });
}
/**
* Get all stored accounts
*/
export async function authList(): Promise<MinecraftAccount[]> {
return await invoke("auth_list");
}
/**
* Remove account by UUID
*/
export async function authRemove(uuid: string) {
return await invoke("auth_remove", { uuid });
}
/**
* Validate token (not expired)
*/
export async function authValidate(uuid: string): Promise<boolean> {
return await invoke("auth_validate", { uuid });
}
/**
* Get valid account (auto-refresh if needed)
*/
export async function authGetValid(uuid: string): Promise<MinecraftAccount> {
return await invoke("auth_get_valid", { uuid });
}
+28
View File
@@ -0,0 +1,28 @@
import { invoke } from "@tauri-apps/api/core";
import { GameVersion } from "./types";
import { MinecraftAccount } from "./auth";
export const versions: GameVersion[] = [
{
id: "1.21.4",
name: "Fabric 1.21.4",
loader_version: "0.18.6",
},
{
id: "1.21.5",
name: "Fabric 1.21.5",
loader_version: "0.18.6",
},
{
id: "1.21.6",
name: "Fabric 1.21.6",
loader_version: "0.18.6",
},
];
/**
* Launch the game with the selected account and version
*/
export async function launch(version: GameVersion, account: MinecraftAccount) {
return await invoke("launch_game", { version, account });
}
@@ -1,10 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { GameVersion, LauncherSettings, Mod } from "./Types"; import { LauncherSettings, Mod } from "./types";
// --- Versions ---
export async function getVersions(): Promise<GameVersion[]> {
return await invoke("get_versions");
}
// --- Mods --- // --- Mods ---
export async function getInstalledMods(versionId: string): Promise<Mod[]> { export async function getInstalledMods(versionId: string): Promise<Mod[]> {
@@ -70,10 +65,3 @@ export async function updateSettings(
): Promise<void> { ): Promise<void> {
await invoke("update_settings", { newSettings }); await invoke("update_settings", { newSettings });
} }
// --- Launch ---
export async function launchGame(
versionId: string,
): Promise<{ success: boolean; message: string }> {
return await invoke("launch_game", { versionId });
}
+1 -2
View File
@@ -12,8 +12,7 @@ export interface Mod {
export interface GameVersion { export interface GameVersion {
id: string; id: string;
name: string; name: string;
release_date: string; loader_version: string;
type: "release" | "snapshot";
} }
export interface LauncherSettings { export interface LauncherSettings {
+6
View File
@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: any[]) {
return twMerge(clsx(inputs));
}