Merge pull request #6 from saturnclientmc/working-launcher-auth
Working launcher and auth
This commit is contained in:
Generated
+1344
-36
File diff suppressed because it is too large
Load Diff
@@ -22,4 +22,10 @@ tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
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"] }
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tauri::State;
|
||||
|
||||
use crate::{GameVersion, LauncherSettings, Mod, SharedState};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_versions(state: State<SharedState>) -> Vec<GameVersion> {
|
||||
let state = state.lock().unwrap();
|
||||
state.versions.clone()
|
||||
}
|
||||
use crate::{LauncherSettings, Mod, SharedState};
|
||||
|
||||
#[tauri::command]
|
||||
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();
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -1,4 +1,5 @@
|
||||
pub mod commands;
|
||||
pub mod launcher;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
@@ -20,8 +21,7 @@ pub struct Mod {
|
||||
pub struct GameVersion {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub release_date: String,
|
||||
pub r#type: String, // "release" | "snapshot"
|
||||
pub loader_version: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
@@ -32,7 +32,6 @@ pub struct LauncherSettings {
|
||||
|
||||
// --- App State ---
|
||||
pub struct AppState {
|
||||
pub versions: Vec<GameVersion>,
|
||||
pub mods: Vec<Mod>,
|
||||
pub settings: LauncherSettings,
|
||||
}
|
||||
@@ -40,26 +39,6 @@ pub struct AppState {
|
||||
impl Default for AppState {
|
||||
fn default() -> 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![
|
||||
Mod {
|
||||
id: "sodium".into(),
|
||||
|
||||
+11
-2
@@ -10,13 +10,22 @@ fn main() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(Mutex::new(AppState::default()))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_versions,
|
||||
// Saturn Launcher Commands
|
||||
get_installed_mods,
|
||||
toggle_mod,
|
||||
install_mod,
|
||||
get_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!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+43
-191
@@ -1,51 +1,31 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Play,
|
||||
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 { useEffect, useRef, useState } from "react";
|
||||
import Sidebar from "./components/layout/Sidebar";
|
||||
import TopBar from "./components/layout/TopBar";
|
||||
import PlaySection from "./components/PlaySection";
|
||||
import ModsSection from "./components/ModsSection";
|
||||
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";
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [activeSection, setActiveSection] = useState<Section>("play");
|
||||
const [versions, setVersions] = useState<GameVersion[]>([]);
|
||||
export default function App() {
|
||||
const [active, setActive] = useState<Section>("play");
|
||||
const [selectedVersion, setSelectedVersion] = useState<GameVersion | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [isVersionDropdownOpen, setIsVersionDropdownOpen] = useState(false);
|
||||
const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false);
|
||||
|
||||
const versionRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const accountRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const versionRef = useRef<HTMLDivElement | null>(null);
|
||||
const accountRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVersions = async () => {
|
||||
const v = await getVersions();
|
||||
setVersions(v);
|
||||
if (v.length > 0) setSelectedVersion(v[0]);
|
||||
};
|
||||
fetchVersions();
|
||||
}, []);
|
||||
const [accounts, setAccounts] = useState<MinecraftAccount[]>([]);
|
||||
const [activeAccount, setActiveAccount] = useState<MinecraftAccount | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -63,175 +43,47 @@ const App: React.FC = () => {
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedVersion(versions[0]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen bg-saturn-bg text-saturn-text-primary overflow-hidden font-sans">
|
||||
{/* Sidebar */}
|
||||
<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>
|
||||
<div className="flex h-screen">
|
||||
<Sidebar active={active} setActive={setActive} />
|
||||
|
||||
<nav className="flex flex-col gap-4 h-full">
|
||||
<button
|
||||
onClick={() => setActiveSection("play")}
|
||||
className={cn("sidebar-icon", activeSection === "play" && "active")}
|
||||
title="Play"
|
||||
>
|
||||
<Play
|
||||
size={22}
|
||||
fill={activeSection === "play" ? "currentColor" : "none"}
|
||||
<main className="flex-1 flex flex-col">
|
||||
<TopBar
|
||||
versions={versions}
|
||||
selectedVersion={selectedVersion}
|
||||
setSelectedVersion={setSelectedVersion}
|
||||
isVersionDropdownOpen={isVersionDropdownOpen}
|
||||
setIsVersionDropdownOpen={setIsVersionDropdownOpen}
|
||||
isAccountDropdownOpen={isAccountDropdownOpen}
|
||||
setIsAccountDropdownOpen={setIsAccountDropdownOpen}
|
||||
versionRef={versionRef}
|
||||
accountRef={accountRef}
|
||||
accounts={accounts}
|
||||
setAccounts={setAccounts}
|
||||
activeAccount={activeAccount}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveSection("mods")}
|
||||
className={cn("sidebar-icon", activeSection === "mods" && "active")}
|
||||
title="Mods"
|
||||
>
|
||||
<Puzzle
|
||||
size={22}
|
||||
fill={activeSection === "mods" ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveSection("settings")}
|
||||
className={cn(
|
||||
"sidebar-icon",
|
||||
activeSection === "settings" && "active",
|
||||
<div className="flex-1 p-8 overflow-y-scroll">
|
||||
{active === "play" && (
|
||||
<PlaySection version={selectedVersion} account={activeAccount} />
|
||||
)}
|
||||
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} />
|
||||
)}
|
||||
{activeSection === "settings" && <SettingsSection />}
|
||||
{active === "settings" && <SettingsSection />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
}
|
||||
|
||||
@@ -11,14 +11,14 @@ import {
|
||||
import {
|
||||
getInstalledMods,
|
||||
discoverMods,
|
||||
getVersions,
|
||||
toggleMod,
|
||||
installMod,
|
||||
} from "../services/LauncherService";
|
||||
} from "@/lib/saturn";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
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[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -40,7 +40,6 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
|
||||
const [selectedVersionsForInstall, setSelectedVersionsForInstall] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [allVersions, setAllVersions] = useState<GameVersion[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMods = async () => {
|
||||
@@ -55,14 +54,6 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
|
||||
loadMods();
|
||||
}, [activeTab, version.id, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAllVersions = async () => {
|
||||
const v = await getVersions();
|
||||
setAllVersions(v);
|
||||
};
|
||||
loadAllVersions();
|
||||
}, []);
|
||||
|
||||
const handleToggleMod = async (modId: string) => {
|
||||
await toggleMod(modId);
|
||||
const m = await getInstalledMods(version.id);
|
||||
@@ -243,7 +234,7 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-3 max-h-64 overflow-y-auto">
|
||||
{allVersions.map((v) => {
|
||||
{versions.map((v) => {
|
||||
const isSupported =
|
||||
selectedModForInstall.supported_versions.includes(v.id);
|
||||
const isSelected = selectedVersionsForInstall.includes(v.id);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Play, Loader2 } from "lucide-react";
|
||||
import { launchGame } from "../services/LauncherService";
|
||||
// import { launchGame } from "../services/SaturnApi";
|
||||
import Bg from "@/assets/bg.png";
|
||||
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 {
|
||||
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 [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>
|
||||
|
||||
<button
|
||||
onClick={() => launchGame(version.id)}
|
||||
disabled={isLaunching}
|
||||
className="relative btn-primary px-16 py-4 text-xl flex items-center gap-3 min-w-60 justify-center"
|
||||
onClick={() => version && account && launch(version, account)}
|
||||
disabled={isLaunching || !version || !account}
|
||||
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 ? (
|
||||
<>
|
||||
@@ -55,7 +63,7 @@ const PlaySection: React.FC<PlaySectionProps> = ({ version }) => {
|
||||
) : (
|
||||
<>
|
||||
<Play size={24} fill="currentColor" />
|
||||
<span>LAUNCH {version.id}</span>
|
||||
<span>LAUNCH {version?.id}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
Info,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { getSettings, updateSettings } from "../services/LauncherService";
|
||||
import { getSettings, updateSettings } from "../lib/saturn";
|
||||
import { motion } from "framer-motion";
|
||||
import { LauncherSettings } from "@/services/Types";
|
||||
import { LauncherSettings } from "@/lib/types";
|
||||
|
||||
const SettingsSection: React.FC = () => {
|
||||
const [settings, setSettings] = useState<LauncherSettings | null>(null);
|
||||
@@ -118,8 +118,7 @@ const SettingsSection: React.FC = () => {
|
||||
<div className="relative w-full mt-2 h-6">
|
||||
{[1024, 4096, 8192, 12288, 16384].map((value) => {
|
||||
const percent =
|
||||
(((value - 1024) / (16384 - 1024)) * 100 - 50) * 0.97 +
|
||||
50;
|
||||
(((value - 1024) / (16384 - 1024)) * 100 - 50) * 0.97 + 50;
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 { GameVersion, LauncherSettings, Mod } from "./Types";
|
||||
|
||||
// --- Versions ---
|
||||
export async function getVersions(): Promise<GameVersion[]> {
|
||||
return await invoke("get_versions");
|
||||
}
|
||||
import { LauncherSettings, Mod } from "./types";
|
||||
|
||||
// --- Mods ---
|
||||
export async function getInstalledMods(versionId: string): Promise<Mod[]> {
|
||||
@@ -70,10 +65,3 @@ export async function updateSettings(
|
||||
): Promise<void> {
|
||||
await invoke("update_settings", { newSettings });
|
||||
}
|
||||
|
||||
// --- Launch ---
|
||||
export async function launchGame(
|
||||
versionId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return await invoke("launch_game", { versionId });
|
||||
}
|
||||
@@ -12,8 +12,7 @@ export interface Mod {
|
||||
export interface GameVersion {
|
||||
id: string;
|
||||
name: string;
|
||||
release_date: string;
|
||||
type: "release" | "snapshot";
|
||||
loader_version: string;
|
||||
}
|
||||
|
||||
export interface LauncherSettings {
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: any[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user