38 Commits
Author SHA1 Message Date
selimaj-dev 6bf0c09556 Workflow: fix 2026-04-08 02:24:27 +02:00
selimaj-dev 0a29f10468 Workflow: Fix windows get release tag 2026-04-08 02:20:46 +02:00
selimaj-dev 7e1ea9fcad Workflow: remove dmg from mac 2026-04-08 02:15:56 +02:00
selimaj-dev f2f44bcc3d Workflow run on dispatch 2026-04-08 02:07:21 +02:00
selimaj-dev 0638737562 Workflow fix 2026-04-08 02:05:19 +02:00
selimaj-dev 966e0eeb52 Workflow fix: Mac zip file and publish directly 2026-04-08 02:00:44 +02:00
Kr4ight 1ba5d1655b Add macOS app preparation and signing steps
Added steps to prepare and sign the macOS app, and zip it for distribution.
2026-04-08 01:25:58 +02:00
selimaj-dev f1830f63f5 Merge pull request #17 from saturnclientmc/added-versions
Tauri Release Build / Build (ubuntu-latest) (release) Failing after 1m53s
Tauri Release Build / Build (macos-latest) (release) Canceled after 0s
Tauri Release Build / Build (windows-latest) (release) Canceled after 0s
Tauri Release Build / Upload to GitHub Release (release) Canceled after 0s
Added versions
2026-04-07 22:17:25 +02:00
Kr4ight f9752b3ea6 Update: version to 0.1.3 2026-04-07 22:11:59 +02:00
Kr4ight 955890dabf Fix: Mods tab 2026-04-07 22:10:57 +02:00
Kr4ight a7842e2c12 Added 1.21.7-1.21.11 2026-04-07 22:07:09 +02:00
Kr4ight 615ac0c530 Fixed reset
Tauri Release Build / Build (ubuntu-latest) (release) Failing after 34s
Tauri Release Build / Build (macos-latest) (release) Canceled after 0s
Tauri Release Build / Build (windows-latest) (release) Canceled after 0s
Tauri Release Build / Upload to GitHub Release (release) Canceled after 0s
2026-04-06 22:19:09 +02:00
Kr4ight 6d253aaf4f Merge pull request #16 from saturnclientmc/refactor
Refactor
2026-04-06 22:12:36 +02:00
Kr4ight ac9108f507 Small changes 2026-04-06 22:11:24 +02:00
Kr4ight 19b19ba08e Improved slider 2026-04-06 22:09:20 +02:00
Kr4ight 2bb686baaf Implemented optional sync 2026-04-06 22:05:27 +02:00
Kr4ight 38207a1f30 Improved settings 2026-04-06 21:52:33 +02:00
Kr4ight 43b9d5910e Updated schema settings 2026-04-06 21:31:09 +02:00
Kr4ight fba51d78b8 Working Instance Sync 2026-04-06 21:13:02 +02:00
Kr4ight 950d7f0a1d Obfuscated auth code log for security 2026-04-06 20:39:59 +02:00
Kr4ight 450ade4b53 Fix: auth not refreshing on launch #15 2026-04-06 20:36:41 +02:00
Kr4ight 9769a2d6c1 Implemented state 2026-04-06 20:02:56 +02:00
Kr4ight 725ac2187f Implementing features 2026-04-06 19:50:56 +02:00
Kr4ight 1436950b90 Merge pull request #13 from saturnclientmc/patches-and-impl
Tauri Release Build / Build (ubuntu-latest) (release) Failing after 33s
Tauri Release Build / Build (macos-latest) (release) Canceled after 0s
Tauri Release Build / Build (windows-latest) (release) Canceled after 0s
Tauri Release Build / Upload to GitHub Release (release) Canceled after 0s
Patches and impl
2026-04-05 19:40:27 +02:00
Kr4ight 7e9b663705 Logo with no bg 2026-04-05 19:30:39 +02:00
Kr4ight a134d9b67e Updated icons 2026-04-05 19:27:51 +02:00
Kr4ight 3f2054c721 Changed logo and readme 2026-04-05 19:25:58 +02:00
Kr4ight f33aadb3e0 Refactored directory system 2026-04-05 19:01:46 +02:00
Kr4ight 807bbbc7e2 slight change in settings 2026-04-05 18:50:19 +02:00
Kr4ight 03815cce1e Removed v from version in mods section 2026-04-05 18:42:03 +02:00
Kr4ight 443a22bbed Updated version 2026-04-05 18:41:33 +02:00
Kr4ight 262bb89899 Improved mod install verbosity 2026-04-05 18:36:09 +02:00
Kr4ight d77e0c8924 Fix: remove disabled mod 2026-04-05 18:31:24 +02:00
Kr4ight 06f53935a0 Working drag and drop 2026-04-05 18:26:17 +02:00
Kr4ight e71617d535 Drag and drop UI 2026-04-05 18:17:49 +02:00
Kr4ight d71c0c437d Refactoring Install paths for a frontend experience 2026-04-05 17:37:21 +02:00
Kr4ight 7167211f06 Remove mod #11 2026-04-05 17:16:09 +02:00
Kr4ight 167bb33144 Drag and drop to install #12 2026-04-05 17:05:51 +02:00
80 changed files with 942 additions and 265 deletions
+45 -37
View File
@@ -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 }}
+24 -4
View File
@@ -1,7 +1,27 @@
# Tauri + React + Typescript
<p align="center">
<img src="src/assets/logo.svg" alt="Saturn Launcher Logo" width="150px"/>
</p>
This template should help get you started developing with Tauri, React and Typescript in Vite.
<h1 align="center">Saturn Launcher</h1>
## Recommended IDE Setup
<p align="center">
A fast, modern, and customizable launcher built for performance and flexibility, built with Rust.
</p>
---
## ✨ Features
- ⚡ **High Performance** – Optimized for speed and responsiveness
- 🎨 **Modern UI** – Clean, minimal, and aesthetic interface
- 🧩 **Modular Architecture** – Easily extend and maintain mods
- 📦 **Efficient File Handling** – Smart installation and management system
---
## 🧱 Tech Stack
- **Frontend:** React / TypeScript / TailwindCSS
- **Backend:** Rust (Tauri)
- **Build Tooling:** Vite
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+27 -1
View File
@@ -16,6 +16,7 @@
"lucide-react": "^1.7.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hot-toast": "^2.6.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2"
},
@@ -1776,7 +1777,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -1945,6 +1945,15 @@
"node": ">=6.9.0"
}
},
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2384,6 +2393,23 @@
"react": "^19.2.4"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "saturn-launcher",
"private": true,
"version": "0.1.0",
"version": "0.1.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -18,6 +18,7 @@
"lucide-react": "^1.7.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hot-toast": "^2.6.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2"
},
+1 -12
View File
@@ -874,15 +874,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "directories"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -3896,12 +3887,10 @@ dependencies = [
[[package]]
name = "saturn-launcher"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"directories",
"futures",
"lyceris",
"once_cell",
"rayon",
"reqwest 0.12.28",
"serde",
+3 -5
View File
@@ -1,8 +1,8 @@
[package]
name = "saturn-launcher"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
version = "0.1.3"
description = "A minecraft launcher for Saturn Client"
authors = ["SaturnClient"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -22,8 +22,6 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
directories = "6.0"
once_cell = "1.21"
lyceris = "1.1.3"
rayon = "1.10.0"
reqwest = "0.12.9"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+62 -9
View File
@@ -1,10 +1,10 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use tauri::State;
use tauri::{AppHandle, Emitter, State};
use tokio::fs;
use crate::{launcher::LAUNCHER_DIR, save_state, LauncherSettings, Mod, SharedState};
use crate::{launcher::launcher_dir, save_state, LauncherSettings, Mod, SharedState};
#[derive(Deserialize)]
struct ModrinthVersion {
@@ -25,12 +25,12 @@ struct ModrinthFile {
pub async fn get_installed_mods(version_id: String) -> Result<Vec<Mod>, String> {
let mut mods = Vec::new();
let base_dir = LAUNCHER_DIR.data_dir().join(&version_id).join("mods");
let base_dir = launcher_dir().join(&version_id).join("mods");
let enabled_dir = base_dir.clone();
let disabled_dir = base_dir.join("disabled_mods");
let cache_dir = LAUNCHER_DIR.data_dir().join("cache");
let cache_dir = launcher_dir().join("cache");
// process a directory async
async fn process_dir(dir: PathBuf, enabled: bool, cache_dir: PathBuf) -> Vec<Mod> {
@@ -105,10 +105,32 @@ fn fallback_mod(file_name: &str, enabled: bool) -> Mod {
}
}
#[tauri::command]
pub async fn remove_mod(version: String, file_name: String) -> Result<(), String> {
// mods folder
let mods_dir: PathBuf = launcher_dir().join(&version).join("mods");
// File to remove
let mut source = mods_dir.join(&file_name);
if !source.exists() {
source = mods_dir.join("disabled_mods").join(&file_name);
}
if !source.exists() {
return Err(format!("Mod file not found: {}", file_name));
}
// move file
fs::remove_file(&source).await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn disable_mod(version: String, file_name: String) -> Result<(), String> {
// mods folder
let mods_dir: PathBuf = LAUNCHER_DIR.data_dir().join(&version).join("mods");
let mods_dir: PathBuf = launcher_dir().join(&version).join("mods");
// disabled_mods folder
let disabled_dir = mods_dir.join("disabled_mods");
@@ -135,7 +157,7 @@ pub async fn disable_mod(version: String, file_name: String) -> Result<(), Strin
#[tauri::command]
pub async fn enable_mod(version: String, file_name: String) -> Result<(), String> {
let mods_dir = LAUNCHER_DIR.data_dir().join(&version).join("mods");
let mods_dir = launcher_dir().join(&version).join("mods");
let disabled_dir = mods_dir.join("disabled_mods");
@@ -159,7 +181,7 @@ pub async fn install_mod(mut mod_meta: Mod, versions: Vec<String>) -> Result<(),
println!("Installing {} for {:?}", mod_meta.name, versions);
let cache_dir = LAUNCHER_DIR.data_dir().join("cache");
let cache_dir = launcher_dir().join("cache");
fs::create_dir_all(&cache_dir)
.await
.map_err(|e| e.to_string())?;
@@ -206,7 +228,7 @@ pub async fn install_mod(mut mod_meta: Mod, versions: Vec<String>) -> Result<(),
.await
.map_err(|e| e.to_string())?;
let dir: PathBuf = LAUNCHER_DIR.data_dir().join(&mc_version).join("mods");
let dir: PathBuf = launcher_dir().join(&mc_version).join("mods");
fs::create_dir_all(&dir).await.map_err(|e| e.to_string())?;
@@ -269,3 +291,34 @@ pub fn update_version(state: State<SharedState>, version: String) {
save_state(&state);
}
#[tauri::command]
pub async fn install_paths(
app: AppHandle,
state: State<'_, SharedState>,
paths: Vec<String>,
) -> Result<(), String> {
for path in &paths {
let file = Path::new(&path)
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| String::from("File name not found"))?;
let version_dir = launcher_dir().join(&state.lock().unwrap().version);
let mc_child = if path.ends_with(".jar") {
"mods"
} else {
return Err(String::from("Invalid file type"));
};
fs::copy(path, version_dir.join(mc_child).join(file))
.await
.map_err(|e| e.to_string())?;
app.emit("path-installed", path.to_string())
.map_err(|e| e.to_string())?;
}
Ok(())
}
+123
View File
@@ -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")
}
+78
View File
@@ -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(())
}
}
+26 -22
View File
@@ -5,10 +5,10 @@ use tauri::{command, AppHandle, Emitter, Manager, Url};
use lyceris::auth::microsoft::{authenticate, create_link, refresh, validate, MinecraftAccount};
use crate::launcher::LAUNCHER_DIR;
use crate::launcher::launcher_dir;
fn accounts_path() -> PathBuf {
LAUNCHER_DIR.data_dir().join("accounts.json")
launcher_dir().join("accounts.json")
}
fn load_accounts() -> Vec<MinecraftAccount> {
@@ -52,30 +52,31 @@ pub fn auth_create_link(app: AppHandle) -> Result<String, String> {
std::thread::spawn(move || {
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();
.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);
// Check for auth code
if url_str.contains("code=") {
if let Some(code) = extract_code(&url_str) {
println!("Auth code received: {} ({})", &code[0..5], code.len());
let _ = app_handle.emit("auth-code", code);
let _ = app_handle.emit("auth-code", code);
if let Some(win) = app_handle.get_webview_window("auth") {
let _ = win.close();
if let Some(win) = app_handle.get_webview_window("auth") {
let _ = win.close();
}
}
}
}
// Always allow navigation
true
})
.build()
.map_err(|e| e.to_string()).unwrap();
// Always allow navigation
true
})
.build()
.map_err(|e| e.to_string())
.unwrap();
});
Ok(link)
@@ -124,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]
+46 -16
View File
@@ -2,7 +2,8 @@ pub mod auth;
pub mod downloader;
pub mod install;
use directories::ProjectDirs;
use std::{env, path::PathBuf};
use lyceris::{
auth::microsoft::MinecraftAccount,
minecraft::{
@@ -12,20 +13,41 @@ use lyceris::{
loader::fabric::Fabric,
},
};
use once_cell::sync::Lazy;
use tauri::{Emitter as _, State};
use crate::{GameVersion, SharedState};
use crate::{features::FeatureState, GameVersion, SharedState};
pub static LAUNCHER_DIR: Lazy<ProjectDirs> = Lazy::new(|| {
ProjectDirs::from("org", "SaturnLauncher", "SaturnLauncher")
.expect("Failed to create project directories")
});
pub fn launcher_dir() -> PathBuf {
if cfg!(target_os = "macos") {
let home = env::var("HOME")
.map(PathBuf::from)
.expect("HOME environment variable not set");
home.join("Library/Application Support/SaturnLauncher")
} else if cfg!(target_os = "windows") {
env::var("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join("SaturnLauncher")
} else {
env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join(".local/share")
})
.join("SaturnLauncher")
}
}
#[tauri::command]
pub async fn launch_game(
app: tauri::AppHandle,
state: State<'_, SharedState>,
feature_state: State<'_, FeatureState>,
version: GameVersion,
account: MinecraftAccount,
) -> Result<(), String> {
@@ -70,20 +92,18 @@ pub async fn launch_game(
})
.await;
let current_dir = LAUNCHER_DIR.data_dir();
let config = ConfigBuilder::new(
current_dir.join(&version.id),
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,
))
@@ -105,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())?
@@ -112,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);
+17 -8
View File
@@ -1,12 +1,17 @@
pub mod commands;
pub mod features;
pub mod launcher;
use serde::{Deserialize, Serialize};
use std::{fs, path::PathBuf, sync::Mutex};
use std::{
fs,
path::PathBuf,
sync::{Arc, Mutex},
};
use crate::launcher::LAUNCHER_DIR;
use crate::{features::FeatureSettings, launcher::launcher_dir};
type SharedState = Mutex<AppState>;
pub type SharedState = Arc<Mutex<AppState>>;
// --- Types ---
#[derive(Clone, Serialize, Deserialize)]
@@ -31,6 +36,7 @@ pub struct GameVersion {
#[derive(Clone, Serialize, Deserialize)]
pub struct LauncherSettings {
pub max_memory: u32,
pub features: FeatureSettings,
}
// --- App State ---
@@ -43,15 +49,18 @@ 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"),
}
}
}
// Helper to get config path
fn config_path() -> PathBuf {
LAUNCHER_DIR.config_dir().join("launcher.json")
launcher_dir().join("launcher.json")
}
// --- Load ---
@@ -61,7 +70,7 @@ pub fn load_state() -> SharedState {
if path.exists() {
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<AppState>(&content) {
Ok(state) => return Mutex::new(state),
Ok(state) => return Arc::new(Mutex::new(state)),
Err(err) => {
eprintln!("Failed to parse config, using default: {err}");
}
@@ -73,7 +82,7 @@ pub fn load_state() -> SharedState {
}
// If missing or failed → create default
let default = Mutex::new(AppState::default());
let default = Arc::new(Mutex::new(AppState::default()));
save_state(&default);
default
+46 -2
View File
@@ -1,23 +1,27 @@
// 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)]
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,
saturn_launcher_lib::commands::install_mod,
saturn_launcher_lib::commands::enable_mod,
saturn_launcher_lib::commands::disable_mod,
saturn_launcher_lib::commands::install_mod,
saturn_launcher_lib::commands::remove_mod,
saturn_launcher_lib::commands::get_settings,
saturn_launcher_lib::commands::update_settings,
saturn_launcher_lib::commands::get_version,
saturn_launcher_lib::commands::update_version,
saturn_launcher_lib::commands::install_paths,
// Launcher Commands
saturn_launcher_lib::launcher::launch_game,
// Auth Commands
@@ -29,6 +33,46 @@ fn main() {
saturn_launcher_lib::launcher::auth::auth_validate,
saturn_launcher_lib::launcher::auth::auth_get_valid
])
.setup(|app| {
match app.get_webview_window("main") {
Some(window) => {
let handle = app.handle().clone();
window.on_window_event(move |event| {
if let WindowEvent::DragDrop(file_event) = event {
if let tauri::DragDropEvent::Drop { paths, .. } = file_event {
println!("Installing files: {:?}", paths);
if handle
.emit(
"install-paths",
paths
.iter()
.filter_map(|p| {
if p.is_file() {
Some(p.to_str()?.to_string())
} else {
None
}
})
.collect::<Vec<_>>(),
)
.is_err()
{
println!("Failed emitting files to frontend");
}
}
}
});
}
None => {
println!("WARN: Drag and drop feature will not work");
}
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6 -5
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "saturn-launcher",
"version": "0.1.0",
"productName": "Saturn Launcher",
"version": "0.1.3",
"identifier": "org.saturnclient.saturn-launcher",
"build": {
"beforeDevCommand": "npm run dev",
@@ -12,11 +12,12 @@
"app": {
"windows": [
{
"title": "saturn-launcher",
"title": "Saturn Launcher v0.1.3",
"width": 1000,
"height": 650,
"backgroundColor": "#0a0a0a",
"visible": false
"visible": false,
"dragDropEnabled": true
}
],
"security": {
@@ -34,4 +35,4 @@
"icons/icon.ico"
]
}
}
}
+79 -33
View File
@@ -6,9 +6,10 @@ 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, updateVersion } from "./lib/saturn";
import { getVersion, installPaths, updateVersion } from "./lib/saturn";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { listen } from "@tauri-apps/api/event";
import toast, { Toaster } from "react-hot-toast";
type Section = "play" | "mods" | "settings";
@@ -24,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,
);
@@ -68,37 +69,82 @@ export default function App() {
window.show();
}, []);
useEffect(() => {
if (!selectedVersion?.id) return;
const version = selectedVersion?.id;
const unlistenInstalls = listen<string[]>("install-paths", (event) => {
event.payload.forEach((path) => {
const fileName = path.split(/[\\/]/).pop();
toast.promise(installPaths(version, [path]), {
loading: `${fileName}`,
success: `${fileName}`,
error: (e) => `${fileName} Failed: ${e}`
}).catch(console.error);
});
});
return () => {
unlistenInstalls.then((f) => f());
};
}, [selectedVersion]);
return (
<div className="flex h-screen">
<Sidebar active={active} setActive={setActive} />
<>
<Toaster position="bottom-right" toastOptions={{
loading: {
style: {
backgroundColor: 'var(--color-saturn-panel)',
color: 'var(--color-saturn-text-primary)'
}
},
success: {
style: {
backgroundColor: 'var(--color-saturn-panel)',
color: 'var(--color-saturn-text-primary)'
}
},
error: {
style: {
backgroundColor: 'var(--color-saturn-panel)',
color: 'var(--color-saturn-text-primary)'
}
}
}} />
<main className="flex-1 flex flex-col">
<TopBar
versions={versions}
selectedVersion={selectedVersion}
setSelectedVersion={setSelectedVersion}
isVersionDropdownOpen={isVersionDropdownOpen}
setIsVersionDropdownOpen={setIsVersionDropdownOpen}
isAccountDropdownOpen={isAccountDropdownOpen}
setIsAccountDropdownOpen={setIsAccountDropdownOpen}
versionRef={versionRef}
accountRef={accountRef}
accounts={accounts}
setAccounts={setAccounts}
activeAccount={activeAccount}
setActiveAccount={setActiveAccount}
/>
<div className="flex h-screen">
<Sidebar active={active} setActive={setActive} />
<div className="flex-1 p-8 overflow-y-scroll">
{active === "play" && (
<PlaySection version={selectedVersion} account={activeAccount} />
)}
{active === "mods" && selectedVersion && (
<ModsSection version={selectedVersion} />
)}
{active === "settings" && <SettingsSection />}
</div>
</main>
</div>
<main className="flex-1 flex flex-col">
<TopBar
versions={versions}
selectedVersion={selectedVersion}
setSelectedVersion={setSelectedVersion}
isVersionDropdownOpen={isVersionDropdownOpen}
setIsVersionDropdownOpen={setIsVersionDropdownOpen}
isAccountDropdownOpen={isAccountDropdownOpen}
setIsAccountDropdownOpen={setIsAccountDropdownOpen}
versionRef={versionRef}
accountRef={accountRef}
accounts={accounts}
setAccounts={setAccounts}
activeAccount={activeAccount}
setActiveAccount={setActiveAccount}
/>
<div className="flex-1 p-8 overflow-y-scroll">
{active === "play" && (
<PlaySection version={selectedVersion} activeAccount={activeAccount} setActiveAccount={setActiveAccount} accounts={accounts} setAccounts={setAccounts} />
)}
{active === "mods" && selectedVersion && (
<ModsSection version={selectedVersion} />
)}
{active === "settings" && <SettingsSection />}
</div>
</main>
</div>
</>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 612 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+31 -11
View File
@@ -14,12 +14,15 @@ import {
enableMod,
installMod,
disableMod,
removeMod,
} from "@/lib/saturn";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
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));
@@ -42,14 +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 (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);
}
}
@@ -72,6 +82,11 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
setMods(m);
};
const handleRemove = async (modId: string) => {
await removeMod(version.id, modId);
loadMods();
}
const openInstallDialog = (mod: Mod) => {
setSelectedModForInstall(mod);
setSelectedVersionsForInstall([version.id]);
@@ -80,12 +95,17 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
const handleInstall = async () => {
if (selectedModForInstall && selectedVersionsForInstall.length > 0) {
await installMod(selectedModForInstall, selectedVersionsForInstall);
setIsInstallDialogOpen(false);
if (activeTab === "installed") {
const m = await getInstalledMods(version.id);
setMods(m);
}
toast.promise(installMod(selectedModForInstall, selectedVersionsForInstall), {
loading: `Installing mod: ${selectedModForInstall.name}`,
success: `Mod sucessfully installed: ${selectedModForInstall.name}`,
error: (e) => `${selectedModForInstall.name} Failed: ${e}`
}).then(async () => {
if (activeTab === "installed") {
const m = await getInstalledMods(version.id);
setMods(m);
}
}).catch(console.error);
}
};
@@ -170,7 +190,7 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
<div className="flex items-center gap-2">
<h3 className="font-bold text-sm truncate">{mod.name}</h3>
<span className="text-[10px] text-saturn-text-secondary font-medium px-1.5 py-0.5 rounded bg-white/5">
v{mod.version}
{mod.version}
</span>
<span className="text-[10px] text-saturn-text-secondary">
by {mod.author}
@@ -200,7 +220,7 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
)}
{mod.enabled ? "Enabled" : "Disabled"}
</button>
<button className="p-2 text-saturn-text-secondary hover:text-red-400 transition-colors">
<button className="p-2 text-saturn-text-secondary hover:text-red-400 transition-colors" onClick={() => handleRemove(mod.id)}>
<Trash2 size={16} />
</button>
</>
@@ -271,7 +291,7 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
? "bg-saturn-accent/10 border-saturn-accent/50"
: "bg-white/5 border-transparent hover:border-white/10",
!isSupported &&
"opacity-40 cursor-not-allowed grayscale",
"opacity-40 cursor-not-allowed grayscale",
)}
>
<div className="flex items-center gap-3">
+16 -6
View File
@@ -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 ? (
+207 -72
View File
@@ -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;
@@ -45,9 +242,9 @@ const SettingsSection: React.FC = () => {
<div className="max-w-4xl mx-auto h-full flex flex-col gap-8">
<div className="flex items-center justify-between border-b border-saturn-border pb-6">
<div>
<h1 className="text-2xl font-bold">Performance Settings</h1>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-sm text-saturn-text-secondary">
Configure how Saturn Client interacts with your hardware.
Configure how Saturn Launcher launches your game
</p>
</div>
<div className="flex items-center gap-3">
@@ -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>
);
+10 -11
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
import { Play, Puzzle, Settings } from "lucide-react";
import Logo from "@/assets/logo.png";
import Logo from "@/assets/logo_no_bg.png";
import { cn } from "@/lib/utils";
export default function Sidebar({ active, setActive }: any) {
+4 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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",
},
];
+11
View File
@@ -17,6 +17,13 @@ export async function disableMod(
await invoke("disable_mod", { version, fileName: modId });
}
export async function removeMod(
version: string,
modId: string,
): Promise<void> {
await invoke("remove_mod", { version, fileName: modId });
}
export async function discoverMods(query: string): Promise<Mod[]> {
const url = new URL("https://api.modrinth.com/v2/search");
@@ -73,3 +80,7 @@ export async function getVersion(): Promise<string> {
export async function updateVersion(version: string): Promise<void> {
await invoke("update_version", { version });
}
export async function installPaths(version: string, paths: string[]): Promise<void> {
await invoke("install_paths", { version, paths });
}
+3
View File
@@ -17,4 +17,7 @@ export interface GameVersion {
export interface LauncherSettings {
max_memory: number;
features: {
sync_options: boolean
}
}