Merge pull request #9 from saturnclientmc/mods

Mods
This commit is contained in:
Kr4ight
2026-04-05 00:04:48 +02:00
committed by GitHub
9 changed files with 316 additions and 124 deletions
+233 -6
View File
@@ -1,17 +1,244 @@
use std::path::PathBuf;
use serde::Deserialize;
use tauri::State;
use tokio::fs;
use crate::{save_state, LauncherSettings, Mod, SharedState};
use crate::{launcher::LAUNCHER_DIR, save_state, LauncherSettings, Mod, SharedState};
#[tauri::command]
pub fn get_installed_mods(state: State<SharedState>, version_id: String) -> Vec<Mod> {
Vec::new()
#[derive(Deserialize)]
struct ModrinthVersion {
version_number: String,
game_versions: Vec<String>,
loaders: Vec<String>,
files: Vec<ModrinthFile>,
}
#[derive(Deserialize)]
struct ModrinthFile {
url: String,
filename: String,
primary: bool,
}
#[tauri::command]
pub fn toggle_mod(state: State<SharedState>, mod_id: String) {}
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 enabled_dir = base_dir.clone();
let disabled_dir = base_dir.join("disabled_mods");
let cache_dir = LAUNCHER_DIR.data_dir().join("cache");
// process a directory async
async fn process_dir(dir: PathBuf, enabled: bool, cache_dir: PathBuf) -> Vec<Mod> {
let mut mods = Vec::new();
if fs::metadata(&dir).await.is_err() {
return mods;
}
let mut entries = match fs::read_dir(&dir).await {
Ok(e) => e,
Err(_) => return mods,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
// only .jar files
if path.extension().and_then(|s| s.to_str()) != Some("jar") {
continue;
}
let file_name = match path.file_name().and_then(|s| s.to_str()) {
Some(name) => name.to_string(),
None => continue,
};
let cache_path = cache_dir.join(format!("{}.json", file_name));
let mod_item = if fs::metadata(&cache_path).await.is_ok() {
match fs::read_to_string(&cache_path).await {
Ok(content) => match serde_json::from_str::<Mod>(&content) {
Ok(mut m) => {
m.enabled = enabled;
m
}
Err(_) => fallback_mod(&file_name, enabled),
},
Err(_) => fallback_mod(&file_name, enabled),
}
} else {
fallback_mod(&file_name, enabled)
};
mods.push(mod_item);
}
mods
}
// run both dirs (sequential, but async-safe)
let mut enabled_mods = process_dir(enabled_dir, true, cache_dir.clone()).await;
let mut disabled_mods = process_dir(disabled_dir, false, cache_dir.clone()).await;
mods.append(&mut enabled_mods);
mods.append(&mut disabled_mods);
Ok(mods)
}
// fallback
fn fallback_mod(file_name: &str, enabled: bool) -> Mod {
Mod {
id: file_name.to_string(),
name: file_name.to_string(),
version: String::new(),
author: String::new(),
description: String::new(),
enabled,
icon: None,
supported_versions: vec![],
}
}
#[tauri::command]
pub fn install_mod(state: State<SharedState>, mut mod_item: Mod) {}
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");
// disabled_mods folder
let disabled_dir = mods_dir.join("disabled_mods");
// create disabled_mods if it doesn't exist
fs::create_dir_all(&disabled_dir)
.await
.map_err(|e| e.to_string())?;
let source = mods_dir.join(&file_name);
let destination = disabled_dir.join(&file_name);
if !source.exists() {
return Err(format!("Mod file not found: {}", file_name));
}
// move file
fs::rename(&source, &destination)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[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 disabled_dir = mods_dir.join("disabled_mods");
let source = disabled_dir.join(&file_name);
let destination = mods_dir.join(&file_name);
if !source.exists() {
return Err(format!("Disabled mod not found: {}", file_name));
}
fs::rename(&source, &destination)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn install_mod(mut mod_meta: Mod, versions: Vec<String>) -> Result<(), String> {
let client = reqwest::Client::new();
println!("Installing {} for {:?}", mod_meta.name, versions);
let cache_dir = LAUNCHER_DIR.data_dir().join("cache");
fs::create_dir_all(&cache_dir)
.await
.map_err(|e| e.to_string())?;
for mc_version in versions {
let url = format!(
"https://api.modrinth.com/v2/project/{}/version",
mod_meta.id
);
let res = client.get(&url).send().await.map_err(|e| e.to_string())?;
let versions_list: Vec<ModrinthVersion> = res.json().await.map_err(|e| e.to_string())?;
let mut compatible: Vec<&ModrinthVersion> = versions_list
.iter()
.filter(|v| {
v.game_versions.contains(&mc_version) && v.loaders.iter().any(|l| l == "fabric")
})
.collect();
if compatible.is_empty() {
println!("No compatible version found for {}", mc_version);
continue;
}
compatible.sort_by(|a, b| b.version_number.cmp(&a.version_number));
let selected = compatible[0];
let file = selected
.files
.iter()
.find(|f| f.primary)
.or_else(|| selected.files.first())
.ok_or("No files found for version")?;
let bytes = client
.get(&file.url)
.send()
.await
.map_err(|e| e.to_string())?
.bytes()
.await
.map_err(|e| e.to_string())?;
let dir: PathBuf = LAUNCHER_DIR.data_dir().join(&mc_version).join("mods");
fs::create_dir_all(&dir).await.map_err(|e| e.to_string())?;
let file_path = dir.join(&file.filename);
// 1. Save JAR
fs::write(&file_path, &bytes)
.await
.map_err(|e| e.to_string())?;
// 2. Prepare cache metadata
mod_meta.id = file.filename.clone(); // ✅ ID is filename
mod_meta.version = selected.version_number.clone();
mod_meta.enabled = true;
let cache_path = cache_dir.join(format!("{}.json", file.filename));
// 3. Save metadata JSON
let json = serde_json::to_string_pretty(&mod_meta).map_err(|e| e.to_string())?;
fs::write(&cache_path, json)
.await
.map_err(|e| e.to_string())?;
println!(
"Installed {} for {} (cached as {})",
mod_meta.name, mc_version, file.filename
);
}
Ok(())
}
#[tauri::command]
pub fn get_settings(state: State<SharedState>) -> LauncherSettings {
+6 -2
View File
@@ -13,9 +13,9 @@ use lyceris::{
},
};
use once_cell::sync::Lazy;
use tauri::Emitter as _;
use tauri::{Emitter as _, State};
use crate::GameVersion;
use crate::{GameVersion, SharedState};
pub static LAUNCHER_DIR: Lazy<ProjectDirs> = Lazy::new(|| {
ProjectDirs::from("org", "SaturnLauncher", "SaturnLauncher")
@@ -25,6 +25,7 @@ pub static LAUNCHER_DIR: Lazy<ProjectDirs> = Lazy::new(|| {
#[tauri::command]
pub async fn launch_game(
app: tauri::AppHandle,
state: State<'_, SharedState>,
version: GameVersion,
account: MinecraftAccount,
) -> Result<(), String> {
@@ -83,6 +84,9 @@ pub async fn launch_game(
},
)
.loader(Fabric(version.loader_version).into())
.memory(lyceris::minecraft::config::Memory::Megabyte(
state.lock().unwrap().settings.max_memory as u64,
))
.build();
println!("Starting installation");
+1 -6
View File
@@ -30,7 +30,6 @@ pub struct GameVersion {
#[derive(Clone, Serialize, Deserialize)]
pub struct LauncherSettings {
pub jvm_arguments: String,
pub max_memory: u32,
}
@@ -44,11 +43,7 @@ pub struct AppState {
impl Default for AppState {
fn default() -> Self {
Self {
settings: LauncherSettings {
jvm_arguments: "-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200"
.into(),
max_memory: 4096,
},
settings: LauncherSettings { max_memory: 4096 },
version: String::from("1.21.6"),
}
}
+2 -1
View File
@@ -11,7 +11,8 @@ fn main() {
.invoke_handler(tauri::generate_handler![
// Saturn Launcher Commands
saturn_launcher_lib::commands::get_installed_mods,
saturn_launcher_lib::commands::toggle_mod,
saturn_launcher_lib::commands::enable_mod,
saturn_launcher_lib::commands::disable_mod,
saturn_launcher_lib::commands::install_mod,
saturn_launcher_lib::commands::get_settings,
saturn_launcher_lib::commands::update_settings,
+35 -15
View File
@@ -11,8 +11,9 @@ import {
import {
getInstalledMods,
discoverMods,
toggleMod,
enableMod,
installMod,
disableMod,
} from "@/lib/saturn";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
@@ -41,21 +42,32 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
string[]
>([]);
useEffect(() => {
const loadMods = async () => {
if (activeTab === "installed") {
const m = await getInstalledMods(version.id);
setMods(m);
} else {
const m = await discoverMods(searchQuery);
const loadMods = async () => {
if (activeTab === "installed") {
const m = await getInstalledMods(version.id);
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
setMods(m);
}
};
loadMods();
}, [activeTab, version.id, searchQuery]);
}
};
const handleToggleMod = async (modId: string) => {
await toggleMod(modId);
useEffect(() => {
const id = setTimeout(loadMods, 800);
return () => clearTimeout(id);
}, [version.id, searchQuery]);
useEffect(() => {
setMods([]);
loadMods();
}, [activeTab]);
const handleToggleMod = async (modId: string, enable: boolean) => {
if (enable) await enableMod(version.id, modId);
else await disableMod(version.id, modId);
const m = await getInstalledMods(version.id);
setMods(m);
};
@@ -143,7 +155,15 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
className="panel p-4 flex items-center gap-4 hover:border-white/10 transition-colors"
>
<div className="w-12 h-12 rounded-lg bg-zinc-800 border border-saturn-border flex items-center justify-center shrink-0">
<Package size={24} className="text-saturn-text-secondary" />
{mod.icon ? (
<img
src={mod.icon}
alt="Mod Icon"
className="w-full h-full rounded-lg"
/>
) : (
<Package size={24} className="text-saturn-text-secondary" />
)}
</div>
<div className="flex-1 min-w-0">
@@ -165,7 +185,7 @@ const ModsSection: React.FC<ModsSectionProps> = ({ version }) => {
{activeTab === "installed" ? (
<>
<button
onClick={() => handleToggleMod(mod.id)}
onClick={() => handleToggleMod(mod.id, !mod.enabled)}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium transition-all",
mod.enabled
-2
View File
@@ -39,8 +39,6 @@ const PlaySection: React.FC<PlaySectionProps> = ({ version, account }) => {
};
}, []);
console.log(isLaunching);
return (
<div className="h-full relative">
{/* Background Image */}
+1 -52
View File
@@ -1,12 +1,5 @@
import React, { useState, useEffect } from "react";
import {
Cpu,
MemoryStick,
Save,
RefreshCcw,
Info,
AlertTriangle,
} from "lucide-react";
import { MemoryStick, Save, RefreshCcw, Info } from "lucide-react";
import { getSettings, updateSettings } from "../lib/saturn";
import { motion } from "framer-motion";
import { LauncherSettings } from "@/lib/types";
@@ -41,8 +34,6 @@ const SettingsSection: React.FC = () => {
const handleReset = async () => {
// In a real app, this would reset to defaults in the service
const defaultSettings: LauncherSettings = {
jvm_arguments:
"-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200",
max_memory: 4096,
};
setSettings(defaultSettings);
@@ -142,48 +133,6 @@ const SettingsSection: React.FC = () => {
</div>
</div>
</motion.div>
{/* JVM Arguments */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
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">
<Cpu size={18} />
</div>
<h2 className="text-lg font-bold">Custom JVM Arguments</h2>
</div>
<div className="space-y-4">
<p className="text-xs text-saturn-text-secondary leading-relaxed">
Advanced flags for the Java Virtual Machine. Saturn Client
includes optimized defaults, but you can override them here.
</p>
<textarea
value={settings.jvm_arguments}
onChange={(e) =>
setSettings({ ...settings, jvm_arguments: e.target.value })
}
className="w-full h-32 bg-saturn-bg border border-saturn-border rounded-lg p-4 text-sm font-mono text-saturn-text-secondary focus:outline-none focus:border-saturn-accent/50 focus:ring-1 focus:ring-saturn-accent/50 transition-all resize-none"
placeholder="-XX:+UseG1GC..."
/>
<div className="p-3 bg-yellow-500/5 border border-yellow-500/10 rounded-lg flex gap-3">
<AlertTriangle
size={16}
className="text-yellow-500 shrink-0 mt-0.5"
/>
<p className="text-xs text-yellow-500/80 leading-relaxed">
Incorrect JVM arguments can prevent the game from starting. Only
modify if you know what you're doing.
</p>
</div>
</div>
</motion.div>
</div>
</div>
);
+38 -39
View File
@@ -6,53 +6,52 @@ export async function getInstalledMods(versionId: string): Promise<Mod[]> {
return await invoke("get_installed_mods", { versionId });
}
export async function toggleMod(modId: string): Promise<void> {
await invoke("toggle_mod", { modId });
export async function enableMod(version: string, modId: string): Promise<void> {
await invoke("enable_mod", { version, fileName: modId });
}
export async function disableMod(
version: string,
modId: string,
): Promise<void> {
await invoke("disable_mod", { version, fileName: modId });
}
export async function discoverMods(query: string): Promise<Mod[]> {
const allDiscoverable: Mod[] = [
{
id: "modmenu",
name: "Mod Menu",
version: "7.2.2",
author: "TerraformersMC",
description: "Adds a mod menu to view the list of installed mods.",
enabled: false,
supported_versions: ["1.20.1", "1.20.4", "1.21"],
},
{
id: "starlight",
name: "Starlight",
version: "1.1.2",
author: "Spottedleaf",
description: "Rewrites the light engine to fix performance issues.",
enabled: false,
supported_versions: ["1.20.1"],
},
{
id: "ferritecore",
name: "FerriteCore",
version: "6.0.1",
author: "malte0811",
description: "Memory usage optimizations.",
enabled: false,
supported_versions: ["1.20.1", "1.20.4", "1.21"],
},
];
const url = new URL("https://api.modrinth.com/v2/search");
if (!query) return allDiscoverable;
if (query) {
url.searchParams.set("query", query);
}
return allDiscoverable.filter((m) =>
m.name.toLowerCase().includes(query.toLowerCase()),
// Optional but recommended filters
url.searchParams.set("facets", JSON.stringify([["project_type:mod"]]));
url.searchParams.set("limit", "20");
const res = await fetch(url.toString());
if (!res.ok) {
throw new Error("Failed to fetch mods from Modrinth");
}
const data = await res.json();
return data.hits.map(
(mod: any): Mod => ({
id: mod.project_id,
name: mod.title,
version: "latest",
author: mod.author,
description: mod.description,
enabled: false,
icon: mod.icon_url,
supported_versions: mod.versions || [],
}),
);
}
export async function installMod(
mod: Mod,
versionsList: string[],
): Promise<void> {
await invoke("install_mod", { modItem: mod });
export async function installMod(mod: Mod, versions: string[]): Promise<void> {
await invoke("install_mod", { modMeta: mod, versions });
}
// --- Settings ---
-1
View File
@@ -16,6 +16,5 @@ export interface GameVersion {
}
export interface LauncherSettings {
jvm_arguments: string;
max_memory: number;
}