Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bf0c09556 | ||
|
|
0a29f10468 | ||
|
|
7e1ea9fcad | ||
|
|
f2f44bcc3d | ||
|
|
0638737562 | ||
|
|
966e0eeb52 | ||
|
|
1ba5d1655b | ||
|
|
f1830f63f5 | ||
|
|
f9752b3ea6 | ||
|
|
955890dabf | ||
|
|
a7842e2c12 | ||
|
|
615ac0c530 | ||
|
|
6d253aaf4f | ||
|
|
ac9108f507 | ||
|
|
19b19ba08e | ||
|
|
2bb686baaf | ||
|
|
38207a1f30 | ||
|
|
43b9d5910e | ||
|
|
fba51d78b8 | ||
|
|
950d7f0a1d | ||
|
|
450ade4b53 | ||
|
|
9769a2d6c1 | ||
|
|
725ac2187f |
@@ -4,6 +4,11 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to upload to (e.g. 0.1.0)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -15,17 +20,17 @@ jobs:
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install dependencies (Linux only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
@@ -42,43 +47,46 @@ jobs:
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm install
|
||||
- run: npm install
|
||||
- run: npm run tauri build
|
||||
|
||||
- name: Build Tauri app
|
||||
run: npm run tauri build
|
||||
# --- macOS fix ---
|
||||
- name: Prepare macOS app
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
APP_PATH=$(find src-tauri/target/release/bundle/macos -name "*.app")
|
||||
APP_NAME=$(basename "$APP_PATH" .app)
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
xattr -cr "$APP_PATH"
|
||||
codesign --deep --force --sign - "$APP_PATH"
|
||||
|
||||
ditto -c -k --keepParent "$APP_PATH" "$APP_NAME-macos.zip"
|
||||
|
||||
# --- Get release Tag ---
|
||||
- name: Get release tag
|
||||
id: get_tag
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
elif [ -z "$TAG" ]; then
|
||||
TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# --- Upload to release ---
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: tauri-build-${{ matrix.os }}
|
||||
path: |
|
||||
src-tauri/target/release/bundle/**/!(node_modules)/
|
||||
tag_name: ${{ steps.get_tag.outputs.tag }}
|
||||
files: |
|
||||
src-tauri/target/release/bundle/**/*.AppImage
|
||||
src-tauri/target/release/bundle/**/*.dmg
|
||||
src-tauri/target/release/bundle/**/*.msi
|
||||
src-tauri/target/release/bundle/**/*.exe
|
||||
src-tauri/target/release/bundle/**/*.deb
|
||||
src-tauri/target/release/bundle/**/*.rpm
|
||||
|
||||
release:
|
||||
name: Upload to GitHub Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Upload to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
files: artifacts/**/*
|
||||
*.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "saturn-launcher",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -3887,7 +3887,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "saturn-launcher"
|
||||
version = "0.1.1"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"lyceris",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "saturn-launcher"
|
||||
version = "0.1.1"
|
||||
version = "0.1.3"
|
||||
description = "A minecraft launcher for Saturn Client"
|
||||
authors = ["SaturnClient"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
use std::{collections::HashMap, fs, path::PathBuf};
|
||||
|
||||
use lyceris::auth::microsoft::MinecraftAccount;
|
||||
use tauri::State;
|
||||
|
||||
use crate::{features::Feature, launcher::launcher_dir, GameVersion, SharedState};
|
||||
|
||||
pub struct InstanceSync {}
|
||||
|
||||
impl Default for InstanceSync {
|
||||
fn default() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Feature for InstanceSync {
|
||||
fn launch(
|
||||
&mut self,
|
||||
state: &State<'_, SharedState>,
|
||||
version: &GameVersion,
|
||||
_account: &MinecraftAccount,
|
||||
) -> super::FeatureResult {
|
||||
if state.lock().unwrap().settings.features.sync_options {
|
||||
sync_to_instance(version).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn after_launch(
|
||||
&mut self,
|
||||
state: &State<'_, SharedState>,
|
||||
version: &GameVersion,
|
||||
_account: &MinecraftAccount,
|
||||
) -> super::FeatureResult {
|
||||
if state.lock().unwrap().settings.features.sync_options {
|
||||
sync_back_from_instance(version).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_to_instance(version: &GameVersion) -> std::io::Result<()> {
|
||||
let global_path = options_path_global();
|
||||
let instance_path = options_path_instance(version);
|
||||
|
||||
if !global_path.exists() {
|
||||
if let Ok(instance_content) = fs::read_to_string(instance_path) {
|
||||
fs::write(global_path, instance_content)?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let global_content = fs::read_to_string(global_path)?;
|
||||
|
||||
// Ensure instance dir exists
|
||||
if let Some(parent) = instance_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
fs::write(instance_path, global_content)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_back_from_instance(version: &GameVersion) -> std::io::Result<()> {
|
||||
let global_path = options_path_global();
|
||||
let instance_path = options_path_instance(version);
|
||||
|
||||
let global_content = fs::read_to_string(&global_path)?;
|
||||
let instance_content = fs::read_to_string(&instance_path)?;
|
||||
|
||||
let mut global_map = parse_options(&global_content);
|
||||
let instance_map = parse_options(&instance_content);
|
||||
|
||||
// Only apply changes (ignore deletions)
|
||||
for (key, instance_value) in instance_map {
|
||||
match global_map.get(&key) {
|
||||
Some(global_value) if global_value == &instance_value => {}
|
||||
_ => {
|
||||
// changed or new → update global
|
||||
global_map.insert(key, instance_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(global_path, serialize_options(&global_map))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn options_path_global() -> PathBuf {
|
||||
launcher_dir().join("options.txt")
|
||||
}
|
||||
|
||||
fn options_path_instance(version: &GameVersion) -> PathBuf {
|
||||
launcher_dir().join(&version.id).join("options.txt")
|
||||
}
|
||||
|
||||
fn parse_options(content: &str) -> HashMap<String, String> {
|
||||
let mut map = HashMap::new();
|
||||
|
||||
for line in content.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
fn serialize_options(map: &HashMap<String, String>) -> String {
|
||||
map.iter()
|
||||
.map(|(k, v)| format!("{k}:{v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use lyceris::auth::microsoft::MinecraftAccount;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{features::instance_sync::InstanceSync, GameVersion, SharedState};
|
||||
|
||||
mod instance_sync;
|
||||
|
||||
pub type FeatureResult = Result<(), String>;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct FeatureSettings {
|
||||
// Instance Sync
|
||||
sync_options: bool,
|
||||
}
|
||||
|
||||
impl Default for FeatureSettings {
|
||||
fn default() -> Self {
|
||||
Self { sync_options: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub trait Feature: Send + Sync {
|
||||
fn launch(
|
||||
&mut self,
|
||||
state: &State<'_, SharedState>,
|
||||
version: &GameVersion,
|
||||
account: &MinecraftAccount,
|
||||
) -> FeatureResult {
|
||||
Ok(())
|
||||
}
|
||||
fn after_launch(
|
||||
&mut self,
|
||||
state: &State<'_, SharedState>,
|
||||
version: &GameVersion,
|
||||
account: &MinecraftAccount,
|
||||
) -> FeatureResult {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FeatureState(Mutex<Vec<Box<dyn Feature>>>);
|
||||
|
||||
impl FeatureState {
|
||||
pub fn new() -> Self {
|
||||
Self(Mutex::new(vec![Box::new(InstanceSync::default())]))
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureState {
|
||||
pub async fn launch(
|
||||
&self,
|
||||
state: &State<'_, SharedState>,
|
||||
version: &GameVersion,
|
||||
account: &MinecraftAccount,
|
||||
) -> FeatureResult {
|
||||
for feature in self.0.lock().await.iter_mut() {
|
||||
feature.launch(state, version, account)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn after_launch(
|
||||
&self,
|
||||
state: &State<'_, SharedState>,
|
||||
version: &GameVersion,
|
||||
account: &MinecraftAccount,
|
||||
) -> FeatureResult {
|
||||
for feature in self.0.lock().await.iter_mut() {
|
||||
feature.after_launch(state, version, account)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ pub fn auth_create_link(app: AppHandle) -> Result<String, String> {
|
||||
// Check for auth code
|
||||
if url_str.contains("code=") {
|
||||
if let Some(code) = extract_code(&url_str) {
|
||||
println!("Auth code received: {}", code);
|
||||
println!("Auth code received: {} ({})", &code[0..5], code.len());
|
||||
|
||||
let _ = app_handle.emit("auth-code", code);
|
||||
|
||||
@@ -125,8 +125,11 @@ pub async fn auth_refresh(uuid: String) -> Result<MinecraftAccount, String> {
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub fn auth_list() -> Result<Vec<MinecraftAccount>, String> {
|
||||
Ok(load_accounts())
|
||||
pub fn auth_list() -> Result<Vec<(String, String)>, String> {
|
||||
Ok(load_accounts()
|
||||
.iter()
|
||||
.map(|account| (account.uuid.clone(), account.username.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[command]
|
||||
|
||||
@@ -16,7 +16,7 @@ use lyceris::{
|
||||
|
||||
use tauri::{Emitter as _, State};
|
||||
|
||||
use crate::{GameVersion, SharedState};
|
||||
use crate::{features::FeatureState, GameVersion, SharedState};
|
||||
|
||||
pub fn launcher_dir() -> PathBuf {
|
||||
if cfg!(target_os = "macos") {
|
||||
@@ -47,6 +47,7 @@ pub fn launcher_dir() -> PathBuf {
|
||||
pub async fn launch_game(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, SharedState>,
|
||||
feature_state: State<'_, FeatureState>,
|
||||
version: GameVersion,
|
||||
account: MinecraftAccount,
|
||||
) -> Result<(), String> {
|
||||
@@ -95,14 +96,14 @@ pub async fn launch_game(
|
||||
launcher_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,
|
||||
username: account.username.clone(),
|
||||
xuid: account.xuid.clone(),
|
||||
uuid: account.uuid.clone(),
|
||||
access_token: account.access_token.clone(),
|
||||
refresh_token: account.refresh_token.clone(),
|
||||
},
|
||||
)
|
||||
.loader(Fabric(version.loader_version).into())
|
||||
.loader(Fabric(version.loader_version.clone()).into())
|
||||
.memory(lyceris::minecraft::config::Memory::Megabyte(
|
||||
state.lock().unwrap().settings.max_memory as u64,
|
||||
))
|
||||
@@ -124,6 +125,11 @@ pub async fn launch_game(
|
||||
|
||||
app.emit("launch-status", "Launching").unwrap();
|
||||
|
||||
feature_state
|
||||
.inner()
|
||||
.launch(&state, &version, &account)
|
||||
.await?;
|
||||
|
||||
launch(&config, Some(&emitter))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -131,6 +137,11 @@ pub async fn launch_game(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
feature_state
|
||||
.inner()
|
||||
.after_launch(&state, &version, &account)
|
||||
.await?;
|
||||
|
||||
app.emit("is-launching", "false").unwrap();
|
||||
app.emit("launch-status", "").unwrap();
|
||||
emit_progress(&app, 0.0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod commands;
|
||||
pub mod features;
|
||||
pub mod launcher;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -8,7 +9,7 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crate::launcher::launcher_dir;
|
||||
use crate::{features::FeatureSettings, launcher::launcher_dir};
|
||||
|
||||
pub type SharedState = Arc<Mutex<AppState>>;
|
||||
|
||||
@@ -35,6 +36,7 @@ pub struct GameVersion {
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct LauncherSettings {
|
||||
pub max_memory: u32,
|
||||
pub features: FeatureSettings,
|
||||
}
|
||||
|
||||
// --- App State ---
|
||||
@@ -47,8 +49,11 @@ pub struct AppState {
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
settings: LauncherSettings { max_memory: 4096 },
|
||||
version: String::from("1.21.6"),
|
||||
settings: LauncherSettings {
|
||||
max_memory: 2048,
|
||||
features: FeatureSettings::default(),
|
||||
},
|
||||
version: String::from("1.21.11"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use saturn_launcher_lib::load_state;
|
||||
use saturn_launcher_lib::{features::FeatureState, load_state};
|
||||
use tauri::{Emitter, Manager, WindowEvent};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -9,6 +9,7 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(load_state())
|
||||
.manage(FeatureState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Saturn Launcher Commands
|
||||
saturn_launcher_lib::commands::get_installed_mods,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "saturn-launcher",
|
||||
"version": "0.1.1",
|
||||
"productName": "Saturn Launcher",
|
||||
"version": "0.1.3",
|
||||
"identifier": "org.saturnclient.saturn-launcher",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Saturn Launcher v0.1.1",
|
||||
"title": "Saturn Launcher v0.1.3",
|
||||
"width": 1000,
|
||||
"height": 650,
|
||||
"backgroundColor": "#0a0a0a",
|
||||
|
||||
+3
-4
@@ -6,7 +6,6 @@ import ModsSection from "./components/ModsSection";
|
||||
import SettingsSection from "./components/SettingsSection";
|
||||
import { versions } from "./lib/launcher";
|
||||
import { GameVersion } from "./lib/types";
|
||||
import { MinecraftAccount } from "./lib/auth";
|
||||
import { getVersion, installPaths, updateVersion } from "./lib/saturn";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
@@ -26,8 +25,8 @@ export default function App() {
|
||||
const versionRef = useRef<HTMLDivElement | null>(null);
|
||||
const accountRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [accounts, setAccounts] = useState<MinecraftAccount[]>([]);
|
||||
const [activeAccount, setActiveAccount] = useState<MinecraftAccount | null>(
|
||||
const [accounts, setAccounts] = useState<[string, string][]>([]);
|
||||
const [activeAccount, setActiveAccount] = useState<[string, string] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -137,7 +136,7 @@ export default function App() {
|
||||
|
||||
<div className="flex-1 p-8 overflow-y-scroll">
|
||||
{active === "play" && (
|
||||
<PlaySection version={selectedVersion} account={activeAccount} />
|
||||
<PlaySection version={selectedVersion} activeAccount={activeAccount} setActiveAccount={setActiveAccount} accounts={accounts} setAccounts={setAccounts} />
|
||||
)}
|
||||
{active === "mods" && selectedVersion && (
|
||||
<ModsSection version={selectedVersion} />
|
||||
|
||||
@@ -22,6 +22,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import { GameVersion, Mod } from "@/lib/types";
|
||||
import { versions } from "@/lib/launcher";
|
||||
import toast from "react-hot-toast";
|
||||
import { useRef } from "react";
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -44,18 +45,21 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
|
||||
string[]
|
||||
>([]);
|
||||
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const loadMods = async () => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
|
||||
if (activeTab === "installed") {
|
||||
const m = await getInstalledMods(version.id);
|
||||
setMods(m);
|
||||
if (activeTab === "installed") {
|
||||
// Fix: If discover mods takes too long and the user switches tabs it glitches
|
||||
|
||||
if (requestId === requestIdRef.current) {
|
||||
setMods(m);
|
||||
}
|
||||
} else {
|
||||
const m = await discoverMods(searchQuery);
|
||||
if (activeTab === "discover") {
|
||||
// Fix: If discover mods takes too long and the user switches tabs it glitches
|
||||
|
||||
if (requestId === requestIdRef.current) {
|
||||
setMods(m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Play, Loader2 } from "lucide-react";
|
||||
// import { launchGame } from "../services/SaturnApi";
|
||||
import Bg from "@/assets/bg.png";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { GameVersion } from "@/lib/types";
|
||||
import { launch } from "@/lib/launcher";
|
||||
import { MinecraftAccount } from "@/lib/auth";
|
||||
import { authGetValid } from "@/lib/auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SetState } from "./layout/props";
|
||||
|
||||
interface PlaySectionProps {
|
||||
version: GameVersion | null;
|
||||
account: MinecraftAccount | null;
|
||||
activeAccount: [string, string] | null;
|
||||
setActiveAccount: SetState<[string, string] | null>,
|
||||
accounts: [string, string][],
|
||||
setAccounts: SetState<[string, string][]>,
|
||||
}
|
||||
|
||||
const PlaySection: React.FC<PlaySectionProps> = ({ version, account }) => {
|
||||
const PlaySection: React.FC<PlaySectionProps> = ({ version, activeAccount: account }) => {
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const [launchStatus, setLaunchStatus] = useState("");
|
||||
const [progress, setProgress] = useState(0);
|
||||
@@ -39,6 +42,13 @@ const PlaySection: React.FC<PlaySectionProps> = ({ version, account }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLaunch = async () => {
|
||||
if (version && account) {
|
||||
const validAccount = await authGetValid(account[0]);
|
||||
launch(version, validAccount);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full relative">
|
||||
{/* Background Image */}
|
||||
@@ -54,12 +64,12 @@ const PlaySection: React.FC<PlaySectionProps> = ({ version, account }) => {
|
||||
<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={() => version && account && launch(version, account)}
|
||||
onClick={handleLaunch}
|
||||
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",
|
||||
"cursor-not-allowed opacity-70",
|
||||
)}
|
||||
>
|
||||
{isLaunching ? (
|
||||
|
||||
@@ -1,9 +1,210 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { MemoryStick, Save, RefreshCcw, Info } from "lucide-react";
|
||||
import { Save, RefreshCcw } from "lucide-react";
|
||||
import { getSettings, updateSettings } from "../lib/saturn";
|
||||
import { motion } from "framer-motion";
|
||||
import { LauncherSettings } from "@/lib/types";
|
||||
|
||||
export type SettingSchemaItem =
|
||||
| SettingSchemaLeaf
|
||||
| SettingSchemaGroup;
|
||||
|
||||
export interface SettingSchemaBase {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SettingSchemaLeaf extends SettingSchemaBase {
|
||||
type: "slider" | "toggle" | "input";
|
||||
|
||||
key: string; // leaf key (resolved in context)
|
||||
default: any;
|
||||
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
format?: (value: any) => string;
|
||||
ticks?: number[];
|
||||
info?: string;
|
||||
}
|
||||
|
||||
export interface SettingSchemaGroup extends SettingSchemaBase {
|
||||
type: "group";
|
||||
children: SettingSchemaItem[];
|
||||
}
|
||||
|
||||
export const settingsSchema: SettingSchemaItem[] = [
|
||||
{
|
||||
type: "group",
|
||||
label: "Performance",
|
||||
|
||||
children: [
|
||||
{
|
||||
key: "max_memory",
|
||||
label: "Maximum Memory (RAM)",
|
||||
type: "slider",
|
||||
min: 1024,
|
||||
max: 16384,
|
||||
step: 512,
|
||||
default: 2048,
|
||||
ticks: [1024, 4096, 8192, 12288, 16384],
|
||||
|
||||
format: (v) => `${(v / 1024).toFixed(1)} GB`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
type: "group",
|
||||
label: "Features",
|
||||
|
||||
children: [
|
||||
{
|
||||
key: "features.sync_options",
|
||||
label: "Sync options.txt",
|
||||
type: "toggle",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getValue = (obj: any, path: string) =>
|
||||
path.split(".").reduce((acc, key) => acc?.[key], obj);
|
||||
|
||||
const setValue = (obj: any, path: string, value: any) => {
|
||||
const keys = path.split(".");
|
||||
const last = keys.pop()!;
|
||||
const clone = structuredClone(obj);
|
||||
|
||||
let current = clone;
|
||||
for (const key of keys) current = current[key];
|
||||
|
||||
current[last] = value;
|
||||
return clone;
|
||||
};
|
||||
|
||||
const SettingItem: React.FC<{
|
||||
item: SettingSchemaItem;
|
||||
settings: LauncherSettings;
|
||||
setSettings: React.Dispatch<
|
||||
React.SetStateAction<LauncherSettings | null>
|
||||
>;
|
||||
}> = ({ item, settings, setSettings }) => {
|
||||
if (item.type === "group") {
|
||||
return (
|
||||
<div className="space-y-4 border-l border-saturn-border pl-4">
|
||||
<div className="text-sm font-semibold text-saturn-text-secondary">
|
||||
{item.label}
|
||||
</div>
|
||||
|
||||
{item.children.map((child, i) => (
|
||||
<SettingItem
|
||||
key={i}
|
||||
item={child}
|
||||
settings={settings}
|
||||
setSettings={setSettings}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const value = getValue(settings, item.key);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{item.type === "slider" && (
|
||||
<div key={item.key}>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-md font-medium">{item.label}</span>
|
||||
<span className="text-md font-medium text-saturn-accent">{(item.format && item.format(value)) || value}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
step={item.step}
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) =>
|
||||
prev
|
||||
? setValue(prev, item.key, parseInt(e.target.value))
|
||||
: prev
|
||||
)
|
||||
}
|
||||
className="w-full h-2 bg-saturn-border rounded-lg appearance-none cursor-pointer accent-saturn-accent"
|
||||
/>
|
||||
{item.ticks && (
|
||||
<div className="relative w-full mt-2 h-6">
|
||||
{item.ticks.map((tick) => {
|
||||
const percent =
|
||||
(((tick - item.min!) / (item.max! - item.min!)) * 100 - 50) *
|
||||
0.97 +
|
||||
50;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={tick}
|
||||
className="absolute text-[10px] text-saturn-text-secondary font-bold uppercase tracking-widest -translate-x-1/2 whitespace-nowrap"
|
||||
style={{ left: `${percent}%` }}
|
||||
>
|
||||
{tick / 1024} GB
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.type === "toggle" && (
|
||||
<div className="flex gap-2 items-center" key={item.key}>
|
||||
<input
|
||||
className="appearance-none w-4 h-4 bg-saturn-panel border border-saturn-border rounded checked:bg-saturn-accent"
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) =>
|
||||
prev ? setValue(prev, item.key, e.target.checked) : prev
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function setNestedValue(obj: any, path: string, value: any) {
|
||||
const keys = path.split(".");
|
||||
let current = obj;
|
||||
|
||||
keys.forEach((key, index) => {
|
||||
if (index === keys.length - 1) {
|
||||
current[key] = value;
|
||||
} else {
|
||||
if (!current[key] || typeof current[key] !== "object") {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const buildDefaultSettings = (schema: SettingSchemaItem[]): any => {
|
||||
const result: any = {};
|
||||
|
||||
for (const item of schema) {
|
||||
if (item.type === "group") {
|
||||
Object.assign(result, buildDefaultSettings(item.children));
|
||||
} else {
|
||||
setNestedValue(result, item.key, item.default);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const SettingsSection: React.FC = () => {
|
||||
const [settings, setSettings] = useState<LauncherSettings | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -32,11 +233,7 @@ const SettingsSection: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
// In a real app, this would reset to defaults in the service
|
||||
const defaultSettings: LauncherSettings = {
|
||||
max_memory: 4096,
|
||||
};
|
||||
setSettings(defaultSettings);
|
||||
setSettings(buildDefaultSettings(settingsSchema) as LauncherSettings);
|
||||
};
|
||||
|
||||
if (!settings) return null;
|
||||
@@ -70,69 +267,7 @@ const SettingsSection: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
{/* Memory Allocation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="panel p-6 space-y-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-saturn-accent/10 flex items-center justify-center text-saturn-accent">
|
||||
<MemoryStick size={18} />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold">Memory Allocation</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">Maximum Memory (RAM)</span>
|
||||
<span className="text-lg font-bold text-saturn-accent">
|
||||
{(settings.max_memory / 1024).toFixed(1)} GB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="1024"
|
||||
max="16384"
|
||||
step="512"
|
||||
value={settings.max_memory}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
max_memory: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full h-2 bg-saturn-border rounded-lg appearance-none cursor-pointer accent-saturn-accent"
|
||||
/>
|
||||
|
||||
<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;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={value}
|
||||
className="absolute text-[10px] text-saturn-text-secondary font-bold uppercase tracking-widest -translate-x-1/2 whitespace-nowrap"
|
||||
style={{ left: `${percent}%` }}
|
||||
>
|
||||
{value / 1024} GB
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-500/5 border border-blue-500/10 rounded-lg flex gap-3">
|
||||
<Info size={16} className="text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-blue-300/80 leading-relaxed">
|
||||
Allocating too much memory can cause system instability, while
|
||||
too little can lead to performance issues or crashes. 4GB - 8GB
|
||||
is recommended for most users.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{settingsSchema.map((item) => (<SettingItem item={item} settings={settings} setSettings={setSettings} />))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
authRemove,
|
||||
authCreateLink,
|
||||
authLogin,
|
||||
MinecraftAccount,
|
||||
} from "@/lib/auth";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
@@ -64,7 +63,7 @@ export default function AccountDropdown({
|
||||
}
|
||||
|
||||
// Switch account
|
||||
function switchAccount(acc: MinecraftAccount) {
|
||||
function switchAccount(acc: [string, string]) {
|
||||
setActiveAccount(acc);
|
||||
setIsAccountDropdownOpen(false);
|
||||
}
|
||||
@@ -73,7 +72,7 @@ export default function AccountDropdown({
|
||||
async function logout() {
|
||||
if (!activeAccount) return;
|
||||
|
||||
await authRemove(activeAccount.uuid);
|
||||
await authRemove(activeAccount[0]);
|
||||
|
||||
const updated = await authList();
|
||||
setAccounts(updated);
|
||||
@@ -91,7 +90,7 @@ export default function AccountDropdown({
|
||||
});
|
||||
}
|
||||
|
||||
const otherAccounts = accounts.filter((a) => a.uuid !== activeAccount?.uuid);
|
||||
const otherAccounts = accounts.filter((a) => a[0] !== activeAccount?.[0]);
|
||||
|
||||
return (
|
||||
<div ref={accountRef} className="relative group overflow-visible">
|
||||
@@ -106,7 +105,7 @@ export default function AccountDropdown({
|
||||
<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}`}
|
||||
src={`https://render.crafty.gg/3d/bust/${activeAccount[0]}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
@@ -123,7 +122,7 @@ export default function AccountDropdown({
|
||||
Playing as
|
||||
</p>
|
||||
<p className="text-sm font-bold text-white">
|
||||
{activeAccount?.username || "No Account"}
|
||||
{activeAccount?.[1] || "No Account"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -140,17 +139,17 @@ export default function AccountDropdown({
|
||||
{/* Current account */}
|
||||
{activeAccount && (
|
||||
<UserComponent
|
||||
username={activeAccount.username}
|
||||
uuid={activeAccount.uuid}
|
||||
username={activeAccount[1]}
|
||||
uuid={activeAccount[0]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Other accounts */}
|
||||
{otherAccounts.map((acc) => (
|
||||
<UserComponent
|
||||
key={acc.uuid}
|
||||
username={acc.username}
|
||||
uuid={acc.uuid}
|
||||
key={acc[0]}
|
||||
username={acc[1]}
|
||||
uuid={acc[0]}
|
||||
onClick={() => switchAccount(acc)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MinecraftAccount } from "@/lib/auth";
|
||||
import { GameVersion } from "@/lib/types";
|
||||
|
||||
export type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
|
||||
@@ -17,9 +16,9 @@ export interface Props {
|
||||
versionRef: React.RefObject<HTMLDivElement | null>;
|
||||
accountRef: React.RefObject<HTMLDivElement | null>;
|
||||
|
||||
accounts: MinecraftAccount[];
|
||||
setAccounts: SetState<MinecraftAccount[]>;
|
||||
accounts: [string, string][];
|
||||
setAccounts: SetState<[string, string][]>;
|
||||
|
||||
activeAccount: MinecraftAccount | null;
|
||||
setActiveAccount: SetState<MinecraftAccount | null>;
|
||||
activeAccount: [string, string] | null;
|
||||
setActiveAccount: SetState<[string, string] | null>;
|
||||
}
|
||||
|
||||
+2
-1
@@ -33,8 +33,9 @@ export async function authRefresh(uuid: string): Promise<MinecraftAccount> {
|
||||
|
||||
/**
|
||||
* Get all stored accounts
|
||||
* Format [UUID, Username]
|
||||
*/
|
||||
export async function authList(): Promise<MinecraftAccount[]> {
|
||||
export async function authList(): Promise<[string, string][]> {
|
||||
return await invoke("auth_list");
|
||||
}
|
||||
|
||||
|
||||
+28
-3
@@ -6,17 +6,42 @@ export const versions: GameVersion[] = [
|
||||
{
|
||||
id: "1.21.4",
|
||||
name: "Fabric 1.21.4",
|
||||
loader_version: "0.18.6",
|
||||
loader_version: "0.19.0",
|
||||
},
|
||||
{
|
||||
id: "1.21.5",
|
||||
name: "Fabric 1.21.5",
|
||||
loader_version: "0.18.6",
|
||||
loader_version: "0.19.0",
|
||||
},
|
||||
{
|
||||
id: "1.21.6",
|
||||
name: "Fabric 1.21.6",
|
||||
loader_version: "0.18.6",
|
||||
loader_version: "0.19.0",
|
||||
},
|
||||
{
|
||||
id: "1.21.7",
|
||||
name: "Fabric 1.21.7",
|
||||
loader_version: "0.19.0",
|
||||
},
|
||||
{
|
||||
id: "1.21.8",
|
||||
name: "Fabric 1.21.8",
|
||||
loader_version: "0.19.0",
|
||||
},
|
||||
{
|
||||
id: "1.21.9",
|
||||
name: "Fabric 1.21.9",
|
||||
loader_version: "0.19.0",
|
||||
},
|
||||
{
|
||||
id: "1.21.10",
|
||||
name: "Fabric 1.21.10",
|
||||
loader_version: "0.19.0",
|
||||
},
|
||||
{
|
||||
id: "1.21.11",
|
||||
name: "Fabric 1.21.11",
|
||||
loader_version: "0.19.0",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -17,4 +17,7 @@ export interface GameVersion {
|
||||
|
||||
export interface LauncherSettings {
|
||||
max_memory: number;
|
||||
features: {
|
||||
sync_options: boolean
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user