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"
|
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"] }
|
||||||
|
|||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 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
@@ -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
@@ -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;
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { 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 });
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
@@ -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