aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/release.yml94
-rw-r--r--.github/workflows/test.yml35
-rw-r--r--README.md20
-rw-r--r--assets/128x128.pngbin0 -> 6188 bytes
-rw-r--r--assets/image.jpgbin172906 -> 0 bytes
-rw-r--r--assets/image.pngbin0 -> 161028 bytes
-rw-r--r--src-tauri/Cargo.toml3
-rw-r--r--src-tauri/capabilities/default.json3
-rwxr-xr-xsrc-tauri/scripts/fix-appimage.sh32
-rw-r--r--src-tauri/src/core/account_storage.rs29
-rw-r--r--src-tauri/src/core/auth.rs36
-rw-r--r--src-tauri/src/core/config.rs13
-rw-r--r--src-tauri/src/core/downloader.rs164
-rw-r--r--src-tauri/src/core/fabric.rs274
-rw-r--r--src-tauri/src/core/forge.rs336
-rw-r--r--src-tauri/src/core/game_version.rs47
-rw-r--r--src-tauri/src/core/java.rs24
-rw-r--r--src-tauri/src/core/manifest.rs154
-rw-r--r--src-tauri/src/core/maven.rs263
-rw-r--r--src-tauri/src/core/mod.rs4
-rw-r--r--src-tauri/src/core/rules.rs7
-rw-r--r--src-tauri/src/core/version_merge.rs244
-rw-r--r--src-tauri/src/main.rs460
-rw-r--r--src-tauri/tauri.conf.json13
-rw-r--r--ui/package-lock.json1784
-rw-r--r--ui/package.json1
-rw-r--r--ui/pnpm-lock.yaml136
-rw-r--r--ui/src/App.svelte919
-rw-r--r--ui/src/app.css2
-rw-r--r--ui/src/components/BottomBar.svelte79
-rw-r--r--ui/src/components/HomeView.svelte57
-rw-r--r--ui/src/components/LoginModal.svelte30
-rw-r--r--ui/src/components/ModLoaderSelector.svelte245
-rw-r--r--ui/src/components/ParticleBackground.svelte57
-rw-r--r--ui/src/components/SettingsView.svelte286
-rw-r--r--ui/src/components/Sidebar.svelte76
-rw-r--r--ui/src/components/StatusToast.svelte42
-rw-r--r--ui/src/components/VersionsView.svelte255
-rw-r--r--ui/src/lib/DownloadMonitor.svelte109
-rw-r--r--ui/src/lib/GameConsole.svelte2
-rw-r--r--ui/src/lib/effects/ConstellationEffect.ts163
-rw-r--r--ui/src/lib/effects/SaturnEffect.ts194
-rw-r--r--ui/src/lib/modLoaderApi.ts108
-rw-r--r--ui/src/stores/settings.svelte.ts10
-rw-r--r--ui/src/types/index.ts65
45 files changed, 5529 insertions, 1346 deletions
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 23d4181..487ddee 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -38,6 +38,8 @@ jobs:
token: ${{ github.token }}
tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }}
changelogFilePath: CHANGELOG.md
+ includeInvalidCommits: true
+ useGitmojis: false
- name: Create Release
id: create_release
@@ -66,6 +68,12 @@ jobs:
name: "Linux ARM64"
target: "aarch64-unknown-linux-gnu"
args: "--target aarch64-unknown-linux-gnu"
+ - platform: "ubuntu-22.04"
+ name: "Linux x86_64 (Arch/Wayland)"
+ target: "x86_64-unknown-linux-gnu"
+ args: "--target x86_64-unknown-linux-gnu"
+ container: "archlinux:latest"
+ wayland: true
- platform: "macos-latest"
name: "macOS ARM64"
target: "aarch64-apple-darwin"
@@ -80,20 +88,44 @@ jobs:
args: "--target aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }}
+ container:
+ image: ${{ matrix.container }}
+ options: --user root
steps:
- uses: actions/checkout@v4
- name: Install Dependencies (Linux x86_64)
- if: matrix.platform == 'ubuntu-22.04'
+ if: matrix.platform == 'ubuntu-22.04' && !matrix.wayland
run: |
sudo apt-get update
- sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
+ sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libfuse2
- name: Install Dependencies (Linux ARM64)
if: matrix.platform == 'ubuntu-24.04-arm'
run: |
sudo apt-get update
- sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
+ sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libfuse2
+
+ - name: Install Dependencies (Arch Linux)
+ if: matrix.wayland
+ run: |
+ pacman -Syu --noconfirm
+ pacman -S --noconfirm webkit2gtk-4.1 base-devel curl wget file openssl gtk3 appindicator-gtk3 librsvg fuse2
+
+ - name: Setup Wayland Environment (Arch)
+ if: matrix.wayland
+ run: |
+ pacman -S --noconfirm cage wayland protocols weston
+
+ echo "WAYLAND_DISPLAY=wayland-1" >> $GITHUB_ENV
+ echo "GDK_BACKEND=wayland" >> $GITHUB_ENV
+ echo "XDG_SESSION_TYPE=wayland" >> $GITHUB_ENV
+ echo "XDG_RUNTIME_DIR=/tmp/runtime-wayland" >> $GITHUB_ENV
+
+ mkdir -p /tmp/runtime-wayland
+ chmod 0700 /tmp/runtime-wayland
+
+ echo "Wayland environment configured for testing"
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -122,6 +154,18 @@ jobs:
workspaces: "./src-tauri -> target"
shared-key: ${{ matrix.target }}
+ - name: Setup appimagetool (Linux)
+ if: (startsWith(matrix.platform, 'ubuntu') || matrix.wayland) && !startsWith(matrix.platform, 'macos') && !startsWith(matrix.platform, 'windows')
+ run: |
+ ARCH=$(uname -m)
+ wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${ARCH}.AppImage"
+ chmod +x "appimagetool-${ARCH}.AppImage"
+ if command -v sudo >/dev/null 2>&1; then
+ sudo mv "appimagetool-${ARCH}.AppImage" /usr/local/bin/appimagetool
+ else
+ mv "appimagetool-${ARCH}.AppImage" /usr/local/bin/appimagetool
+ fi
+
- name: Build Tauri App
uses: tauri-apps/tauri-action@v0
env:
@@ -129,3 +173,47 @@ jobs:
with:
releaseId: ${{ needs.promote-release.outputs.release_id }}
args: ${{ matrix.args }}
+
+ - name: Fix AppImage for Wayland (Linux)
+ if: (startsWith(matrix.platform, 'ubuntu') || matrix.wayland) && !startsWith(matrix.platform, 'macos') && !startsWith(matrix.platform, 'windows')
+ run: |
+ # Locate the generated AppImage
+ APPIMAGE=$(find bundle -name "*.AppImage" -type f | head -1)
+ echo "Found AppImage: $APPIMAGE"
+
+ if [ -n "$APPIMAGE" ]; then
+ # backup original AppImage
+ cp "$APPIMAGE" "${APPIMAGE}.backup"
+
+ # extract AppImage
+ "$APPIMAGE" --appimage-extract
+
+ # Fix GTK hook, remove forced X11
+ if [ -f squashfs-root/apprun-hooks/linuxdeploy-plugin-gtk.sh ]; then
+ sed -i 's/^export GDK_BACKEND=x11.*$/# export GDK_BACKEND=x11 # Disabled for Wayland compatibility/' squashfs-root/apprun-hooks/linuxdeploy-plugin-gtk.sh
+ echo "Successfully patched GTK hook for Wayland compatibility"
+ fi
+
+ # Repack AppImage
+ appimagetool squashfs-root "$APPIMAGE"
+ rm -rf squashfs-root
+ fi
+ working-directory: src-tauri/target/release
+
+ - name: Test AppImage on Wayland (Arch)
+ if: matrix.wayland
+ run: |
+ # Test that the AppImage can run in Wayland environment
+ APPIMAGE=$(find bundle -name "*.AppImage" -type f | head -1)
+ echo "Testing AppImage: $APPIMAGE"
+ echo "Wayland display: $WAYLAND_DISPLAY"
+ echo "GDK backend: $GDK_BACKEND"
+
+ # Quick startup test (timeout after 5 seconds)
+ timeout 5 "$APPIMAGE" --version 2>&1 || {
+ echo "AppImage test failed with exit code: $?"
+ exit 1
+ }
+
+ echo "AppImage Wayland compatibility test passed!"
+ working-directory: src-tauri/target/release
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b5311c9..8ca056e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -15,23 +15,50 @@ env:
jobs:
test:
- name: Test on ${{ matrix.platform }}
+ name: Test on ${{ matrix.name }}
runs-on: ${{ matrix.platform }}
+ container:
+ image: ${{ matrix.container }}
+ options: --user root
strategy:
fail-fast: false
matrix:
# On Push: Linux only. On PR: All platforms.
- platform: ${{ (github.event_name == 'pull_request') && fromJson('["ubuntu-22.04", "windows-latest", "macos-14"]') || fromJson('["ubuntu-22.04"]') }}
+ include:
+ - platform: "ubuntu-22.04"
+ name: "Ubuntu 22.04"
+ - platform: "ubuntu-22.04"
+ name: "Arch Linux (Wayland)"
+ container: "archlinux:latest"
+ wayland: true
+ - platform: "windows-latest"
+ name: "Windows"
+ - platform: "macos-14"
+ name: "macOS"
steps:
- uses: actions/checkout@v4
- - name: Install Dependencies (Linux)
- if: runner.os == 'Linux'
+ - name: Install Dependencies (Ubuntu)
+ if: runner.os == 'Linux' && !matrix.wayland
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
+ - name: Install Dependencies (Arch Linux)
+ if: matrix.wayland
+ run: |
+ pacman -Syu --noconfirm
+ pacman -S --noconfirm webkit2gtk-4.1 base-devel curl wget file openssl gtk3
+
+ - name: Setup Wayland Environment (Arch)
+ if: matrix.wayland
+ run: |
+ echo "WAYLAND_DISPLAY=wayland-1" >> $GITHUB_ENV
+ echo "GDK_BACKEND=wayland" >> $GITHUB_ENV
+ echo "XDG_SESSION_TYPE=wayland" >> $GITHUB_ENV
+ echo "Wayland test environment configured"
+
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
diff --git a/README.md b/README.md
index 420efc6..7475c60 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,9 @@
-# DropOut - Next Generation Minecraft Launcher
+# DropOut
DropOut is a modern, fast, and efficient Minecraft launcher built with the latest web and system technologies. It leverages **Tauri v2** to deliever a lightweight application with a robust **Rust** backend and a reactive **Svelte 5** frontend.
<div align="center">
- <img width="500" src="assets/image.jpg" />
+ <img width="700" src="assets/image.png" />
</div>
## Features
@@ -13,16 +13,10 @@ DropOut is a modern, fast, and efficient Minecraft launcher built with the lates
- **Cross-Platform**: Native support for **Windows**, **Linux**, and **macOS**.
- **Modern UI**: Clean and responsive interface built with Svelte 5 and Tailwind CSS 4.
- **Game Management**:
+
- Version isolation and management
- Efficient asset and library downloading
- - Custom Java arguments support.
-
-## Supported Platforms
-
-- [X] **Linux** `x86_64`
-- [X] **macOS** `ARM64 (Apple Silicon)`
-- [X] **Windows** `x86_64`
-- [X] **Windows** `ARM64`
+ - Custom Java arguments support
## Roadmap
@@ -30,10 +24,10 @@ DropOut is a modern, fast, and efficient Minecraft launcher built with the lates
- [X] **Token Refresh** — Auto-refresh expired Microsoft tokens
- [X] **JVM Arguments Parsing** — Parse `arguments.jvm` from version.json for Mac M1/ARM support
- [X] **Java Auto-detection** — Scan common paths for Java installations
-- [ ] **Fabric Loader Support** — Install and launch with Fabric
-- [ ] **Forge Loader Support** — Install and launch with Forge
+- [X] **Fabric Loader Support** — Install and launch with Fabric
+- [X] **Forge Loader Support** — Install and launch with Forge (basic support)
+- [X] **Version Filtering** — Filter by release/snapshot/modded in UI
- [ ] **Instance/Profile System** — Multiple isolated game directories with different versions/mods
-- [ ] **Version Filtering** — Filter by release/snapshot/old_beta in UI
- [ ] **Multi-account Support** — Switch between multiple accounts
- [ ] **Custom Game Directory** — Allow users to choose game files location
- [ ] **Launcher Auto-updater** — Self-update mechanism via Tauri updater plugin
diff --git a/assets/128x128.png b/assets/128x128.png
new file mode 100644
index 0000000..ff83086
--- /dev/null
+++ b/assets/128x128.png
Binary files differ
diff --git a/assets/image.jpg b/assets/image.jpg
deleted file mode 100644
index 28cffe1..0000000
--- a/assets/image.jpg
+++ /dev/null
Binary files differ
diff --git a/assets/image.png b/assets/image.png
new file mode 100644
index 0000000..ddda96f
--- /dev/null
+++ b/assets/image.png
Binary files differ
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 0387526..bc831fb 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "dropout"
-version = "0.1.13"
+version = "0.1.19"
edition = "2021"
authors = ["HsiangNianian"]
description = "The DropOut Minecraft Game Launcher"
@@ -27,6 +27,7 @@ flate2 = "1.0"
tar = "0.4"
dirs = "5.0"
serde_urlencoded = "0.7.1"
+tauri-plugin-dialog = "2.5.0"
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 894b905..4d8b907 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -12,6 +12,7 @@
"core:app:allow-version",
"core:path:default",
"core:window:default",
- "shell:allow-open"
+ "shell:allow-open",
+ "dialog:default"
]
}
diff --git a/src-tauri/scripts/fix-appimage.sh b/src-tauri/scripts/fix-appimage.sh
new file mode 100755
index 0000000..6bb375b
--- /dev/null
+++ b/src-tauri/scripts/fix-appimage.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+# fix AppImage for Wayland compatibility
+# This script modifies the AppImage bundle created by Tauri to ensure compatibility with Wayland
+# It specifically targets the GTK backend settings to avoid forcing X11
+
+set -e
+
+echo "Fixing AppImage for Wayland compatibility..."
+
+# Tauri sets the APPIMAGE_BUNDLE_PATH environment variable during the build process
+APPDIR_PATH="${APPIMAGE_BUNDLE_PATH:-}"
+
+if [ -z "$APPDIR_PATH" ]; then
+ echo "No AppImage bundle path found, skipping fix"
+ exit 0
+fi
+
+# Check for the presence of the GTK hook file
+if [ -d "$APPDIR_PATH/apprun-hooks" ]; then
+ HOOK_FILE="$APPDIR_PATH/apprun-hooks/linuxdeploy-plugin-gtk.sh"
+
+ if [ -f "$HOOK_FILE" ]; then
+ echo "Found GTK hook file, patching..."
+
+ # Comment out the line that forces GDK_BACKEND to x11
+ sed -i 's/^export GDK_BACKEND=x11.*$/# export GDK_BACKEND=x11 # Disabled for Wayland compatibility/' "$HOOK_FILE"
+
+ echo "Successfully patched $HOOK_FILE"
+ fi
+fi
+
+echo "AppImage Wayland fix completed!"
diff --git a/src-tauri/src/core/account_storage.rs b/src-tauri/src/core/account_storage.rs
index b8e15e1..569df7b 100644
--- a/src-tauri/src/core/account_storage.rs
+++ b/src-tauri/src/core/account_storage.rs
@@ -4,21 +4,12 @@ use std::fs;
use std::path::PathBuf;
/// Stored account data for persistence
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AccountStore {
pub accounts: Vec<StoredAccount>,
pub active_account_id: Option<String>,
}
-impl Default for AccountStore {
- fn default() -> Self {
- Self {
- accounts: Vec::new(),
- active_account_id: None,
- }
- }
-}
-
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum StoredAccount {
@@ -131,13 +122,17 @@ impl AccountStorage {
pub fn get_active_account(&self) -> Option<(StoredAccount, Option<String>)> {
let store = self.load();
if let Some(active_id) = &store.active_account_id {
- store.accounts.iter().find(|a| &a.id() == active_id).map(|a| {
- let ms_token = match a {
- StoredAccount::Microsoft(m) => m.ms_refresh_token.clone(),
- _ => None,
- };
- (a.clone(), ms_token)
- })
+ store
+ .accounts
+ .iter()
+ .find(|a| &a.id() == active_id)
+ .map(|a| {
+ let ms_token = match a {
+ StoredAccount::Microsoft(m) => m.ms_refresh_token.clone(),
+ _ => None,
+ };
+ (a.clone(), ms_token)
+ })
} else {
None
}
diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs
index 624f1de..5f01a58 100644
--- a/src-tauri/src/core/auth.rs
+++ b/src-tauri/src/core/auth.rs
@@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use uuid::Uuid;
-
// Helper to create a client with a custom User-Agent
// This is critical because Microsoft's WAF often blocks requests without a valid UA
fn get_client() -> reqwest::Client {
@@ -116,7 +115,7 @@ pub async fn refresh_microsoft_token(refresh_token: &str) -> Result<TokenRespons
let resp = client
.post(url)
.header("Content-Type", "application/x-www-form-urlencoded")
- .body(serde_urlencoded::to_string(&params).map_err(|e| e.to_string())?)
+ .body(serde_urlencoded::to_string(params).map_err(|e| e.to_string())?)
.send()
.await
.map_err(|e| e.to_string())?;
@@ -142,30 +141,32 @@ pub fn is_token_expired(expires_at: i64) -> bool {
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
-
+
// Consider expired if less than 5 minutes remaining
expires_at - now < 300
}
/// Full refresh flow: refresh MS token -> Xbox -> XSTS -> Minecraft
-pub async fn refresh_full_auth(ms_refresh_token: &str) -> Result<(MicrosoftAccount, String), String> {
+pub async fn refresh_full_auth(
+ ms_refresh_token: &str,
+) -> Result<(MicrosoftAccount, String), String> {
println!("[Auth] Starting full token refresh...");
-
+
// 1. Refresh Microsoft token
let token_resp = refresh_microsoft_token(ms_refresh_token).await?;
-
+
// 2. Xbox Live Auth
let (xbl_token, uhs) = method_xbox_live(&token_resp.access_token).await?;
-
+
// 3. XSTS Auth
let xsts_token = method_xsts(&xbl_token).await?;
-
+
// 4. Minecraft Auth
let mc_token = login_minecraft(&xsts_token, &uhs).await?;
-
+
// 5. Get Profile
let profile = fetch_profile(&mc_token).await?;
-
+
// 6. Create Account
let account = MicrosoftAccount {
username: profile.name,
@@ -175,12 +176,15 @@ pub async fn refresh_full_auth(ms_refresh_token: &str) -> Result<(MicrosoftAccou
expires_at: (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
- .as_secs() + token_resp.expires_in) as i64,
+ .as_secs()
+ + token_resp.expires_in) as i64,
};
-
+
// Return new MS refresh token for storage
- let new_ms_refresh = token_resp.refresh_token.unwrap_or_else(|| ms_refresh_token.to_string());
-
+ let new_ms_refresh = token_resp
+ .refresh_token
+ .unwrap_or_else(|| ms_refresh_token.to_string());
+
Ok((account, new_ms_refresh))
}
@@ -221,7 +225,7 @@ pub async fn start_device_flow() -> Result<DeviceCodeResponse, String> {
let resp = client
.post(url)
.header("Content-Type", "application/x-www-form-urlencoded")
- .body(serde_urlencoded::to_string(&params).map_err(|e| e.to_string())?)
+ .body(serde_urlencoded::to_string(params).map_err(|e| e.to_string())?)
.send()
.await
.map_err(|e| e.to_string())?;
@@ -257,7 +261,7 @@ pub async fn exchange_code_for_token(device_code: &str) -> Result<TokenResponse,
let resp = client
.post(url)
.header("Content-Type", "application/x-www-form-urlencoded")
- .body(serde_urlencoded::to_string(&params).map_err(|e| e.to_string())?)
+ .body(serde_urlencoded::to_string(params).map_err(|e| e.to_string())?)
.send()
.await
.map_err(|e| e.to_string())?;
diff --git a/src-tauri/src/core/config.rs b/src-tauri/src/core/config.rs
index 47c5306..510b126 100644
--- a/src-tauri/src/core/config.rs
+++ b/src-tauri/src/core/config.rs
@@ -5,12 +5,19 @@ use std::sync::Mutex;
use tauri::{AppHandle, Manager};
#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default)]
pub struct LauncherConfig {
pub min_memory: u32, // in MB
pub max_memory: u32, // in MB
pub java_path: String,
pub width: u32,
pub height: u32,
+ pub download_threads: u32, // concurrent download threads (1-128)
+ pub custom_background_path: Option<String>,
+ pub enable_gpu_acceleration: bool,
+ pub enable_visual_effects: bool,
+ pub active_effect: String,
+ pub theme: String,
}
impl Default for LauncherConfig {
@@ -21,6 +28,12 @@ impl Default for LauncherConfig {
java_path: "java".to_string(),
width: 854,
height: 480,
+ download_threads: 32,
+ custom_background_path: None,
+ enable_gpu_acceleration: false,
+ enable_visual_effects: true,
+ active_effect: "constellation".to_string(),
+ theme: "dark".to_string(),
}
}
}
diff --git a/src-tauri/src/core/downloader.rs b/src-tauri/src/core/downloader.rs
index 09101c9..d33c44d 100644
--- a/src-tauri/src/core/downloader.rs
+++ b/src-tauri/src/core/downloader.rs
@@ -2,6 +2,7 @@ use futures::StreamExt;
use serde::{Deserialize, Serialize};
use sha1::Digest as Sha1Digest;
use std::path::PathBuf;
+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
use tauri::{Emitter, Window};
use tokio::io::AsyncWriteExt;
@@ -23,6 +24,9 @@ pub struct ProgressEvent {
pub downloaded: u64,
pub total: u64,
pub status: String, // "Downloading", "Verifying", "Finished", "Error"
+ pub completed_files: usize,
+ pub total_files: usize,
+ pub total_downloaded_bytes: u64,
}
/// calculate SHA256 hash of data
@@ -51,9 +55,96 @@ pub fn verify_checksum(data: &[u8], sha256: Option<&str>, sha1: Option<&str>) ->
true
}
-pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<(), String> {
+/// Snapshot of global progress state
+struct ProgressSnapshot {
+ completed_files: usize,
+ total_files: usize,
+ total_downloaded_bytes: u64,
+}
+
+/// Centralized progress tracking with atomic counters
+struct GlobalProgress {
+ completed_files: AtomicUsize,
+ total_downloaded_bytes: AtomicU64,
+ total_files: usize,
+}
+
+impl GlobalProgress {
+ fn new(total_files: usize) -> Self {
+ Self {
+ completed_files: AtomicUsize::new(0),
+ total_downloaded_bytes: AtomicU64::new(0),
+ total_files,
+ }
+ }
+
+ /// Get current progress snapshot without modification
+ fn snapshot(&self) -> ProgressSnapshot {
+ ProgressSnapshot {
+ completed_files: self.completed_files.load(Ordering::Relaxed),
+ total_files: self.total_files,
+ total_downloaded_bytes: self.total_downloaded_bytes.load(Ordering::Relaxed),
+ }
+ }
+
+ /// Increment completed files counter and return updated snapshot
+ fn inc_completed(&self) -> ProgressSnapshot {
+ let completed = self.completed_files.fetch_add(1, Ordering::Relaxed) + 1;
+ ProgressSnapshot {
+ completed_files: completed,
+ total_files: self.total_files,
+ total_downloaded_bytes: self.total_downloaded_bytes.load(Ordering::Relaxed),
+ }
+ }
+
+ /// Add downloaded bytes and return updated snapshot
+ fn add_bytes(&self, delta: u64) -> ProgressSnapshot {
+ let total_bytes = self
+ .total_downloaded_bytes
+ .fetch_add(delta, Ordering::Relaxed)
+ + delta;
+ ProgressSnapshot {
+ completed_files: self.completed_files.load(Ordering::Relaxed),
+ total_files: self.total_files,
+ total_downloaded_bytes: total_bytes,
+ }
+ }
+}
+
+/// Emit a progress event to the frontend
+fn emit_progress(
+ window: &Window,
+ file_name: &str,
+ status: &str,
+ downloaded: u64,
+ total: u64,
+ snapshot: &ProgressSnapshot,
+) {
+ let _ = window.emit(
+ "download-progress",
+ ProgressEvent {
+ file: file_name.to_string(),
+ downloaded,
+ total,
+ status: status.into(),
+ completed_files: snapshot.completed_files,
+ total_files: snapshot.total_files,
+ total_downloaded_bytes: snapshot.total_downloaded_bytes,
+ },
+ );
+}
+
+pub async fn download_files(
+ window: Window,
+ tasks: Vec<DownloadTask>,
+ max_concurrent: usize,
+) -> Result<(), String> {
+ // Clamp max_concurrent to a valid range (1-128) to prevent edge cases
+ let max_concurrent = max_concurrent.clamp(1, 128);
+
let client = reqwest::Client::new();
- let semaphore = Arc::new(Semaphore::new(10)); // Max 10 concurrent downloads
+ let semaphore = Arc::new(Semaphore::new(max_concurrent));
+ let progress = Arc::new(GlobalProgress::new(tasks.len()));
// Notify start (total files)
let _ = window.emit("download-start", tasks.len());
@@ -62,6 +153,7 @@ pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<
let client = client.clone();
let window = window.clone();
let semaphore = semaphore.clone();
+ let progress = progress.clone();
async move {
let _permit = semaphore.acquire().await.unwrap();
@@ -69,15 +161,7 @@ pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<
// 1. Check if file exists and verify checksum
if task.path.exists() {
- let _ = window.emit(
- "download-progress",
- ProgressEvent {
- file: file_name.clone(),
- downloaded: 0,
- total: 0,
- status: "Verifying".into(),
- },
- );
+ emit_progress(&window, &file_name, "Verifying", 0, 0, &progress.snapshot());
if task.sha256.is_some() || task.sha1.is_some() {
if let Ok(data) = tokio::fs::read(&task.path).await {
@@ -86,15 +170,21 @@ pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<
task.sha256.as_deref(),
task.sha1.as_deref(),
) {
- // Already valid
- let _ = window.emit(
- "download-progress",
- ProgressEvent {
- file: file_name.clone(),
- downloaded: 0,
- total: 0,
- status: "Skipped".into(),
- },
+ // Already valid, skip download
+ let skipped_size = tokio::fs::metadata(&task.path)
+ .await
+ .map(|m| m.len())
+ .unwrap_or(0);
+ if skipped_size > 0 {
+ let _ = progress.add_bytes(skipped_size);
+ }
+ emit_progress(
+ &window,
+ &file_name,
+ "Skipped",
+ 0,
+ 0,
+ &progress.inc_completed(),
);
return Ok(());
}
@@ -123,14 +213,14 @@ pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<
return Err(format!("Write error: {}", e));
}
downloaded += chunk.len() as u64;
- let _ = window.emit(
- "download-progress",
- ProgressEvent {
- file: file_name.clone(),
- downloaded,
- total: total_size,
- status: "Downloading".into(),
- },
+ let snapshot = progress.add_bytes(chunk.len() as u64);
+ emit_progress(
+ &window,
+ &file_name,
+ "Downloading",
+ downloaded,
+ total_size,
+ &snapshot,
);
}
Ok(None) => break,
@@ -141,23 +231,21 @@ pub async fn download_files(window: Window, tasks: Vec<DownloadTask>) -> Result<
Err(e) => return Err(format!("Request error: {}", e)),
}
- let _ = window.emit(
- "download-progress",
- ProgressEvent {
- file: file_name.clone(),
- downloaded: 0,
- total: 0,
- status: "Finished".into(),
- },
+ emit_progress(
+ &window,
+ &file_name,
+ "Finished",
+ 0,
+ 0,
+ &progress.inc_completed(),
);
-
Ok(())
}
});
// Buffer unordered to run concurrently
tasks_stream
- .buffer_unordered(10)
+ .buffer_unordered(max_concurrent)
.collect::<Vec<Result<(), String>>>()
.await;
diff --git a/src-tauri/src/core/fabric.rs b/src-tauri/src/core/fabric.rs
new file mode 100644
index 0000000..fd38f41
--- /dev/null
+++ b/src-tauri/src/core/fabric.rs
@@ -0,0 +1,274 @@
+//! Fabric Loader support module.
+//!
+//! This module provides functionality to:
+//! - Fetch available Fabric loader versions from the Fabric Meta API
+//! - Generate version JSON files for Fabric-enabled Minecraft versions
+//! - Install Fabric loader for a specific Minecraft version
+
+use serde::{Deserialize, Serialize};
+use std::error::Error;
+use std::path::PathBuf;
+
+const FABRIC_META_URL: &str = "https://meta.fabricmc.net/v2";
+
+/// Represents a Fabric loader version from the Meta API.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricLoaderVersion {
+ pub separator: String,
+ pub build: i32,
+ pub maven: String,
+ pub version: String,
+ pub stable: bool,
+}
+
+/// Represents a Fabric intermediary mapping version.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricIntermediaryVersion {
+ pub maven: String,
+ pub version: String,
+ pub stable: bool,
+}
+
+/// Represents a combined loader + intermediary version entry.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricLoaderEntry {
+ pub loader: FabricLoaderVersion,
+ pub intermediary: FabricIntermediaryVersion,
+ #[serde(rename = "launcherMeta")]
+ pub launcher_meta: FabricLauncherMeta,
+}
+
+/// Launcher metadata from Fabric Meta API.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricLauncherMeta {
+ pub version: i32,
+ pub libraries: FabricLibraries,
+ #[serde(rename = "mainClass")]
+ pub main_class: FabricMainClass,
+}
+
+/// Libraries required by Fabric loader.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricLibraries {
+ pub client: Vec<FabricLibrary>,
+ pub common: Vec<FabricLibrary>,
+ pub server: Vec<FabricLibrary>,
+}
+
+/// A single Fabric library dependency.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricLibrary {
+ pub name: String,
+ pub url: Option<String>,
+}
+
+/// Main class configuration for Fabric.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricMainClass {
+ pub client: String,
+ pub server: String,
+}
+
+/// Represents a Minecraft version supported by Fabric.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct FabricGameVersion {
+ pub version: String,
+ pub stable: bool,
+}
+
+/// Information about an installed Fabric version.
+#[derive(Debug, Serialize, Clone)]
+pub struct InstalledFabricVersion {
+ pub id: String,
+ pub minecraft_version: String,
+ pub loader_version: String,
+ pub path: PathBuf,
+}
+
+/// Fetch all Minecraft versions supported by Fabric.
+///
+/// # Returns
+/// A list of game versions that have Fabric intermediary mappings available.
+pub async fn fetch_supported_game_versions(
+) -> Result<Vec<FabricGameVersion>, Box<dyn Error + Send + Sync>> {
+ let url = format!("{}/versions/game", FABRIC_META_URL);
+ let resp = reqwest::get(&url)
+ .await?
+ .json::<Vec<FabricGameVersion>>()
+ .await?;
+ Ok(resp)
+}
+
+/// Fetch all available Fabric loader versions.
+///
+/// # Returns
+/// A list of all Fabric loader versions, ordered by build number (newest first).
+pub async fn fetch_loader_versions(
+) -> Result<Vec<FabricLoaderVersion>, Box<dyn Error + Send + Sync>> {
+ let url = format!("{}/versions/loader", FABRIC_META_URL);
+ let resp = reqwest::get(&url)
+ .await?
+ .json::<Vec<FabricLoaderVersion>>()
+ .await?;
+ Ok(resp)
+}
+
+/// Fetch Fabric loader versions available for a specific Minecraft version.
+///
+/// # Arguments
+/// * `game_version` - The Minecraft version (e.g., "1.20.4")
+///
+/// # Returns
+/// A list of loader entries with full metadata for the specified game version.
+pub async fn fetch_loaders_for_game_version(
+ game_version: &str,
+) -> Result<Vec<FabricLoaderEntry>, Box<dyn Error + Send + Sync>> {
+ let url = format!("{}/versions/loader/{}", FABRIC_META_URL, game_version);
+ let resp = reqwest::get(&url)
+ .await?
+ .json::<Vec<FabricLoaderEntry>>()
+ .await?;
+ Ok(resp)
+}
+
+/// Fetch the version JSON profile for a specific Fabric loader + game version combination.
+///
+/// # Arguments
+/// * `game_version` - The Minecraft version (e.g., "1.20.4")
+/// * `loader_version` - The Fabric loader version (e.g., "0.15.6")
+///
+/// # Returns
+/// The raw version JSON as a `serde_json::Value` that can be saved to the versions directory.
+pub async fn fetch_version_profile(
+ game_version: &str,
+ loader_version: &str,
+) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
+ let url = format!(
+ "{}/versions/loader/{}/{}/profile/json",
+ FABRIC_META_URL, game_version, loader_version
+ );
+ let resp = reqwest::get(&url)
+ .await?
+ .json::<serde_json::Value>()
+ .await?;
+ Ok(resp)
+}
+
+/// Generate the version ID for a Fabric installation.
+///
+/// # Arguments
+/// * `game_version` - The Minecraft version
+/// * `loader_version` - The Fabric loader version
+///
+/// # Returns
+/// The version ID string (e.g., "fabric-loader-0.15.6-1.20.4")
+pub fn generate_version_id(game_version: &str, loader_version: &str) -> String {
+ format!("fabric-loader-{}-{}", loader_version, game_version)
+}
+
+/// Install Fabric loader for a specific Minecraft version.
+///
+/// This creates the version JSON file in the versions directory.
+/// The actual library downloads happen during game launch.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `game_version` - The Minecraft version (e.g., "1.20.4")
+/// * `loader_version` - The Fabric loader version (e.g., "0.15.6")
+///
+/// # Returns
+/// Information about the installed version.
+pub async fn install_fabric(
+ game_dir: &PathBuf,
+ game_version: &str,
+ loader_version: &str,
+) -> Result<InstalledFabricVersion, Box<dyn Error + Send + Sync>> {
+ // Fetch the version profile from Fabric Meta
+ let profile = fetch_version_profile(game_version, loader_version).await?;
+
+ // Get the version ID from the profile or generate it
+ let version_id = profile
+ .get("id")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| generate_version_id(game_version, loader_version));
+
+ // Create the version directory
+ let version_dir = game_dir.join("versions").join(&version_id);
+ tokio::fs::create_dir_all(&version_dir).await?;
+
+ // Write the version JSON
+ let json_path = version_dir.join(format!("{}.json", version_id));
+ let json_content = serde_json::to_string_pretty(&profile)?;
+ tokio::fs::write(&json_path, json_content).await?;
+
+ Ok(InstalledFabricVersion {
+ id: version_id,
+ minecraft_version: game_version.to_string(),
+ loader_version: loader_version.to_string(),
+ path: json_path,
+ })
+}
+
+/// Check if Fabric is installed for a specific version combination.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `game_version` - The Minecraft version
+/// * `loader_version` - The Fabric loader version
+///
+/// # Returns
+/// `true` if the version JSON exists, `false` otherwise.
+pub fn is_fabric_installed(game_dir: &PathBuf, game_version: &str, loader_version: &str) -> bool {
+ let version_id = generate_version_id(game_version, loader_version);
+ let json_path = game_dir
+ .join("versions")
+ .join(&version_id)
+ .join(format!("{}.json", version_id));
+ json_path.exists()
+}
+
+/// List all installed Fabric versions in the game directory.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+///
+/// # Returns
+/// A list of installed Fabric version IDs.
+pub async fn list_installed_fabric_versions(
+ game_dir: &PathBuf,
+) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
+ let versions_dir = game_dir.join("versions");
+ let mut installed = Vec::new();
+
+ if !versions_dir.exists() {
+ return Ok(installed);
+ }
+
+ let mut entries = tokio::fs::read_dir(&versions_dir).await?;
+ while let Some(entry) = entries.next_entry().await? {
+ let name = entry.file_name().to_string_lossy().to_string();
+ if name.starts_with("fabric-loader-") {
+ // Verify the JSON file exists
+ let json_path = entry.path().join(format!("{}.json", name));
+ if json_path.exists() {
+ installed.push(name);
+ }
+ }
+ }
+
+ Ok(installed)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_generate_version_id() {
+ assert_eq!(
+ generate_version_id("1.20.4", "0.15.6"),
+ "fabric-loader-0.15.6-1.20.4"
+ );
+ }
+}
diff --git a/src-tauri/src/core/forge.rs b/src-tauri/src/core/forge.rs
new file mode 100644
index 0000000..0f17bcc
--- /dev/null
+++ b/src-tauri/src/core/forge.rs
@@ -0,0 +1,336 @@
+//! Forge Loader support module.
+//!
+//! This module provides functionality to:
+//! - Fetch available Forge versions from the Forge promotions API
+//! - Install Forge loader for a specific Minecraft version
+//!
+//! Note: Forge installation is more complex than Fabric, especially for versions 1.13+.
+//! This implementation focuses on the basic JSON generation approach.
+//! For full Forge 1.13+ support, processor execution would need to be implemented.
+
+use serde::{Deserialize, Serialize};
+use std::error::Error;
+use std::path::PathBuf;
+
+const FORGE_PROMOTIONS_URL: &str =
+ "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json";
+const FORGE_MAVEN_URL: &str = "https://maven.minecraftforge.net/";
+
+/// Represents a Forge version entry.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct ForgeVersion {
+ pub version: String,
+ pub minecraft_version: String,
+ #[serde(default)]
+ pub recommended: bool,
+ #[serde(default)]
+ pub latest: bool,
+}
+
+/// Forge promotions response from the API.
+#[derive(Debug, Deserialize)]
+struct ForgePromotions {
+ promos: std::collections::HashMap<String, String>,
+}
+
+/// Information about an installed Forge version.
+#[derive(Debug, Serialize, Clone)]
+pub struct InstalledForgeVersion {
+ pub id: String,
+ pub minecraft_version: String,
+ pub forge_version: String,
+ pub path: PathBuf,
+}
+
+/// Fetch all Minecraft versions supported by Forge.
+///
+/// # Returns
+/// A list of Minecraft version strings that have Forge available.
+pub async fn fetch_supported_game_versions() -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
+ let promos = fetch_promotions().await?;
+
+ let mut versions: Vec<String> = promos
+ .promos
+ .keys()
+ .filter_map(|key| {
+ // Keys are like "1.20.4-latest", "1.20.4-recommended"
+ let parts: Vec<&str> = key.split('-').collect();
+ if parts.len() >= 2 {
+ Some(parts[0].to_string())
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ // Deduplicate and sort
+ versions.sort();
+ versions.dedup();
+ versions.reverse(); // Newest first
+
+ Ok(versions)
+}
+
+/// Fetch Forge promotions data.
+async fn fetch_promotions() -> Result<ForgePromotions, Box<dyn Error + Send + Sync>> {
+ let resp = reqwest::get(FORGE_PROMOTIONS_URL)
+ .await?
+ .json::<ForgePromotions>()
+ .await?;
+ Ok(resp)
+}
+
+/// Fetch available Forge versions for a specific Minecraft version.
+///
+/// # Arguments
+/// * `game_version` - The Minecraft version (e.g., "1.20.4")
+///
+/// # Returns
+/// A list of Forge versions available for the specified game version.
+pub async fn fetch_forge_versions(
+ game_version: &str,
+) -> Result<Vec<ForgeVersion>, Box<dyn Error + Send + Sync>> {
+ let promos = fetch_promotions().await?;
+ let mut versions = Vec::new();
+
+ // Look for both latest and recommended
+ let latest_key = format!("{}-latest", game_version);
+ let recommended_key = format!("{}-recommended", game_version);
+
+ if let Some(latest) = promos.promos.get(&latest_key) {
+ versions.push(ForgeVersion {
+ version: latest.clone(),
+ minecraft_version: game_version.to_string(),
+ recommended: false,
+ latest: true,
+ });
+ }
+
+ if let Some(recommended) = promos.promos.get(&recommended_key) {
+ // Don't duplicate if recommended == latest
+ if !versions.iter().any(|v| v.version == *recommended) {
+ versions.push(ForgeVersion {
+ version: recommended.clone(),
+ minecraft_version: game_version.to_string(),
+ recommended: true,
+ latest: false,
+ });
+ } else {
+ // Mark the existing one as both
+ if let Some(v) = versions.iter_mut().find(|v| v.version == *recommended) {
+ v.recommended = true;
+ }
+ }
+ }
+
+ Ok(versions)
+}
+
+/// Generate the version ID for a Forge installation.
+///
+/// # Arguments
+/// * `game_version` - The Minecraft version
+/// * `forge_version` - The Forge version
+///
+/// # Returns
+/// The version ID string (e.g., "1.20.4-forge-49.0.38")
+pub fn generate_version_id(game_version: &str, forge_version: &str) -> String {
+ format!("{}-forge-{}", game_version, forge_version)
+}
+
+/// Install Forge for a specific Minecraft version.
+///
+/// Note: This creates a basic version JSON. For Forge 1.13+, the full installation
+/// requires running the Forge installer processors, which is not yet implemented.
+/// This basic implementation works for legacy Forge versions (<1.13) and creates
+/// the structure needed for modern Forge (libraries will need to be downloaded
+/// separately).
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `game_version` - The Minecraft version (e.g., "1.20.4")
+/// * `forge_version` - The Forge version (e.g., "49.0.38")
+///
+/// # Returns
+/// Information about the installed version.
+pub async fn install_forge(
+ game_dir: &PathBuf,
+ game_version: &str,
+ forge_version: &str,
+) -> Result<InstalledForgeVersion, Box<dyn Error + Send + Sync>> {
+ let version_id = generate_version_id(game_version, forge_version);
+
+ // Create basic version JSON structure
+ // Note: This is a simplified version. Full Forge installation requires
+ // downloading the installer and running processors.
+ let version_json = create_forge_version_json(game_version, forge_version)?;
+
+ // Create the version directory
+ let version_dir = game_dir.join("versions").join(&version_id);
+ tokio::fs::create_dir_all(&version_dir).await?;
+
+ // Write the version JSON
+ let json_path = version_dir.join(format!("{}.json", version_id));
+ let json_content = serde_json::to_string_pretty(&version_json)?;
+ tokio::fs::write(&json_path, json_content).await?;
+
+ Ok(InstalledForgeVersion {
+ id: version_id,
+ minecraft_version: game_version.to_string(),
+ forge_version: forge_version.to_string(),
+ path: json_path,
+ })
+}
+
+/// Create a basic Forge version JSON.
+///
+/// This creates a minimal version JSON that inherits from vanilla and adds
+/// the Forge libraries. For full functionality with Forge 1.13+, the installer
+/// would need to be run to patch the game.
+fn create_forge_version_json(
+ game_version: &str,
+ forge_version: &str,
+) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
+ let version_id = generate_version_id(game_version, forge_version);
+ let forge_maven_coord = format!(
+ "net.minecraftforge:forge:{}-{}",
+ game_version, forge_version
+ );
+
+ // Determine main class based on version
+ // Forge 1.13+ uses different launchers
+ let (main_class, libraries) = if is_modern_forge(game_version) {
+ // Modern Forge (1.13+) uses cpw.mods.bootstraplauncher
+ (
+ "cpw.mods.bootstraplauncher.BootstrapLauncher".to_string(),
+ vec![
+ create_library_entry(&forge_maven_coord, Some(FORGE_MAVEN_URL)),
+ create_library_entry(
+ &format!(
+ "net.minecraftforge:forge:{}-{}:universal",
+ game_version, forge_version
+ ),
+ Some(FORGE_MAVEN_URL),
+ ),
+ ],
+ )
+ } else {
+ // Legacy Forge uses LaunchWrapper
+ (
+ "net.minecraft.launchwrapper.Launch".to_string(),
+ vec![
+ create_library_entry(&forge_maven_coord, Some(FORGE_MAVEN_URL)),
+ create_library_entry("net.minecraft:launchwrapper:1.12", None),
+ ],
+ )
+ };
+
+ let json = serde_json::json!({
+ "id": version_id,
+ "inheritsFrom": game_version,
+ "type": "release",
+ "mainClass": main_class,
+ "libraries": libraries,
+ "arguments": {
+ "game": [],
+ "jvm": []
+ }
+ });
+
+ Ok(json)
+}
+
+/// Create a library entry for the version JSON.
+fn create_library_entry(name: &str, maven_url: Option<&str>) -> serde_json::Value {
+ let mut entry = serde_json::json!({
+ "name": name
+ });
+
+ if let Some(url) = maven_url {
+ entry["url"] = serde_json::Value::String(url.to_string());
+ }
+
+ entry
+}
+
+/// Check if the Minecraft version uses modern Forge (1.13+).
+fn is_modern_forge(game_version: &str) -> bool {
+ let parts: Vec<&str> = game_version.split('.').collect();
+ if parts.len() >= 2 {
+ if let (Ok(major), Ok(minor)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
+ return major > 1 || (major == 1 && minor >= 13);
+ }
+ }
+ false
+}
+
+/// Check if Forge is installed for a specific version combination.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `game_version` - The Minecraft version
+/// * `forge_version` - The Forge version
+///
+/// # Returns
+/// `true` if the version JSON exists, `false` otherwise.
+pub fn is_forge_installed(game_dir: &PathBuf, game_version: &str, forge_version: &str) -> bool {
+ let version_id = generate_version_id(game_version, forge_version);
+ let json_path = game_dir
+ .join("versions")
+ .join(&version_id)
+ .join(format!("{}.json", version_id));
+ json_path.exists()
+}
+
+/// List all installed Forge versions in the game directory.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+///
+/// # Returns
+/// A list of installed Forge version IDs.
+pub async fn list_installed_forge_versions(
+ game_dir: &PathBuf,
+) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
+ let versions_dir = game_dir.join("versions");
+ let mut installed = Vec::new();
+
+ if !versions_dir.exists() {
+ return Ok(installed);
+ }
+
+ let mut entries = tokio::fs::read_dir(&versions_dir).await?;
+ while let Some(entry) = entries.next_entry().await? {
+ let name = entry.file_name().to_string_lossy().to_string();
+ if name.contains("-forge-") {
+ // Verify the JSON file exists
+ let json_path = entry.path().join(format!("{}.json", name));
+ if json_path.exists() {
+ installed.push(name);
+ }
+ }
+ }
+
+ Ok(installed)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_generate_version_id() {
+ assert_eq!(
+ generate_version_id("1.20.4", "49.0.38"),
+ "1.20.4-forge-49.0.38"
+ );
+ }
+
+ #[test]
+ fn test_is_modern_forge() {
+ assert!(!is_modern_forge("1.12.2"));
+ assert!(is_modern_forge("1.13"));
+ assert!(is_modern_forge("1.20.4"));
+ assert!(is_modern_forge("1.21"));
+ }
+}
diff --git a/src-tauri/src/core/game_version.rs b/src-tauri/src/core/game_version.rs
index 572882f..c62e232 100644
--- a/src-tauri/src/core/game_version.rs
+++ b/src-tauri/src/core/game_version.rs
@@ -1,11 +1,15 @@
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
-#[derive(Debug, Deserialize)]
+/// Represents a Minecraft version JSON, supporting both vanilla and modded (Fabric/Forge) formats.
+/// Modded versions use `inheritsFrom` to reference a parent vanilla version.
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct GameVersion {
pub id: String,
- pub downloads: Downloads,
+ /// Optional for mod loaders that inherit from vanilla
+ pub downloads: Option<Downloads>,
+ /// Optional for mod loaders that inherit from vanilla
#[serde(rename = "assetIndex")]
- pub asset_index: AssetIndex,
+ pub asset_index: Option<AssetIndex>,
pub libraries: Vec<Library>,
#[serde(rename = "mainClass")]
pub main_class: String,
@@ -14,66 +18,77 @@ pub struct GameVersion {
pub arguments: Option<Arguments>,
#[serde(rename = "javaVersion")]
pub java_version: Option<JavaVersion>,
+ /// For mod loaders: the vanilla version this inherits from
+ #[serde(rename = "inheritsFrom")]
+ pub inherits_from: Option<String>,
+ /// Fabric/Forge may specify a custom assets version
+ pub assets: Option<String>,
+ /// Release type (release, snapshot, old_beta, etc.)
+ #[serde(rename = "type")]
+ pub version_type: Option<String>,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Downloads {
pub client: DownloadArtifact,
pub server: Option<DownloadArtifact>,
}
-#[derive(Debug, Deserialize, Clone)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct DownloadArtifact {
- pub sha1: String,
- pub size: u64,
+ pub sha1: Option<String>,
+ pub size: Option<u64>,
pub url: String,
pub path: Option<String>,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AssetIndex {
pub id: String,
pub sha1: String,
pub size: u64,
pub url: String,
#[serde(rename = "totalSize")]
- pub total_size: u64,
+ pub total_size: Option<u64>,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Library {
pub downloads: Option<LibraryDownloads>,
pub name: String,
pub rules: Option<Vec<Rule>>,
pub natives: Option<serde_json::Value>,
+ /// Maven repository URL for mod loader libraries
+ pub url: Option<String>,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Rule {
pub action: String, // "allow" or "disallow"
pub os: Option<OsRule>,
+ pub features: Option<serde_json::Value>, // Feature-based rules (e.g., is_demo_user, has_quick_plays_support)
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct OsRule {
pub name: Option<String>, // "linux", "osx", "windows"
pub version: Option<String>, // Regex
pub arch: Option<String>, // "x86"
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LibraryDownloads {
pub artifact: Option<DownloadArtifact>,
pub classifiers: Option<serde_json::Value>, // Complex, simplifying for now
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Arguments {
pub game: Option<serde_json::Value>,
pub jvm: Option<serde_json::Value>,
}
-#[derive(Debug, Deserialize)]
+#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct JavaVersion {
pub component: String,
#[serde(rename = "majorVersion")]
diff --git a/src-tauri/src/core/java.rs b/src-tauri/src/core/java.rs
index a622d60..b223cd2 100644
--- a/src-tauri/src/core/java.rs
+++ b/src-tauri/src/core/java.rs
@@ -356,7 +356,10 @@ pub fn detect_java_installations() -> Vec<JavaInstallation> {
for candidate in candidates {
if let Some(java) = check_java_installation(&candidate) {
// Avoid duplicates
- if !installations.iter().any(|j: &JavaInstallation| j.path == java.path) {
+ if !installations
+ .iter()
+ .any(|j: &JavaInstallation| j.path == java.path)
+ {
installations.push(java);
}
}
@@ -460,7 +463,9 @@ fn get_java_candidates() -> Vec<PathBuf> {
if homebrew_arm.exists() {
if let Ok(entries) = std::fs::read_dir(&homebrew_arm) {
for entry in entries.flatten() {
- let java_path = entry.path().join("libexec/openjdk.jdk/Contents/Home/bin/java");
+ let java_path = entry
+ .path()
+ .join("libexec/openjdk.jdk/Contents/Home/bin/java");
if java_path.exists() {
candidates.push(java_path);
}
@@ -472,8 +477,10 @@ fn get_java_candidates() -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
{
// Windows Java paths
- let program_files = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string());
- let program_files_x86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".to_string());
+ let program_files =
+ std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string());
+ let program_files_x86 = std::env::var("ProgramFiles(x86)")
+ .unwrap_or_else(|_| "C:\\Program Files (x86)".to_string());
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_default();
let win_paths = [
@@ -525,14 +532,11 @@ fn get_java_candidates() -> Vec<PathBuf> {
/// Check a specific Java installation and get its version info
fn check_java_installation(path: &PathBuf) -> Option<JavaInstallation> {
- let output = Command::new(path)
- .arg("-version")
- .output()
- .ok()?;
+ let output = Command::new(path).arg("-version").output().ok()?;
// Java outputs version info to stderr
let version_output = String::from_utf8_lossy(&output.stderr);
-
+
// Parse version string (e.g., "openjdk version \"17.0.1\"" or "java version \"1.8.0_301\"")
let version = parse_version_string(&version_output)?;
let is_64bit = version_output.contains("64-Bit");
@@ -579,7 +583,7 @@ fn parse_java_version(version: &str) -> u32 {
/// Get the best Java for a specific Minecraft version
pub fn get_recommended_java(required_major_version: Option<u64>) -> Option<JavaInstallation> {
let installations = detect_java_installations();
-
+
if let Some(required) = required_major_version {
// Find exact match or higher
installations.into_iter().find(|java| {
diff --git a/src-tauri/src/core/manifest.rs b/src-tauri/src/core/manifest.rs
index 11ebc5a..bae87c9 100644
--- a/src-tauri/src/core/manifest.rs
+++ b/src-tauri/src/core/manifest.rs
@@ -1,5 +1,8 @@
use serde::{Deserialize, Serialize};
use std::error::Error;
+use std::path::PathBuf;
+
+use crate::core::game_version::GameVersion;
#[derive(Debug, Deserialize, Serialize)]
pub struct VersionManifest {
@@ -24,8 +27,157 @@ pub struct Version {
pub release_time: String,
}
-pub async fn fetch_version_manifest() -> Result<VersionManifest, Box<dyn Error>> {
+pub async fn fetch_version_manifest() -> Result<VersionManifest, Box<dyn Error + Send + Sync>> {
let url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
let resp = reqwest::get(url).await?.json::<VersionManifest>().await?;
Ok(resp)
}
+
+/// Load a version JSON from the local versions directory.
+///
+/// This is used for loading both vanilla and modded versions that have been
+/// previously downloaded or installed.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `version_id` - The version ID to load
+///
+/// # Returns
+/// The parsed `GameVersion` if found, or an error if not found.
+pub async fn load_local_version(
+ game_dir: &PathBuf,
+ version_id: &str,
+) -> Result<GameVersion, Box<dyn Error + Send + Sync>> {
+ let json_path = game_dir
+ .join("versions")
+ .join(version_id)
+ .join(format!("{}.json", version_id));
+
+ if !json_path.exists() {
+ return Err(format!("Version {} not found locally", version_id).into());
+ }
+
+ let content = tokio::fs::read_to_string(&json_path).await?;
+ let version: GameVersion = serde_json::from_str(&content)?;
+ Ok(version)
+}
+
+/// Fetch a version JSON from Mojang's servers.
+///
+/// # Arguments
+/// * `version_id` - The version ID to fetch
+///
+/// # Returns
+/// The parsed `GameVersion` from Mojang's API.
+pub async fn fetch_vanilla_version(
+ version_id: &str,
+) -> Result<GameVersion, Box<dyn Error + Send + Sync>> {
+ // First, get the manifest to find the version URL
+ let manifest = fetch_version_manifest().await?;
+
+ let version_entry = manifest
+ .versions
+ .iter()
+ .find(|v| v.id == version_id)
+ .ok_or_else(|| format!("Version {} not found in manifest", version_id))?;
+
+ // Fetch the actual version JSON
+ let resp = reqwest::get(&version_entry.url)
+ .await?
+ .json::<GameVersion>()
+ .await?;
+
+ Ok(resp)
+}
+
+/// Load a version, checking local first, then fetching from remote if needed.
+///
+/// For modded versions (those with `inheritsFrom`), this will also resolve
+/// the inheritance chain.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `version_id` - The version ID to load
+///
+/// # Returns
+/// A fully resolved `GameVersion` ready for launching.
+pub async fn load_version(
+ game_dir: &PathBuf,
+ version_id: &str,
+) -> Result<GameVersion, Box<dyn Error + Send + Sync>> {
+ // Try loading from local first
+ let mut version = match load_local_version(game_dir, version_id).await {
+ Ok(v) => v,
+ Err(_) => {
+ // Not found locally, try fetching from Mojang
+ fetch_vanilla_version(version_id).await?
+ }
+ };
+
+ // If this version inherits from another, resolve the inheritance iteratively
+ while let Some(parent_id) = version.inherits_from.clone() {
+ // Load the parent version
+ let parent = match load_local_version(game_dir, &parent_id).await {
+ Ok(v) => v,
+ Err(_) => fetch_vanilla_version(&parent_id).await?,
+ };
+
+ // Merge child into parent
+ version = crate::core::version_merge::merge_versions(version, parent);
+ }
+
+ Ok(version)
+}
+
+/// Save a version JSON to the local versions directory.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+/// * `version` - The version to save
+///
+/// # Returns
+/// The path where the JSON was saved.
+pub async fn save_local_version(
+ game_dir: &PathBuf,
+ version: &GameVersion,
+) -> Result<PathBuf, Box<dyn Error + Send + Sync>> {
+ let version_dir = game_dir.join("versions").join(&version.id);
+ tokio::fs::create_dir_all(&version_dir).await?;
+
+ let json_path = version_dir.join(format!("{}.json", version.id));
+ let content = serde_json::to_string_pretty(version)?;
+ tokio::fs::write(&json_path, content).await?;
+
+ Ok(json_path)
+}
+
+/// List all locally installed versions.
+///
+/// # Arguments
+/// * `game_dir` - The .minecraft directory path
+///
+/// # Returns
+/// A list of version IDs found in the versions directory.
+pub async fn list_local_versions(
+ game_dir: &PathBuf,
+) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
+ let versions_dir = game_dir.join("versions");
+ let mut versions = Vec::new();
+
+ if !versions_dir.exists() {
+ return Ok(versions);
+ }
+
+ let mut entries = tokio::fs::read_dir(&versions_dir).await?;
+ while let Some(entry) = entries.next_entry().await? {
+ if entry.file_type().await?.is_dir() {
+ let name = entry.file_name().to_string_lossy().to_string();
+ let json_path = entry.path().join(format!("{}.json", name));
+ if json_path.exists() {
+ versions.push(name);
+ }
+ }
+ }
+
+ Ok(versions)
+}
diff --git a/src-tauri/src/core/maven.rs b/src-tauri/src/core/maven.rs
new file mode 100644
index 0000000..8c89768
--- /dev/null
+++ b/src-tauri/src/core/maven.rs
@@ -0,0 +1,263 @@
+//! Maven coordinate parsing and URL construction utilities.
+//!
+//! Mod loaders like Fabric and Forge specify libraries using Maven coordinates
+//! (e.g., `net.fabricmc:fabric-loader:0.14.21`) instead of direct download URLs.
+//! This module provides utilities to parse these coordinates and construct
+//! download URLs for various Maven repositories.
+
+use std::path::PathBuf;
+
+/// Known Maven repository URLs for mod loaders
+pub const MAVEN_CENTRAL: &str = "https://repo1.maven.org/maven2/";
+pub const FABRIC_MAVEN: &str = "https://maven.fabricmc.net/";
+pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net/";
+pub const MOJANG_LIBRARIES: &str = "https://libraries.minecraft.net/";
+
+/// Represents a parsed Maven coordinate.
+///
+/// Maven coordinates follow the format: `group:artifact:version[:classifier][@extension]`
+/// Examples:
+/// - `net.fabricmc:fabric-loader:0.14.21`
+/// - `org.lwjgl:lwjgl:3.3.1:natives-linux`
+/// - `com.example:artifact:1.0@zip`
+#[derive(Debug, Clone, PartialEq)]
+pub struct MavenCoordinate {
+ pub group: String,
+ pub artifact: String,
+ pub version: String,
+ pub classifier: Option<String>,
+ pub extension: String,
+}
+
+impl MavenCoordinate {
+ /// Parse a Maven coordinate string.
+ ///
+ /// # Arguments
+ /// * `coord` - A string in the format `group:artifact:version[:classifier][@extension]`
+ ///
+ /// # Returns
+ /// * `Some(MavenCoordinate)` if parsing succeeds
+ /// * `None` if the format is invalid
+ ///
+ /// # Examples
+ /// ```
+ /// let coord = MavenCoordinate::parse("net.fabricmc:fabric-loader:0.14.21").unwrap();
+ /// assert_eq!(coord.group, "net.fabricmc");
+ /// assert_eq!(coord.artifact, "fabric-loader");
+ /// assert_eq!(coord.version, "0.14.21");
+ /// ```
+ pub fn parse(coord: &str) -> Option<Self> {
+ // Handle extension suffix (e.g., @zip)
+ let (coord_part, extension) = if let Some(at_idx) = coord.rfind('@') {
+ let ext = &coord[at_idx + 1..];
+ let base = &coord[..at_idx];
+ (base, ext.to_string())
+ } else {
+ (coord, "jar".to_string())
+ };
+
+ let parts: Vec<&str> = coord_part.split(':').collect();
+
+ match parts.len() {
+ 3 => Some(MavenCoordinate {
+ group: parts[0].to_string(),
+ artifact: parts[1].to_string(),
+ version: parts[2].to_string(),
+ classifier: None,
+ extension,
+ }),
+ 4 => Some(MavenCoordinate {
+ group: parts[0].to_string(),
+ artifact: parts[1].to_string(),
+ version: parts[2].to_string(),
+ classifier: Some(parts[3].to_string()),
+ extension,
+ }),
+ _ => None,
+ }
+ }
+
+ /// Get the relative path for this artifact in a Maven repository.
+ ///
+ /// # Returns
+ /// The path as `group/artifact/version/artifact-version[-classifier].extension`
+ ///
+ /// # Examples
+ /// ```
+ /// let coord = MavenCoordinate::parse("net.fabricmc:fabric-loader:0.14.21").unwrap();
+ /// assert_eq!(coord.to_path(), "net/fabricmc/fabric-loader/0.14.21/fabric-loader-0.14.21.jar");
+ /// ```
+ pub fn to_path(&self) -> String {
+ let group_path = self.group.replace('.', "/");
+ let filename = match &self.classifier {
+ Some(classifier) => {
+ format!(
+ "{}-{}-{}.{}",
+ self.artifact, self.version, classifier, self.extension
+ )
+ }
+ None => {
+ format!("{}-{}.{}", self.artifact, self.version, self.extension)
+ }
+ };
+
+ format!(
+ "{}/{}/{}/{}",
+ group_path, self.artifact, self.version, filename
+ )
+ }
+
+ /// Get the local file path for storing this artifact.
+ ///
+ /// # Arguments
+ /// * `libraries_dir` - The base libraries directory
+ ///
+ /// # Returns
+ /// The full path where the library should be stored
+ pub fn to_local_path(&self, libraries_dir: &PathBuf) -> PathBuf {
+ let rel_path = self.to_path();
+ libraries_dir.join(rel_path.replace('/', std::path::MAIN_SEPARATOR_STR))
+ }
+
+ /// Construct the full download URL for this artifact.
+ ///
+ /// # Arguments
+ /// * `base_url` - The Maven repository base URL (e.g., `https://maven.fabricmc.net/`)
+ ///
+ /// # Returns
+ /// The full URL to download the artifact
+ pub fn to_url(&self, base_url: &str) -> String {
+ let base = base_url.trim_end_matches('/');
+ format!("{}/{}", base, self.to_path())
+ }
+}
+
+/// Resolve the download URL for a library.
+///
+/// This function handles both:
+/// 1. Libraries with explicit download URLs (vanilla Minecraft)
+/// 2. Libraries with only Maven coordinates (Fabric/Forge)
+///
+/// # Arguments
+/// * `name` - The Maven coordinate string
+/// * `explicit_url` - An explicit download URL if provided in the library JSON
+/// * `maven_url` - A custom Maven repository URL from the library JSON
+///
+/// # Returns
+/// The resolved download URL
+pub fn resolve_library_url(
+ name: &str,
+ explicit_url: Option<&str>,
+ maven_url: Option<&str>,
+) -> Option<String> {
+ // If there's an explicit URL, use it
+ if let Some(url) = explicit_url {
+ return Some(url.to_string());
+ }
+
+ // Parse the Maven coordinate
+ let coord = MavenCoordinate::parse(name)?;
+
+ // Determine the base Maven URL
+ let base_url = maven_url.unwrap_or_else(|| {
+ // Guess the repository based on group
+ if coord.group.starts_with("net.fabricmc") {
+ FABRIC_MAVEN
+ } else if coord.group.starts_with("net.minecraftforge")
+ || coord.group.starts_with("cpw.mods")
+ {
+ FORGE_MAVEN
+ } else {
+ MOJANG_LIBRARIES
+ }
+ });
+
+ Some(coord.to_url(base_url))
+}
+
+/// Get the local storage path for a library.
+///
+/// # Arguments
+/// * `name` - The Maven coordinate string
+/// * `libraries_dir` - The base libraries directory
+///
+/// # Returns
+/// The path where the library should be stored
+pub fn get_library_path(name: &str, libraries_dir: &PathBuf) -> Option<PathBuf> {
+ let coord = MavenCoordinate::parse(name)?;
+ Some(coord.to_local_path(libraries_dir))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_simple_coordinate() {
+ let coord = MavenCoordinate::parse("net.fabricmc:fabric-loader:0.14.21").unwrap();
+ assert_eq!(coord.group, "net.fabricmc");
+ assert_eq!(coord.artifact, "fabric-loader");
+ assert_eq!(coord.version, "0.14.21");
+ assert_eq!(coord.classifier, None);
+ assert_eq!(coord.extension, "jar");
+ }
+
+ #[test]
+ fn test_parse_with_classifier() {
+ let coord = MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.1:natives-linux").unwrap();
+ assert_eq!(coord.group, "org.lwjgl");
+ assert_eq!(coord.artifact, "lwjgl");
+ assert_eq!(coord.version, "3.3.1");
+ assert_eq!(coord.classifier, Some("natives-linux".to_string()));
+ assert_eq!(coord.extension, "jar");
+ }
+
+ #[test]
+ fn test_parse_with_extension() {
+ let coord = MavenCoordinate::parse("com.example:artifact:1.0@zip").unwrap();
+ assert_eq!(coord.extension, "zip");
+ }
+
+ #[test]
+ fn test_to_path() {
+ let coord = MavenCoordinate::parse("net.fabricmc:fabric-loader:0.14.21").unwrap();
+ assert_eq!(
+ coord.to_path(),
+ "net/fabricmc/fabric-loader/0.14.21/fabric-loader-0.14.21.jar"
+ );
+ }
+
+ #[test]
+ fn test_to_path_with_classifier() {
+ let coord = MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.1:natives-linux").unwrap();
+ assert_eq!(
+ coord.to_path(),
+ "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-linux.jar"
+ );
+ }
+
+ #[test]
+ fn test_to_url() {
+ let coord = MavenCoordinate::parse("net.fabricmc:fabric-loader:0.14.21").unwrap();
+ assert_eq!(
+ coord.to_url(FABRIC_MAVEN),
+ "https://maven.fabricmc.net/net/fabricmc/fabric-loader/0.14.21/fabric-loader-0.14.21.jar"
+ );
+ }
+
+ #[test]
+ fn test_resolve_library_url_explicit() {
+ let url = resolve_library_url(
+ "net.fabricmc:fabric-loader:0.14.21",
+ Some("https://example.com/lib.jar"),
+ None,
+ );
+ assert_eq!(url, Some("https://example.com/lib.jar".to_string()));
+ }
+
+ #[test]
+ fn test_resolve_library_url_fabric() {
+ let url = resolve_library_url("net.fabricmc:fabric-loader:0.14.21", None, None);
+ assert!(url.unwrap().starts_with(FABRIC_MAVEN));
+ }
+}
diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs
index 475a304..3c09a76 100644
--- a/src-tauri/src/core/mod.rs
+++ b/src-tauri/src/core/mod.rs
@@ -2,7 +2,11 @@ pub mod account_storage;
pub mod auth;
pub mod config;
pub mod downloader;
+pub mod fabric;
+pub mod forge;
pub mod game_version;
pub mod java;
pub mod manifest;
+pub mod maven;
pub mod rules;
+pub mod version_merge;
diff --git a/src-tauri/src/core/rules.rs b/src-tauri/src/core/rules.rs
index 877982a..71abda5 100644
--- a/src-tauri/src/core/rules.rs
+++ b/src-tauri/src/core/rules.rs
@@ -47,6 +47,13 @@ pub fn is_library_allowed(rules: &Option<Vec<Rule>>) -> bool {
}
fn rule_matches(rule: &Rule) -> bool {
+ // Feature-based rules (e.g., is_demo_user, has_quick_plays_support, is_quick_play_singleplayer)
+ // are not implemented in this launcher, so we return false for any rule that has features.
+ // This prevents adding arguments like --demo, --quickPlayPath, --quickPlaySingleplayer, etc.
+ if rule.features.is_some() {
+ return false;
+ }
+
match &rule.os {
None => true, // No OS condition means it applies to all
Some(os_rule) => {
diff --git a/src-tauri/src/core/version_merge.rs b/src-tauri/src/core/version_merge.rs
new file mode 100644
index 0000000..fe6b3cd
--- /dev/null
+++ b/src-tauri/src/core/version_merge.rs
@@ -0,0 +1,244 @@
+//! Version merging utilities for mod loaders.
+//!
+//! Mod loaders like Fabric and Forge create "partial" version JSON files that
+//! inherit from vanilla Minecraft versions via the `inheritsFrom` field.
+//! This module provides functionality to merge these partial versions with
+//! their parent versions to create a complete, launchable version profile.
+
+use crate::core::game_version::{Arguments, GameVersion};
+use std::error::Error;
+
+/// Merge a child version (mod loader) with its parent version (vanilla).
+///
+/// The merging follows these rules:
+/// 1. Child's `mainClass` overrides parent's
+/// 2. Child's libraries are prepended to parent's (mod loader classes take priority)
+/// 3. Arguments are merged (child's additions come after parent's)
+/// 4. Parent provides `downloads`, `assetIndex`, `javaVersion` if child doesn't have them
+///
+/// # Arguments
+/// * `child` - The mod loader version (e.g., Fabric)
+/// * `parent` - The vanilla Minecraft version
+///
+/// # Returns
+/// A merged `GameVersion` that can be used for launching.
+pub fn merge_versions(child: GameVersion, parent: GameVersion) -> GameVersion {
+ // Libraries: child libraries first (mod loader takes priority in classpath)
+ let mut merged_libraries = child.libraries;
+ merged_libraries.extend(parent.libraries);
+
+ // Arguments: merge both game and JVM arguments
+ let merged_arguments = merge_arguments(child.arguments, parent.arguments);
+
+ GameVersion {
+ id: child.id,
+ // Use child's downloads if present, otherwise parent's
+ downloads: child.downloads.or(parent.downloads),
+ // Use child's asset_index if present, otherwise parent's
+ asset_index: child.asset_index.or(parent.asset_index),
+ libraries: merged_libraries,
+ // Child's main class always takes priority (this is the mod loader entry point)
+ main_class: child.main_class,
+ // Prefer child's minecraft_arguments, fall back to parent's
+ minecraft_arguments: child.minecraft_arguments.or(parent.minecraft_arguments),
+ arguments: merged_arguments,
+ // Use child's java_version if specified, otherwise parent's
+ java_version: child.java_version.or(parent.java_version),
+ // Clear inheritsFrom since we've now merged
+ inherits_from: None,
+ // Use child's assets field if present, otherwise parent's
+ assets: child.assets.or(parent.assets),
+ // Use parent's version type if child doesn't specify
+ version_type: child.version_type.or(parent.version_type),
+ }
+}
+
+/// Merge argument objects from child and parent versions.
+///
+/// Both game and JVM arguments are merged, with parent arguments coming first
+/// and child arguments appended (child can add additional arguments).
+fn merge_arguments(child: Option<Arguments>, parent: Option<Arguments>) -> Option<Arguments> {
+ match (child, parent) {
+ (None, None) => None,
+ (Some(c), None) => Some(c),
+ (None, Some(p)) => Some(p),
+ (Some(c), Some(p)) => Some(Arguments {
+ game: merge_json_arrays(p.game, c.game),
+ jvm: merge_json_arrays(p.jvm, c.jvm),
+ }),
+ }
+}
+
+/// Merge two JSON arrays (used for arguments).
+///
+/// Parent array comes first, child array is appended.
+fn merge_json_arrays(
+ parent: Option<serde_json::Value>,
+ child: Option<serde_json::Value>,
+) -> Option<serde_json::Value> {
+ match (parent, child) {
+ (None, None) => None,
+ (Some(p), None) => Some(p),
+ (None, Some(c)) => Some(c),
+ (Some(p), Some(c)) => {
+ if let (serde_json::Value::Array(mut p_arr), serde_json::Value::Array(c_arr)) =
+ (p.clone(), c.clone())
+ {
+ p_arr.extend(c_arr);
+ Some(serde_json::Value::Array(p_arr))
+ } else {
+ // If they're not arrays, prefer child
+ Some(c)
+ }
+ }
+ }
+}
+
+/// Check if a version requires inheritance resolution.
+///
+/// # Arguments
+/// * `version` - The version to check
+///
+/// # Returns
+/// `true` if the version has an `inheritsFrom` field that needs resolution.
+pub fn needs_inheritance_resolution(version: &GameVersion) -> bool {
+ version.inherits_from.is_some()
+}
+
+/// Recursively resolve version inheritance.
+///
+/// This function resolves the entire inheritance chain by loading parent versions
+/// and merging them until a version without `inheritsFrom` is found.
+///
+/// # Arguments
+/// * `version` - The starting version (e.g., a Fabric version)
+/// * `version_loader` - A function that loads a version by ID
+///
+/// # Returns
+/// A fully merged `GameVersion` with all inheritance resolved.
+pub async fn resolve_inheritance<F, Fut>(
+ version: GameVersion,
+ version_loader: F,
+) -> Result<GameVersion, Box<dyn Error + Send + Sync>>
+where
+ F: Fn(String) -> Fut,
+ Fut: std::future::Future<Output = Result<GameVersion, Box<dyn Error + Send + Sync>>>,
+{
+ let mut current = version;
+
+ // Keep resolving until we have no more inheritance
+ while let Some(parent_id) = current.inherits_from.clone() {
+ let parent = version_loader(parent_id).await?;
+ current = merge_versions(current, parent);
+ }
+
+ Ok(current)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::core::game_version::{DownloadArtifact, Downloads, Library};
+
+ fn create_test_library(name: &str) -> Library {
+ Library {
+ name: name.to_string(),
+ downloads: None,
+ rules: None,
+ natives: None,
+ url: None,
+ }
+ }
+
+ #[test]
+ fn test_merge_libraries_order() {
+ let child = GameVersion {
+ id: "fabric-1.20.4".to_string(),
+ downloads: None,
+ asset_index: None,
+ libraries: vec![create_test_library("fabric:loader:1.0")],
+ main_class: "net.fabricmc.loader.launch.knot.KnotClient".to_string(),
+ minecraft_arguments: None,
+ arguments: None,
+ java_version: None,
+ inherits_from: Some("1.20.4".to_string()),
+ assets: None,
+ version_type: None,
+ };
+
+ let parent = GameVersion {
+ id: "1.20.4".to_string(),
+ downloads: Some(Downloads {
+ client: DownloadArtifact {
+ sha1: Some("abc".to_string()),
+ size: Some(1000),
+ url: "https://example.com/client.jar".to_string(),
+ path: None,
+ },
+ server: None,
+ }),
+ asset_index: None,
+ libraries: vec![create_test_library("net.minecraft:client:1.20.4")],
+ main_class: "net.minecraft.client.main.Main".to_string(),
+ minecraft_arguments: None,
+ arguments: None,
+ java_version: None,
+ inherits_from: None,
+ assets: None,
+ version_type: Some("release".to_string()),
+ };
+
+ let merged = merge_versions(child, parent);
+
+ // Child libraries should come first
+ assert_eq!(merged.libraries.len(), 2);
+ assert_eq!(merged.libraries[0].name, "fabric:loader:1.0");
+ assert_eq!(merged.libraries[1].name, "net.minecraft:client:1.20.4");
+
+ // Child main class should override
+ assert_eq!(
+ merged.main_class,
+ "net.fabricmc.loader.launch.knot.KnotClient"
+ );
+
+ // Parent downloads should be used
+ assert!(merged.downloads.is_some());
+
+ // inheritsFrom should be cleared
+ assert!(merged.inherits_from.is_none());
+ }
+
+ #[test]
+ fn test_needs_inheritance_resolution() {
+ let with_inheritance = GameVersion {
+ id: "test".to_string(),
+ downloads: None,
+ asset_index: None,
+ libraries: vec![],
+ main_class: "Main".to_string(),
+ minecraft_arguments: None,
+ arguments: None,
+ java_version: None,
+ inherits_from: Some("1.20.4".to_string()),
+ assets: None,
+ version_type: None,
+ };
+
+ let without_inheritance = GameVersion {
+ id: "test".to_string(),
+ downloads: None,
+ asset_index: None,
+ libraries: vec![],
+ main_class: "Main".to_string(),
+ minecraft_arguments: None,
+ arguments: None,
+ java_version: None,
+ inherits_from: None,
+ assets: None,
+ version_type: None,
+ };
+
+ assert!(needs_inheritance_resolution(&with_inheritance));
+ assert!(!needs_inheritance_resolution(&without_inheritance));
+ }
+}
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 4c3f689..88d614c 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -26,6 +26,12 @@ pub struct MsRefreshTokenState {
pub token: Mutex<Option<String>>,
}
+impl Default for MsRefreshTokenState {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
impl MsRefreshTokenState {
pub fn new() -> Self {
Self {
@@ -34,6 +40,27 @@ impl MsRefreshTokenState {
}
}
+/// Check if a string contains unresolved placeholders in the form ${...}
+///
+/// After the replacement phase, if a string still contains ${...}, it means
+/// that placeholder variable was not found in the replacements map and is
+/// therefore unresolved. We should skip adding such arguments to avoid
+/// passing malformed arguments to the game launcher.
+fn has_unresolved_placeholder(s: &str) -> bool {
+ // Look for the opening sequence
+ if let Some(start_pos) = s.find("${") {
+ // Check if there's a closing brace after the opening sequence
+ if s[start_pos + 2..].find('}').is_some() {
+ // Found a complete ${...} pattern - this is an unresolved placeholder
+ return true;
+ }
+ // Found ${ but no closing } - also treat as unresolved/malformed
+ return true;
+ }
+ // No ${ found - the string is fully resolved
+ false
+}
+
#[tauri::command]
async fn start_game(
window: Window,
@@ -41,7 +68,10 @@ async fn start_game(
config_state: State<'_, core::config::ConfigState>,
version_id: String,
) -> Result<String, String> {
- emit_log!(window, format!("Starting game launch for version: {}", version_id));
+ emit_log!(
+ window,
+ format!("Starting game launch for version: {}", version_id)
+ );
// Check for active account
emit_log!(window, "Checking for active account...".to_string());
@@ -51,16 +81,22 @@ async fn start_game(
.unwrap()
.clone()
.ok_or("No active account found. Please login first.")?;
-
+
let account_type = match &account {
core::auth::Account::Offline(_) => "Offline",
core::auth::Account::Microsoft(_) => "Microsoft",
};
- emit_log!(window, format!("Account found: {} ({})", account.username(), account_type));
+ emit_log!(
+ window,
+ format!("Account found: {} ({})", account.username(), account_type)
+ );
let config = config_state.config.lock().unwrap().clone();
emit_log!(window, format!("Java path: {}", config.java_path));
- emit_log!(window, format!("Memory: {}MB - {}MB", config.min_memory, config.max_memory));
+ emit_log!(
+ window,
+ format!("Memory: {}MB - {}MB", config.min_memory, config.max_memory)
+ );
// Get App Data Directory (e.g., ~/.local/share/com.dropout.launcher or similar)
// The identifier is set in tauri.conf.json.
@@ -78,45 +114,50 @@ async fn start_game(
emit_log!(window, format!("Game directory: {:?}", game_dir));
- // 1. Fetch manifest to find the version URL
- emit_log!(window, "Fetching version manifest...".to_string());
- let manifest = core::manifest::fetch_version_manifest()
- .await
- .map_err(|e| e.to_string())?;
- emit_log!(window, format!("Found {} versions in manifest", manifest.versions.len()));
-
- // Find the version info
- let version_info = manifest
- .versions
- .iter()
- .find(|v| v.id == version_id)
- .ok_or_else(|| format!("Version {} not found in manifest", version_id))?;
-
- // 2. Fetch specific version JSON (client.jar info)
- emit_log!(window, format!("Fetching version details for {}...", version_id));
- let version_url = &version_info.url;
- let version_details: core::game_version::GameVersion = reqwest::get(version_url)
- .await
- .map_err(|e| e.to_string())?
- .json()
+ // 1. Load version (supports both vanilla and modded versions with inheritance)
+ emit_log!(
+ window,
+ format!("Loading version details for {}...", version_id)
+ );
+
+ let version_details = core::manifest::load_version(&game_dir, &version_id)
.await
.map_err(|e| e.to_string())?;
- emit_log!(window, format!("Version details loaded: main class = {}", version_details.main_class));
- // 3. Prepare download tasks
+ emit_log!(
+ window,
+ format!(
+ "Version details loaded: main class = {}",
+ version_details.main_class
+ )
+ );
+
+ // Determine the actual minecraft version for client.jar
+ // (for modded versions, this is the parent vanilla version)
+ let minecraft_version = version_details
+ .inherits_from
+ .clone()
+ .unwrap_or_else(|| version_id.clone());
+
+ // 2. Prepare download tasks
emit_log!(window, "Preparing download tasks...".to_string());
let mut download_tasks = Vec::new();
// --- Client Jar ---
- let client_jar = version_details.downloads.client;
+ // Get downloads from version_details (may be inherited)
+ let downloads = version_details
+ .downloads
+ .as_ref()
+ .ok_or("Version has no downloads information")?;
+ let client_jar = &downloads.client;
let mut client_path = game_dir.join("versions");
- client_path.push(&version_id);
- client_path.push(format!("{}.jar", version_id));
+ client_path.push(&minecraft_version);
+ client_path.push(format!("{}.jar", minecraft_version));
download_tasks.push(core::downloader::DownloadTask {
- url: client_jar.url,
+ url: client_jar.url.clone(),
path: client_path.clone(),
- sha1: Some(client_jar.sha1),
+ sha1: client_jar.sha1.clone(),
sha256: None,
});
@@ -127,7 +168,7 @@ async fn start_game(
for lib in &version_details.libraries {
if core::rules::is_library_allowed(&lib.rules) {
- // 1. Standard Library
+ // 1. Standard Library - check for explicit downloads first
if let Some(downloads) = &lib.downloads {
if let Some(artifact) = &downloads.artifact {
let path_str = artifact
@@ -141,7 +182,7 @@ async fn start_game(
download_tasks.push(core::downloader::DownloadTask {
url: artifact.url.clone(),
path: lib_path,
- sha1: Some(artifact.sha1.clone()),
+ sha1: artifact.sha1.clone(),
sha256: None,
});
}
@@ -175,7 +216,7 @@ async fn start_game(
download_tasks.push(core::downloader::DownloadTask {
url: native_artifact.url,
path: native_path.clone(),
- sha1: Some(native_artifact.sha1),
+ sha1: native_artifact.sha1,
sha256: None,
});
@@ -183,6 +224,21 @@ async fn start_game(
}
}
}
+ } else {
+ // 3. Library without explicit downloads (mod loader libraries)
+ // Use Maven coordinate resolution
+ if let Some(url) =
+ core::maven::resolve_library_url(&lib.name, None, lib.url.as_deref())
+ {
+ if let Some(lib_path) = core::maven::get_library_path(&lib.name, &libraries_dir)
+ {
+ download_tasks.push(core::downloader::DownloadTask {
+ url,
+ path: lib_path,
+ sha1: None, // Maven libraries often don't have SHA1 in the JSON
+ });
+ }
+ }
}
}
}
@@ -193,8 +249,14 @@ async fn start_game(
let objects_dir = assets_dir.join("objects");
let indexes_dir = assets_dir.join("indexes");
+ // Get asset index (may be inherited from parent)
+ let asset_index = version_details
+ .asset_index
+ .as_ref()
+ .ok_or("Version has no asset index information")?;
+
// Download Asset Index JSON
- let asset_index_path = indexes_dir.join(format!("{}.json", version_details.asset_index.id));
+ let asset_index_path = indexes_dir.join(format!("{}.json", asset_index.id));
// Check if index exists or download it
// Note: We need the content of this file to parse it.
@@ -206,11 +268,8 @@ async fn start_game(
.await
.map_err(|e| e.to_string())?
} else {
- println!(
- "Downloading asset index from {}",
- version_details.asset_index.url
- );
- let content = reqwest::get(&version_details.asset_index.url)
+ println!("Downloading asset index from {}", asset_index.url);
+ let content = reqwest::get(&asset_index.url)
.await
.map_err(|e| e.to_string())?
.text()
@@ -260,16 +319,29 @@ async fn start_game(
});
}
- emit_log!(window, format!(
- "Total download tasks: {} (Client + Libraries + Assets)",
- download_tasks.len()
- ));
+ emit_log!(
+ window,
+ format!(
+ "Total download tasks: {} (Client + Libraries + Assets)",
+ download_tasks.len()
+ )
+ );
// 4. Start Download
- emit_log!(window, "Starting downloads...".to_string());
- core::downloader::download_files(window.clone(), download_tasks)
- .await
- .map_err(|e| e.to_string())?;
+ emit_log!(
+ window,
+ format!(
+ "Starting downloads with {} concurrent threads...",
+ config.download_threads
+ )
+ );
+ core::downloader::download_files(
+ window.clone(),
+ download_tasks,
+ config.download_threads as usize,
+ )
+ .await
+ .map_err(|e| e.to_string())?;
emit_log!(window, "All downloads completed successfully".to_string());
// 5. Extract Natives
@@ -332,16 +404,16 @@ async fn start_game(
parse_jvm_arguments(jvm_args, &mut args, &natives_path, &classpath);
}
}
-
+
// Add memory settings (these override any defaults)
args.push(format!("-Xmx{}M", config.max_memory));
args.push(format!("-Xms{}M", config.min_memory));
-
+
// Ensure natives path is set if not already in jvm args
if !args.iter().any(|a| a.contains("-Djava.library.path")) {
args.push(format!("-Djava.library.path={}", natives_path));
}
-
+
// Ensure classpath is set if not already
if !args.iter().any(|a| a == "-cp" || a == "-classpath") {
args.push("-cp".to_string());
@@ -358,10 +430,7 @@ async fn start_game(
replacements.insert("${version_name}", version_id.clone());
replacements.insert("${game_directory}", game_dir.to_string_lossy().to_string());
replacements.insert("${assets_root}", assets_dir.to_string_lossy().to_string());
- replacements.insert(
- "${assets_index_name}",
- version_details.asset_index.id.clone(),
- );
+ replacements.insert("${assets_index_name}", asset_index.id.clone());
replacements.insert("${auth_uuid}", account.uuid());
replacements.insert("${auth_access_token}", account.access_token());
replacements.insert("${user_type}", "mojang".to_string());
@@ -413,7 +482,10 @@ async fn start_game(
for (key, replacement) in &replacements {
arg = arg.replace(key, replacement);
}
- args.push(arg);
+ // Skip arguments with unresolved placeholders
+ if !has_unresolved_placeholder(&arg) {
+ args.push(arg);
+ }
} else if let Some(arr) = val.as_array() {
for sub in arr {
if let Some(s) = sub.as_str() {
@@ -421,7 +493,10 @@ async fn start_game(
for (key, replacement) in &replacements {
arg = arg.replace(key, replacement);
}
- args.push(arg);
+ // Skip arguments with unresolved placeholders
+ if !has_unresolved_placeholder(&arg) {
+ args.push(arg);
+ }
}
}
}
@@ -433,14 +508,20 @@ async fn start_game(
}
}
- emit_log!(window, format!("Preparing to launch game with {} arguments...", args.len()));
+ emit_log!(
+ window,
+ format!("Preparing to launch game with {} arguments...", args.len())
+ );
// Debug: Log arguments (only first few to avoid spam)
if args.len() > 10 {
emit_log!(window, format!("First 10 args: {:?}", &args[..10]));
}
// Spawn the process
- emit_log!(window, format!("Starting Java process: {}", config.java_path));
+ emit_log!(
+ window,
+ format!("Starting Java process: {}", config.java_path)
+ );
let mut command = Command::new(&config.java_path);
command.args(&args);
command.current_dir(&game_dir); // Run in game directory
@@ -452,7 +533,10 @@ async fn start_game(
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
- emit_log!(window, "Applied CREATE_NO_WINDOW flag for Windows".to_string());
+ emit_log!(
+ window,
+ "Applied CREATE_NO_WINDOW flag for Windows".to_string()
+ );
}
// Spawn and handle output
@@ -472,7 +556,10 @@ async fn start_game(
.expect("child did not have a handle to stderr");
// Emit launcher log that game is running
- emit_log!(window, "Game is now running, capturing output...".to_string());
+ emit_log!(
+ window,
+ "Game is now running, capturing output...".to_string()
+ );
let window_rx = window.clone();
tokio::spawn(async move {
@@ -541,9 +628,9 @@ fn parse_jvm_arguments(
} else if let Some(obj) = item.as_object() {
// Conditional argument with rules
let allow = if let Some(rules_val) = obj.get("rules") {
- if let Ok(rules) = serde_json::from_value::<Vec<core::game_version::Rule>>(
- rules_val.clone(),
- ) {
+ if let Ok(rules) =
+ serde_json::from_value::<Vec<core::game_version::Rule>>(rules_val.clone())
+ {
core::rules::is_library_allowed(&Some(rules))
} else {
false
@@ -600,13 +687,16 @@ async fn login_offline(
let account = core::auth::Account::Offline(core::auth::OfflineAccount { username, uuid });
*state.active_account.lock().unwrap() = Some(account.clone());
-
+
// Save to storage
let app_handle = window.app_handle();
- let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?;
+ let app_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir);
storage.add_or_update_account(&account, None)?;
-
+
Ok(account)
}
@@ -618,23 +708,28 @@ async fn get_active_account(
}
#[tauri::command]
-async fn logout(
- window: Window,
- state: State<'_, core::auth::AccountState>,
-) -> Result<(), String> {
+async fn logout(window: Window, state: State<'_, core::auth::AccountState>) -> Result<(), String> {
// Get current account UUID before clearing
- let uuid = state.active_account.lock().unwrap().as_ref().map(|a| a.uuid());
-
+ let uuid = state
+ .active_account
+ .lock()
+ .unwrap()
+ .as_ref()
+ .map(|a| a.uuid());
+
*state.active_account.lock().unwrap() = None;
-
+
// Remove from storage
if let Some(uuid) = uuid {
let app_handle = window.app_handle();
- let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?;
+ let app_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir);
storage.remove_account(&uuid)?;
}
-
+
Ok(())
}
@@ -669,23 +764,23 @@ async fn complete_microsoft_login(
) -> Result<core::auth::Account, String> {
// 1. Poll (once) for token
let token_resp = core::auth::exchange_code_for_token(&device_code).await?;
-
+
// Store MS refresh token
let ms_refresh_token = token_resp.refresh_token.clone();
*ms_refresh_state.token.lock().unwrap() = ms_refresh_token.clone();
-
+
// 2. Xbox Live Auth
let (xbl_token, uhs) = core::auth::method_xbox_live(&token_resp.access_token).await?;
-
+
// 3. XSTS Auth
let xsts_token = core::auth::method_xsts(&xbl_token).await?;
-
+
// 4. Minecraft Auth
let mc_token = core::auth::login_minecraft(&xsts_token, &uhs).await?;
-
+
// 5. Get Profile
let profile = core::auth::fetch_profile(&mc_token).await?;
-
+
// 6. Create Account
let account = core::auth::Account::Microsoft(core::auth::MicrosoftAccount {
username: profile.name,
@@ -695,18 +790,22 @@ async fn complete_microsoft_login(
expires_at: (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
- .as_secs() + token_resp.expires_in) as i64,
+ .as_secs()
+ + token_resp.expires_in) as i64,
});
-
+
// 7. Save to state
*state.active_account.lock().unwrap() = Some(account.clone());
-
+
// 8. Save to storage
let app_handle = window.app_handle();
- let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?;
+ let app_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir);
storage.add_or_update_account(&account, ms_refresh_token)?;
-
+
Ok(account)
}
@@ -719,26 +818,29 @@ async fn refresh_account(
) -> Result<core::auth::Account, String> {
// Get stored MS refresh token
let app_handle = window.app_handle();
- let app_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?;
+ let app_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir.clone());
-
+
let (stored_account, ms_refresh) = storage
.get_active_account()
.ok_or("No active account found")?;
-
+
let ms_refresh_token = ms_refresh.ok_or("No refresh token available")?;
-
+
// Perform full refresh
let (new_account, new_ms_refresh) = core::auth::refresh_full_auth(&ms_refresh_token).await?;
let account = core::auth::Account::Microsoft(new_account);
-
+
// Update state
*state.active_account.lock().unwrap() = Some(account.clone());
*ms_refresh_state.token.lock().unwrap() = Some(new_ms_refresh.clone());
-
+
// Update storage
storage.add_or_update_account(&account, Some(new_ms_refresh))?;
-
+
Ok(account)
}
@@ -790,33 +892,178 @@ async fn fetch_available_java_versions() -> Result<Vec<u32>, String> {
core::java::fetch_available_versions().await
}
+/// Get Minecraft versions supported by Fabric
+#[tauri::command]
+async fn get_fabric_game_versions() -> Result<Vec<core::fabric::FabricGameVersion>, String> {
+ core::fabric::fetch_supported_game_versions()
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Get available Fabric loader versions
+#[tauri::command]
+async fn get_fabric_loader_versions() -> Result<Vec<core::fabric::FabricLoaderVersion>, String> {
+ core::fabric::fetch_loader_versions()
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Get Fabric loaders available for a specific Minecraft version
+#[tauri::command]
+async fn get_fabric_loaders_for_version(
+ game_version: String,
+) -> Result<Vec<core::fabric::FabricLoaderEntry>, String> {
+ core::fabric::fetch_loaders_for_game_version(&game_version)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Install Fabric loader for a specific Minecraft version
+#[tauri::command]
+async fn install_fabric(
+ window: Window,
+ game_version: String,
+ loader_version: String,
+) -> Result<core::fabric::InstalledFabricVersion, String> {
+ emit_log!(
+ window,
+ format!(
+ "Installing Fabric {} for Minecraft {}...",
+ loader_version, game_version
+ )
+ );
+
+ let app_handle = window.app_handle();
+ let game_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+ let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ emit_log!(
+ window,
+ format!("Fabric installed successfully: {}", result.id)
+ );
+
+ Ok(result)
+}
+
+/// List installed Fabric versions
+#[tauri::command]
+async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, String> {
+ let app_handle = window.app_handle();
+ let game_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+ core::fabric::list_installed_fabric_versions(&game_dir)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Check if Fabric is installed for a specific version
+#[tauri::command]
+async fn is_fabric_installed(
+ window: Window,
+ game_version: String,
+ loader_version: String,
+) -> Result<bool, String> {
+ let app_handle = window.app_handle();
+ let game_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+ Ok(core::fabric::is_fabric_installed(
+ &game_dir,
+ &game_version,
+ &loader_version,
+ ))
+}
+
+/// Get Minecraft versions supported by Forge
+#[tauri::command]
+async fn get_forge_game_versions() -> Result<Vec<String>, String> {
+ core::forge::fetch_supported_game_versions()
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Get available Forge versions for a specific Minecraft version
+#[tauri::command]
+async fn get_forge_versions_for_game(
+ game_version: String,
+) -> Result<Vec<core::forge::ForgeVersion>, String> {
+ core::forge::fetch_forge_versions(&game_version)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+/// Install Forge for a specific Minecraft version
+#[tauri::command]
+async fn install_forge(
+ window: Window,
+ game_version: String,
+ forge_version: String,
+) -> Result<core::forge::InstalledForgeVersion, String> {
+ emit_log!(
+ window,
+ format!(
+ "Installing Forge {} for Minecraft {}...",
+ forge_version, game_version
+ )
+ );
+
+ let app_handle = window.app_handle();
+ let game_dir = app_handle
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+ let result = core::forge::install_forge(&game_dir, &game_version, &forge_version)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ emit_log!(
+ window,
+ format!("Forge installed successfully: {}", result.id)
+ );
+
+ Ok(result)
+}
+
fn main() {
tauri::Builder::default()
+ .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.manage(core::auth::AccountState::new())
.manage(MsRefreshTokenState::new())
.setup(|app| {
let config_state = core::config::ConfigState::new(app.handle());
app.manage(config_state);
-
+
// Load saved account on startup
let app_dir = app.path().app_data_dir().unwrap();
let storage = core::account_storage::AccountStorage::new(app_dir);
-
+
if let Some((stored_account, ms_refresh)) = storage.get_active_account() {
let account = stored_account.to_account();
let auth_state: State<core::auth::AccountState> = app.state();
*auth_state.active_account.lock().unwrap() = Some(account);
-
+
// Store MS refresh token
if let Some(token) = ms_refresh {
let ms_state: State<MsRefreshTokenState> = app.state();
*ms_state.token.lock().unwrap() = Some(token);
}
-
+
println!("[Startup] Loaded saved account");
}
-
+
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -834,7 +1081,18 @@ fn main() {
get_recommended_java,
fetch_adoptium_java,
download_adoptium_java,
- fetch_available_java_versions
+ fetch_available_java_versions,
+ // Fabric commands
+ get_fabric_game_versions,
+ get_fabric_loader_versions,
+ get_fabric_loaders_for_version,
+ install_fabric,
+ list_installed_fabric_versions,
+ is_fabric_installed,
+ // Forge commands
+ get_forge_game_versions,
+ get_forge_versions_for_game,
+ install_forge
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 7d2b0a3..9a395fa 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
{
"productName": "dropout",
- "version": "0.1.13",
+ "version": "0.1.19",
"identifier": "com.dropout.launcher",
"build": {
"beforeDevCommand": "cd ../ui && pnpm dev",
@@ -14,8 +14,8 @@
"title": "Minecraft DropOut Launcher",
"width": 1024,
"height": 768,
- "minWidth": 800,
- "minHeight": 600,
+ "minWidth": 905,
+ "minHeight": 575,
"resizable": true
}
],
@@ -33,6 +33,11 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
- ]
+ ],
+ "linux": {
+ "appimage": {
+ "bundleMediaFramework": false
+ }
+ }
}
} \ No newline at end of file
diff --git a/ui/package-lock.json b/ui/package-lock.json
new file mode 100644
index 0000000..2682169
--- /dev/null
+++ b/ui/package-lock.json
@@ -0,0 +1,1784 @@
+{
+ "name": "ui",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ui",
+ "version": "0.0.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.9.1",
+ "@tauri-apps/plugin-shell": "^2.3.4"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
+ "@tailwindcss/vite": "^4.1.18",
+ "@tsconfig/svelte": "^5.0.6",
+ "@types/node": "^24.10.1",
+ "autoprefixer": "^10.4.23",
+ "postcss": "^8.5.6",
+ "svelte": "^5.43.8",
+ "svelte-check": "^4.3.4",
+ "tailwindcss": "^4.1.18",
+ "typescript": "~5.9.3",
+ "vite": "npm:rolldown-vite@7.2.5"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
+ "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.1.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
+ "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
+ "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@oxc-project/runtime": {
+ "version": "0.97.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.97.0.tgz",
+ "integrity": "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.97.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.97.0.tgz",
+ "integrity": "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.50.tgz",
+ "integrity": "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.50.tgz",
+ "integrity": "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.50.tgz",
+ "integrity": "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.50.tgz",
+ "integrity": "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.50.tgz",
+ "integrity": "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.50.tgz",
+ "integrity": "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.50.tgz",
+ "integrity": "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.50.tgz",
+ "integrity": "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.50.tgz",
+ "integrity": "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.50.tgz",
+ "integrity": "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.50.tgz",
+ "integrity": "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^1.0.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.50.tgz",
+ "integrity": "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-ia32-msvc": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.50.tgz",
+ "integrity": "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.50.tgz",
+ "integrity": "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz",
+ "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
+ "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
+ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
+ "deepmerge": "^4.3.1",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
+ "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "obug": "^2.1.0"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
+ "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+ "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-x64": "4.1.18",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+ "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+ "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+ "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+ "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+ "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+ "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+ "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
+ "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+ "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+ "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.0",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+ "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+ "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
+ "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.1.18",
+ "@tailwindcss/oxide": "4.1.18",
+ "tailwindcss": "4.1.18"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7"
+ }
+ },
+ "node_modules/@tauri-apps/api": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
+ "integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
+ "license": "Apache-2.0 OR MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/tauri"
+ }
+ },
+ "node_modules/@tauri-apps/plugin-shell": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.4.tgz",
+ "integrity": "sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.8.0"
+ }
+ },
+ "node_modules/@tsconfig/svelte": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.6.tgz",
+ "integrity": "sha512-yGxYL0I9eETH1/DR9qVJey4DAsCdeau4a9wYPKuXfEhm8lFO8wg+LLYJjIpAm6Fw7HSlhepPhYPDop75485yWQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.10.8",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.8.tgz",
+ "integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.23",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
+ "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001760",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.14",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
+ "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001764",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
+ "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz",
+ "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.267",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
+ "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.4",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
+ "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz",
+ "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-beta.50",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.50.tgz",
+ "integrity": "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.97.0",
+ "@rolldown/pluginutils": "1.0.0-beta.50"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-beta.50",
+ "@rolldown/binding-darwin-arm64": "1.0.0-beta.50",
+ "@rolldown/binding-darwin-x64": "1.0.0-beta.50",
+ "@rolldown/binding-freebsd-x64": "1.0.0-beta.50",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50",
+ "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50"
+ }
+ },
+ "node_modules/sade": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+ "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mri": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.46.3",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.3.tgz",
+ "integrity": "sha512-Y5juST3x+/ySty5tYJCVWa6Corkxpt25bUZQHqOceg9xfMUtDsFx6rCsG6cYf1cA6vzDi66HIvaki0byZZX95A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "acorn": "^8.12.1",
+ "aria-query": "^5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.5.0",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.1",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/svelte-check": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz",
+ "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "chokidar": "^4.0.1",
+ "fdir": "^6.2.0",
+ "picocolors": "^1.0.0",
+ "sade": "^1.7.4"
+ },
+ "bin": {
+ "svelte-check": "bin/svelte-check"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
+ "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "name": "rolldown-vite",
+ "version": "7.2.5",
+ "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.2.5.tgz",
+ "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/runtime": "0.97.0",
+ "fdir": "^6.5.0",
+ "lightningcss": "^1.30.2",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rolldown": "1.0.0-beta.50",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "esbuild": "^0.25.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
+ "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/ui/package.json b/ui/package.json
index 0806781..03cc405 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -29,6 +29,7 @@
},
"dependencies": {
"@tauri-apps/api": "^2.9.1",
+ "@tauri-apps/plugin-dialog": "^2.5.0",
"@tauri-apps/plugin-shell": "^2.3.4"
}
}
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 0accf90..23d4ee2 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
'@tauri-apps/api':
specifier: ^2.9.1
version: 2.9.1
+ '@tauri-apps/plugin-dialog':
+ specifier: ^2.5.0
+ version: 2.5.0
'@tauri-apps/plugin-shell':
specifier: ^2.3.4
version: 2.3.4
@@ -124,24 +127,28 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==}
@@ -233,24 +240,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -288,76 +299,8 @@ packages:
'@tauri-apps/api@2.9.1':
resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==}
- '@tauri-apps/cli-darwin-arm64@2.9.6':
- resolution: {integrity: sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [darwin]
-
- '@tauri-apps/cli-darwin-x64@2.9.6':
- resolution: {integrity: sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [darwin]
-
- '@tauri-apps/cli-linux-arm-gnueabihf@2.9.6':
- resolution: {integrity: sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==}
- engines: {node: '>= 10'}
- cpu: [arm]
- os: [linux]
-
- '@tauri-apps/cli-linux-arm64-gnu@2.9.6':
- resolution: {integrity: sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [linux]
-
- '@tauri-apps/cli-linux-arm64-musl@2.9.6':
- resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [linux]
-
- '@tauri-apps/cli-linux-riscv64-gnu@2.9.6':
- resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==}
- engines: {node: '>= 10'}
- cpu: [riscv64]
- os: [linux]
-
- '@tauri-apps/cli-linux-x64-gnu@2.9.6':
- resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [linux]
-
- '@tauri-apps/cli-linux-x64-musl@2.9.6':
- resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [linux]
-
- '@tauri-apps/cli-win32-arm64-msvc@2.9.6':
- resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [win32]
-
- '@tauri-apps/cli-win32-ia32-msvc@2.9.6':
- resolution: {integrity: sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==}
- engines: {node: '>= 10'}
- cpu: [ia32]
- os: [win32]
-
- '@tauri-apps/cli-win32-x64-msvc@2.9.6':
- resolution: {integrity: sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [win32]
-
- '@tauri-apps/cli@2.9.6':
- resolution: {integrity: sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==}
- engines: {node: '>= 10'}
- hasBin: true
+ '@tauri-apps/plugin-dialog@2.5.0':
+ resolution: {integrity: sha512-I0R0ygwRd9AN8Wj5GnzCogOlqu2+OWAtBd0zEC4+kQCI32fRowIyuhPCBoUv4h/lQt2bM39kHlxPHD5vDcFjiA==}
'@tauri-apps/plugin-shell@2.3.4':
resolution: {integrity: sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==}
@@ -504,24 +447,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -867,52 +814,9 @@ snapshots:
'@tauri-apps/api@2.9.1': {}
- '@tauri-apps/cli-darwin-arm64@2.9.6':
- optional: true
-
- '@tauri-apps/cli-darwin-x64@2.9.6':
- optional: true
-
- '@tauri-apps/cli-linux-arm-gnueabihf@2.9.6':
- optional: true
-
- '@tauri-apps/cli-linux-arm64-gnu@2.9.6':
- optional: true
-
- '@tauri-apps/cli-linux-arm64-musl@2.9.6':
- optional: true
-
- '@tauri-apps/cli-linux-riscv64-gnu@2.9.6':
- optional: true
-
- '@tauri-apps/cli-linux-x64-gnu@2.9.6':
- optional: true
-
- '@tauri-apps/cli-linux-x64-musl@2.9.6':
- optional: true
-
- '@tauri-apps/cli-win32-arm64-msvc@2.9.6':
- optional: true
-
- '@tauri-apps/cli-win32-ia32-msvc@2.9.6':
- optional: true
-
- '@tauri-apps/cli-win32-x64-msvc@2.9.6':
- optional: true
-
- '@tauri-apps/cli@2.9.6':
- optionalDependencies:
- '@tauri-apps/cli-darwin-arm64': 2.9.6
- '@tauri-apps/cli-darwin-x64': 2.9.6
- '@tauri-apps/cli-linux-arm-gnueabihf': 2.9.6
- '@tauri-apps/cli-linux-arm64-gnu': 2.9.6
- '@tauri-apps/cli-linux-arm64-musl': 2.9.6
- '@tauri-apps/cli-linux-riscv64-gnu': 2.9.6
- '@tauri-apps/cli-linux-x64-gnu': 2.9.6
- '@tauri-apps/cli-linux-x64-musl': 2.9.6
- '@tauri-apps/cli-win32-arm64-msvc': 2.9.6
- '@tauri-apps/cli-win32-ia32-msvc': 2.9.6
- '@tauri-apps/cli-win32-x64-msvc': 2.9.6
+ '@tauri-apps/plugin-dialog@2.5.0':
+ dependencies:
+ '@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-shell@2.3.4':
dependencies:
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index b637512..1c465b1 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -1,98 +1,9 @@
<script lang="ts">
import { getVersion } from "@tauri-apps/api/app";
- import { onMount } from "svelte";
+ import { onMount, onDestroy } from "svelte";
+ import { convertFileSrc } from "@tauri-apps/api/core";
import DownloadMonitor from "./lib/DownloadMonitor.svelte";
import GameConsole from "./lib/GameConsole.svelte";
-
- let status = "Ready";
- let showConsole = false;
- let currentView = "home";
- let statusTimeout: any;
- let appVersion = "...";
-
- // Watch for status changes to auto-dismiss
- $: if (status !== "Ready") {
- if (statusTimeout) clearTimeout(statusTimeout);
- statusTimeout = setTimeout(() => {
- status = "Ready";
- }, 5000);
- }
-
- interface Version {
- id: string;
- type: string;
- url: string;
- time: string;
- releaseTime: string;
- }
-
- interface Account {
- type: "Offline" | "Microsoft";
- username: string;
- uuid: string;
- }
-
- interface DeviceCodeResponse {
- user_code: string;
- device_code: string;
- verification_uri: string;
- expires_in: number;
- interval: number;
- message?: string;
- }
-
- interface LauncherConfig {
- min_memory: number;
- max_memory: number;
- java_path: string;
- width: number;
- height: number;
- }
-
- interface JavaInstallation {
- path: string;
- version: string;
- is_64bit: boolean;
- }
-
- interface JavaDownloadInfo {
- version: string;
- release_name: string;
- download_url: string;
- file_name: string;
- file_size: number;
- checksum: string | null;
- image_type: string;
- }
-
- let versions: Version[] = [];
- let selectedVersion = "";
- let currentAccount: Account | null = null;
- let settings: LauncherConfig = {
- min_memory: 1024,
- max_memory: 2048,
- java_path: "java",
- width: 854,
- height: 480,
- };
- let javaInstallations: JavaInstallation[] = [];
- let isDetectingJava = false;
-
- let availableJavaVersions: number[] = [];
- let selectedJavaVersion = 21;
- let selectedImageType: "jre" | "jdk" = "jre";
- let isDownloadingJava = false;
- let javaDownloadStatus = "";
- let showJavaDownloadModal = false;
-
- // Login UI State
- let isLoginModalOpen = false;
- let loginMode: "select" | "offline" | "microsoft" = "select";
- let offlineUsername = "";
- let deviceCodeData: DeviceCodeResponse | null = null;
- let msLoginLoading = false;
- let msLoginStatus = "Waiting for authorization...";
- let isPollingRequestActive = false;
// Components
import Sidebar from "./components/Sidebar.svelte";
@@ -102,6 +13,7 @@
import BottomBar from "./components/BottomBar.svelte";
import LoginModal from "./components/LoginModal.svelte";
import StatusToast from "./components/StatusToast.svelte";
+ import ParticleBackground from "./components/ParticleBackground.svelte";
// Stores
import { uiState } from "./stores/ui.svelte";
@@ -109,726 +21,157 @@
import { settingsState } from "./stores/settings.svelte";
import { gameState } from "./stores/game.svelte";
+ let mouseX = $state(0);
+ let mouseY = $state(0);
+
+ function handleMouseMove(e: MouseEvent) {
+ mouseX = (e.clientX / window.innerWidth) * 2 - 1;
+ mouseY = (e.clientY / window.innerHeight) * 2 - 1;
+ }
+
onMount(async () => {
authState.checkAccount();
- settingsState.loadSettings();
+ await settingsState.loadSettings();
gameState.loadVersions();
getVersion().then((v) => (uiState.appVersion = v));
+ window.addEventListener("mousemove", handleMouseMove);
});
-
- async function checkAccount() {
- try {
- const acc = await invoke("get_active_account");
- currentAccount = acc as Account | null;
- } catch (e) {
- console.error("Failed to check account:", e);
- }
- }
-
- async function loadSettings() {
- try {
- settings = await invoke("get_settings");
- } catch (e) {
- console.error("Failed to load settings:", e);
- }
- }
-
- async function saveSettings() {
- try {
- await invoke("save_settings", { config: settings });
- status = "Settings saved!";
- } catch (e) {
- console.error("Failed to save settings:", e);
- status = "Error saving settings: " + e;
- }
- }
-
- async function detectJava() {
- isDetectingJava = true;
- try {
- javaInstallations = await invoke("detect_java");
- if (javaInstallations.length === 0) {
- status = "No Java installations found";
- } else {
- status = `Found ${javaInstallations.length} Java installation(s)`;
- }
- } catch (e) {
- console.error("Failed to detect Java:", e);
- status = "Error detecting Java: " + e;
- } finally {
- isDetectingJava = false;
- }
- }
-
- function selectJava(path: string) {
- settings.java_path = path;
- }
-
- async function openJavaDownloadModal() {
- showJavaDownloadModal = true;
- javaDownloadStatus = "";
- try {
- availableJavaVersions = await invoke("fetch_available_java_versions");
- // Default selection logic
- if (availableJavaVersions.includes(21)) {
- selectedJavaVersion = 21;
- } else if (availableJavaVersions.includes(17)) {
- selectedJavaVersion = 17;
- } else if (availableJavaVersions.length > 0) {
- selectedJavaVersion = availableJavaVersions[availableJavaVersions.length - 1];
- }
- } catch (e) {
- console.error("Failed to fetch available Java versions:", e);
- javaDownloadStatus = "Error fetching Java versions: " + e;
- }
- }
-
- function closeJavaDownloadModal() {
- if (!isDownloadingJava) {
- showJavaDownloadModal = false;
- }
- }
-
- async function downloadJava() {
- isDownloadingJava = true;
- javaDownloadStatus = `Downloading Java ${selectedJavaVersion} ${selectedImageType.toUpperCase()}...`;
+
+ $effect(() => {
+ // ENFORCE DARK MODE: Always add 'dark' class and attribute
+ // This combined with the @variant dark in app.css ensures dark mode is always active
+ // regardless of system preference settings.
+ document.documentElement.classList.add('dark');
+ document.documentElement.setAttribute('data-theme', 'dark');
- try {
- const result: JavaInstallation = await invoke("download_adoptium_java", {
- majorVersion: selectedJavaVersion,
- imageType: selectedImageType,
- customPath: null,
- });
-
- javaDownloadStatus = `Java ${selectedJavaVersion} installed at ${result.path}`;
- settings.java_path = result.path;
-
- await detectJava();
-
- setTimeout(() => {
- showJavaDownloadModal = false;
- status = `Java ${selectedJavaVersion} is ready to use!`;
- }, 1500);
- } catch (e) {
- console.error("Failed to download Java:", e);
- javaDownloadStatus = "Download failed: " + e;
- } finally {
- isDownloadingJava = false;
- }
- }
-
- // --- Auth Functions ---
-
- function openLoginModal() {
- if (currentAccount) {
- if (confirm("Logout " + currentAccount.username + "?")) {
- invoke("logout").then(() => (currentAccount = null));
- }
- return;
- }
- // Reset state
- isLoginModalOpen = true;
- loginMode = "select";
- offlineUsername = "";
- deviceCodeData = null;
- msLoginLoading = false;
- }
-
- function closeLoginModal() {
- stopPolling();
- isLoginModalOpen = false;
- }
-
- async function performOfflineLogin() {
- if (!offlineUsername) return;
- try {
- currentAccount = (await invoke("login_offline", {
- username: offlineUsername,
- })) as Account;
- isLoginModalOpen = false;
- } catch (e) {
- alert("Login failed: " + e);
- }
- }
-
- let pollInterval: any;
-
- // Cleanup on destroy/close
- function stopPolling() {
- if (pollInterval) {
- clearInterval(pollInterval);
- pollInterval = null;
- }
- }
-
- async function startMicrosoftLogin() {
- loginMode = "microsoft";
- msLoginLoading = true;
- msLoginStatus = "Waiting for authorization...";
- stopPolling(); // Ensure no duplicates
-
- try {
- deviceCodeData = (await invoke(
- "start_microsoft_login"
- )) as DeviceCodeResponse;
-
- // UX Improvements: Auto Copy & Auto Open
- if (deviceCodeData) {
- try {
- await navigator.clipboard.writeText(deviceCodeData.user_code);
- } catch (e) {
- console.error("Clipboard failed", e);
- }
-
- openLink(deviceCodeData.verification_uri);
-
- // Start Polling
- console.log("Starting polling for token...");
- const intervalMs = (deviceCodeData.interval || 5) * 1000;
- pollInterval = setInterval(
- () => checkLoginStatus(deviceCodeData!.device_code),
- intervalMs
- );
- }
- } catch (e) {
- alert("Failed to start Microsoft login: " + e);
- loginMode = "select"; // Go back
- } finally {
- msLoginLoading = false;
- }
- }
-
- async function checkLoginStatus(deviceCode: string) {
- if (isPollingRequestActive) return;
- isPollingRequestActive = true;
-
- console.log("Polling Microsoft API...");
- try {
- // This will fail with "authorization_pending" until user logs in
- currentAccount = (await invoke("complete_microsoft_login", {
- deviceCode,
- })) as Account;
-
- // If success:
- console.log("Login Successful!", currentAccount);
- stopPolling();
- isLoginModalOpen = false;
- status = "Welcome back, " + currentAccount.username;
- } catch (e: any) {
- const errStr = e.toString();
- if (errStr.includes("authorization_pending")) {
- console.log("Status: Waiting for user to authorize...");
- // Keep checking
- } else {
- // Real error
- console.error("Polling Error:", errStr);
- msLoginStatus = "Error: " + errStr;
-
- // Optional: Stop polling on fatal errors?
- // expired_token should stop it.
- if (
- errStr.includes("expired_token") ||
- errStr.includes("access_denied")
- ) {
- stopPolling();
- alert("Login failed: " + errStr);
- loginMode = "select";
- }
- }
- } finally {
- isPollingRequestActive = false;
- }
- }
-
- // Clean up manual button to just be a status indicator or 'Retry Now'
- async function completeMicrosoftLogin() {
- if (deviceCodeData) checkLoginStatus(deviceCodeData.device_code);
- }
-
- function openLink(url: string) {
- open(url);
- }
-
- async function startGame() {
- if (!currentAccount) {
- alert("Please login first!");
- openLoginModal();
- return;
- }
-
- if (!selectedVersion) {
- alert("Please select a version!");
- return;
- }
+ // Ensure 'light' class is never present
+ document.documentElement.classList.remove('light');
+ });
- status = "Preparing to launch " + selectedVersion + "...";
- console.log("Invoking start_game for version:", selectedVersion);
- try {
- const msg = await invoke("start_game", { versionId: selectedVersion });
- console.log("Response:", msg);
- status = msg as string;
- } catch (e) {
- console.error(e);
- status = "Error: " + e;
- }
- }
+ onDestroy(() => {
+ if (typeof window !== 'undefined')
+ window.removeEventListener("mousemove", handleMouseMove);
+ });
</script>
<div
- class="flex h-screen bg-zinc-900 text-white font-sans overflow-hidden select-none"
+ class="relative h-screen w-screen overflow-hidden dark:text-white text-gray-900 font-sans selection:bg-indigo-500/30"
>
- <Sidebar />
-
- <!-- Main Content -->
- <main class="flex-1 flex flex-col relative min-w-0">
- <DownloadMonitor />
- <!-- Top Bar (Window Controls Placeholder) -->
- <div
- class="h-8 w-full bg-zinc-900/50 absolute top-0 left-0 z-50 drag-region"
- data-tauri-drag-region
- >
- <!-- Windows/macOS controls would go here or be handled by OS -->
- </div>
-
- <!-- Background / Poster area -->
- <div class="flex-1 relative overflow-hidden group">
- {#if currentView === "home"}
- <!-- Background Image - Using gradient fallback -->
- <div
- class="absolute inset-0 z-0 opacity-60 bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 transition-transform duration-[10s] ease-linear group-hover:scale-105"
- ></div>
- <div
- class="absolute inset-0 z-0 bg-gradient-to-t from-zinc-900 via-transparent to-black/50"
- ></div>
-
- <div class="absolute bottom-24 left-8 z-10 p-4">
- <h1
- class="text-6xl font-black mb-2 tracking-tight text-white drop-shadow-lg"
- >
- MINECRAFT
- </h1>
- <div class="flex items-center gap-2 text-zinc-300">
- <span
- class="bg-zinc-800 text-xs px-2 py-1 rounded border border-zinc-600"
- >JAVA EDITION</span
- >
- <span class="text-lg">Release 1.20.4</span>
- </div>
- </div>
- {:else if currentView === "versions"}
- <div class="p-8 h-full overflow-y-auto bg-zinc-900">
- <h2 class="text-3xl font-bold mb-6">Versions</h2>
- <div class="grid gap-2">
- {#if versions.length === 0}
- <div class="text-zinc-500">Loading versions...</div>
- {:else}
- {#each versions as version}
- <button
- class="flex items-center justify-between p-4 bg-zinc-800 rounded hover:bg-zinc-700 transition text-left border border-zinc-700 {selectedVersion ===
- version.id
- ? 'border-green-500 bg-zinc-800/80 ring-1 ring-green-500'
- : ''}"
- onclick={() => (selectedVersion = version.id)}
- >
- <div>
- <div class="font-bold font-mono text-lg">{version.id}</div>
- <div class="text-xs text-zinc-400 capitalize">
- {version.type} • {new Date(
- version.releaseTime
- ).toLocaleDateString()}
- </div>
- </div>
- {#if selectedVersion === version.id}
- <div class="text-green-500 font-bold text-sm">SELECTED</div>
- {/if}
- </button>
- {/each}
- {/if}
- </div>
- </div>
- {:else if currentView === "settings"}
- <div class="p-8 bg-zinc-900 h-full overflow-y-auto">
- <h2 class="text-3xl font-bold mb-8">Settings</h2>
-
- <div class="space-y-6 max-w-2xl">
- <!-- Java Path -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <label
- class="block text-sm font-bold text-zinc-400 mb-2 uppercase tracking-wide"
- >Java Executable Path</label
- >
- <div class="flex gap-2">
- <input
- bind:value={settings.java_path}
- type="text"
- class="bg-zinc-950 text-white flex-1 p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none font-mono text-sm"
- placeholder="e.g. java, /usr/bin/java"
- />
- <button
- onclick={detectJava}
- disabled={isDetectingJava}
- class="bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-white px-4 py-2 rounded transition-colors whitespace-nowrap"
- >
- {isDetectingJava ? "Detecting..." : "Auto Detect"}
- </button>
- <button
- onclick={openJavaDownloadModal}
- class="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded transition-colors whitespace-nowrap"
- >
- Download Java
- </button>
- </div>
-
- {#if javaInstallations.length > 0}
- <div class="mt-4 space-y-2">
- <p class="text-xs text-zinc-400 uppercase font-bold">Detected Java Installations:</p>
- {#each javaInstallations as java}
- <button
- onclick={() => selectJava(java.path)}
- class="w-full text-left p-3 bg-zinc-950 rounded border transition-colors {settings.java_path === java.path ? 'border-indigo-500 bg-indigo-950/30' : 'border-zinc-700 hover:border-zinc-500'}"
- >
- <div class="flex justify-between items-center">
- <div>
- <span class="text-white font-mono text-sm">{java.version}</span>
- <span class="text-zinc-500 text-xs ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span>
- </div>
- {#if settings.java_path === java.path}
- <span class="text-indigo-400 text-xs">Selected</span>
- {/if}
- </div>
- <div class="text-zinc-500 text-xs font-mono truncate mt-1">{java.path}</div>
- </button>
- {/each}
- </div>
- {/if}
-
- <p class="text-xs text-zinc-500 mt-2">
- The command or path to the Java Runtime Environment. Click "Auto Detect" to find installed Java versions.
- </p>
- </div>
-
- <!-- Memory -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <label
- class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide"
- >Memory Allocation (RAM)</label
- >
-
- <div class="grid grid-cols-2 gap-6">
- <div>
- <label class="block text-xs text-zinc-500 mb-1"
- >Minimum (MB)</label
- >
- <input
- bind:value={settings.min_memory}
- type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
- />
- </div>
- <div>
- <label class="block text-xs text-zinc-500 mb-1"
- >Maximum (MB)</label
- >
- <input
- bind:value={settings.max_memory}
- type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
- />
- </div>
- </div>
- </div>
-
- <!-- Resolution -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <label
- class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide"
- >Game Window Size</label
- >
- <div class="grid grid-cols-2 gap-6">
- <div>
- <label class="block text-xs text-zinc-500 mb-1">Width</label>
- <input
- bind:value={settings.width}
- type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
- />
- </div>
- <div>
- <label class="block text-xs text-zinc-500 mb-1">Height</label>
- <input
- bind:value={settings.height}
- type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
- />
- </div>
- </div>
- </div>
+ <!-- Modern Animated Background -->
+ <div class="absolute inset-0 z-0 bg-[#09090b] dark:bg-[#09090b] bg-gray-100 overflow-hidden">
+ {#if settingsState.settings.custom_background_path}
+ <img
+ src={convertFileSrc(settingsState.settings.custom_background_path)}
+ alt="Background"
+ class="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear hover:scale-105"
+ />
+ <!-- Dimming Overlay for readability -->
+ <div class="absolute inset-0 bg-black/50 "></div>
+ {:else if settingsState.settings.enable_visual_effects}
+ <!-- Original Gradient (Dark Only / or Adjusted for Light) -->
+ {#if settingsState.settings.theme === 'dark'}
+ <div
+ class="absolute inset-0 opacity-60 bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950"
+ ></div>
+ {:else}
+ <!-- Light Mode Gradient -->
+ <div
+ class="absolute inset-0 opacity-100 bg-gradient-to-br from-emerald-100 via-gray-100 to-indigo-100"
+ ></div>
+ {/if}
- <div class="pt-4">
- <button
- onclick={saveSettings}
- class="bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-8 rounded shadow-lg transition-transform active:scale-95"
- >
- Save Settings
- </button>
- </div>
- </div>
- </div>
{#if uiState.currentView === "home"}
- <HomeView />
- {:else if uiState.currentView === "versions"}
- <VersionsView />
- {:else if uiState.currentView === "settings"}
- <SettingsView />
+ <ParticleBackground />
{/if}
- </div>
-
- <BottomBar />
- </main>
-
- <!-- Login Modal -->
- {#if isLoginModalOpen}
- <div
- class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
- >
- <div
- class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200"
- >
- <div class="flex justify-between items-center mb-6">
- <h2 class="text-2xl font-bold text-white">Login</h2>
- <button
- onclick={closeLoginModal}
- class="text-zinc-500 hover:text-white transition group"
- >
- ✕
- </button>
- </div>
-
- {#if loginMode === "select"}
- <div class="space-y-4">
- <button
- onclick={startMicrosoftLogin}
- class="w-full flex items-center justify-center gap-3 bg-[#2F2F2F] hover:bg-[#3F3F3F] text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-500 transition-all group"
- >
- <!-- Microsoft Logo SVG -->
- <svg
- class="w-5 h-5"
- viewBox="0 0 23 23"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- ><path fill="#f35325" d="M1 1h10v10H1z" /><path
- fill="#81bc06"
- d="M12 1h10v10H12z"
- /><path fill="#05a6f0" d="M1 12h10v10H1z" /><path
- fill="#ffba08"
- d="M12 12h10v10H12z"
- /></svg
- >
- Microsoft Account
- </button>
-
- <div class="relative py-2">
- <div class="absolute inset-0 flex items-center">
- <div class="w-full border-t border-zinc-700"></div>
- </div>
- <div class="relative flex justify-center text-xs uppercase">
- <span class="bg-zinc-900 px-2 text-zinc-500">OR</span>
- </div>
- </div>
- <div class="space-y-2">
- <input
- type="text"
- bind:value={offlineUsername}
- placeholder="Offline Username"
- class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-white focus:border-indigo-500 outline-none"
- onkeydown={(e) => e.key === "Enter" && performOfflineLogin()}
- />
- <button
- onclick={performOfflineLogin}
- class="w-full bg-zinc-800 hover:bg-zinc-700 text-zinc-300 p-3 rounded font-medium transition-colors"
- >
- Offline Login
- </button>
- </div>
- </div>
- {:else if loginMode === "microsoft"}
- <div class="text-center">
- {#if msLoginLoading && !deviceCodeData}
- <div class="py-8 text-zinc-400 animate-pulse">
- Starting login flow...
- </div>
- {:else if deviceCodeData}
- <div class="space-y-4">
- <p class="text-sm text-zinc-400">1. Go to this URL:</p>
- <button
- onclick={() =>
- deviceCodeData && openLink(deviceCodeData.verification_uri)}
- class="text-indigo-400 hover:text-indigo-300 underline break-all font-mono text-sm"
- >
- {deviceCodeData.verification_uri}
- </button>
-
- <p class="text-sm text-zinc-400 mt-2">2. Enter this code:</p>
- <div
- class="bg-zinc-950 p-4 rounded border border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors"
- onclick={() =>
- navigator.clipboard.writeText(
- deviceCodeData?.user_code || ""
- )}
- >
- {deviceCodeData.user_code}
- </div>
- <p class="text-xs text-zinc-500">Click code to copy</p>
-
- <div class="pt-6 space-y-3">
- <div class="flex flex-col items-center gap-3">
- <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-600 border-t-indigo-500"></div>
- <span class="text-sm text-zinc-400 font-medium break-all text-center">{msLoginStatus}</span>
- </div>
- <p class="text-xs text-zinc-600">This window will update automatically.</p>
- </div>
-
- <button
- onclick={() => { stopPolling(); loginMode = "select"; }}
- class="text-xs text-zinc-500 hover:text-zinc-300 mt-6 underline"
- >Cancel</button
- >
- </div>
- {/if}
- </div>
- {/if}
- </div>
+ <div
+ class="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50 from-gray-100 to-transparent"
+ ></div>
+ {/if}
+
+ <!-- Subtle Grid Overlay -->
+ <div class="absolute inset-0 z-0 opacity-10 dark:opacity-10 opacity-30 pointer-events-none"
+ style="background-image: linear-gradient({settingsState.settings.theme === 'dark' ? '#ffffff' : '#000000'} 1px, transparent 1px), linear-gradient(90deg, {settingsState.settings.theme === 'dark' ? '#ffffff' : '#000000'} 1px, transparent 1px); background-size: 40px 40px; mask-image: radial-gradient(circle at 50% 50%, black 30%, transparent 70%);">
</div>
- {/if}
+ </div>
- <!-- Overlay Status (Toast) -->
- {#if status !== "Ready"}
- <div
- class="absolute top-12 right-12 bg-zinc-800/90 backdrop-blur border border-zinc-600 p-4 rounded-lg shadow-2xl max-w-sm animate-in fade-in slide-in-from-top-4 duration-300 z-50 group"
- >
- <div class="flex justify-between items-start mb-1">
- <div class="text-xs text-zinc-400 uppercase font-bold">Status</div>
- <button
- onclick={() => (status = "Ready")}
- class="text-zinc-500 hover:text-white transition -mt-1 -mr-1 p-1"
- >
- ✕
- </button>
- </div>
- <div class="font-mono text-sm whitespace-pre-wrap mb-2">{status}</div>
- <div class="w-full bg-zinc-700/50 h-1 rounded-full overflow-hidden">
- <div
- class="h-full bg-indigo-500 animate-[progress_5s_linear_forwards] origin-left w-full"
- ></div>
- </div>
- </div>
- {/if}
+ <!-- Content Wrapper -->
+ <div class="relative z-10 flex h-full p-4 gap-4 text-gray-900 dark:text-white">
+ <!-- Floating Sidebar -->
+ <Sidebar />
- {#if showJavaDownloadModal}
- <div
- class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
- onclick={closeJavaDownloadModal}
- >
+ <!-- Main Content Area - Transparent & Flat -->
+ <main class="flex-1 flex flex-col relative min-w-0 overflow-hidden transition-all duration-300">
+
+ <!-- Window Drag Region -->
<div
- class="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md shadow-2xl"
- onclick={(e) => e.stopPropagation()}
- >
- <div class="flex justify-between items-center mb-6">
- <h3 class="text-xl font-bold">Download Java (Adoptium)</h3>
- {#if !isDownloadingJava}
- <button
- onclick={closeJavaDownloadModal}
- class="text-zinc-500 hover:text-white transition text-xl"
- >
- ✕
- </button>
- {/if}
- </div>
- <div class="space-y-4">
- <!-- Version Selection -->
- <div>
- <label class="block text-sm font-bold text-zinc-400 mb-2">Java Version</label>
- <select
- bind:value={selectedJavaVersion}
- disabled={isDownloadingJava}
- class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-white focus:border-indigo-500 outline-none disabled:opacity-50"
- >
- {#each availableJavaVersions as ver}
- <option value={ver}>
- Java {ver} {ver === 21 ? "(Recommended)" : ver === 17 ? "(LTS)" : ver === 8 ? "(Legacy)" : ""}
- </option>
- {/each}
- </select>
- <p class="text-xs text-zinc-500 mt-1">
- MC 1.20.5+ requires Java 21, MC 1.17-1.20.4 requires Java 17, older versions require Java 8
- </p>
+ class="h-8 w-full absolute top-0 left-0 z-50 drag-region"
+ data-tauri-drag-region
+ ></div>
+
+ <!-- App Content -->
+ <div class="flex-1 relative overflow-hidden flex flex-col">
+ <!-- Views Container -->
+ <div class="flex-1 relative overflow-hidden">
+ {#if uiState.currentView === "home"}
+ <HomeView mouseX={mouseX} mouseY={mouseY} />
+ {:else if uiState.currentView === "versions"}
+ <VersionsView />
+ {:else if uiState.currentView === "settings"}
+ <SettingsView />
+ {/if}
</div>
-
- <!-- Image Type Selection -->
- <div>
- <label class="block text-sm font-bold text-zinc-400 mb-2">Type</label>
- <div class="flex gap-3">
- <button
- onclick={() => selectedImageType = "jre"}
- disabled={isDownloadingJava}
- class="flex-1 p-3 rounded border transition-colors disabled:opacity-50 {selectedImageType === 'jre' ? 'border-indigo-500 bg-indigo-950/30 text-white' : 'border-zinc-700 bg-zinc-950 text-zinc-400 hover:border-zinc-500'}"
- >
- <div class="font-bold">JRE</div>
- <div class="text-xs opacity-70">runtime environment</div>
- </button>
- <button
- onclick={() => selectedImageType = "jdk"}
- disabled={isDownloadingJava}
- class="flex-1 p-3 rounded border transition-colors disabled:opacity-50 {selectedImageType === 'jdk' ? 'border-indigo-500 bg-indigo-950/30 text-white' : 'border-zinc-700 bg-zinc-950 text-zinc-400 hover:border-zinc-500'}"
- >
- <div class="font-bold">JDK</div>
- <div class="text-xs opacity-70">development kit</div>
- </button>
- </div>
+
+ <!-- Download Monitor Overlay -->
+ <div class="absolute bottom-20 left-4 right-4 pointer-events-none z-20">
+ <div class="pointer-events-auto">
+ <DownloadMonitor />
+ </div>
</div>
-
- <!-- Status -->
- {#if javaDownloadStatus}
- <div class="p-3 rounded {javaDownloadStatus.startsWith('✓') ? 'bg-green-950/50 border border-green-700 text-green-400' : javaDownloadStatus.includes('failed') || javaDownloadStatus.includes('Failed') ? 'bg-red-950/50 border border-red-700 text-red-400' : 'bg-zinc-800 border border-zinc-700 text-zinc-300'}">
- <p class="text-sm">{javaDownloadStatus}</p>
- </div>
- {/if}
-
- <!-- Download Button -->
- <button
- onclick={downloadJava}
- disabled={isDownloadingJava || availableJavaVersions.length === 0}
- class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white p-3 rounded font-bold transition-colors flex items-center justify-center gap-2"
- >
- {#if isDownloadingJava}
- <div class="animate-spin rounded-full h-5 w-5 border-2 border-white/30 border-t-white"></div>
- Downloading...
- {:else}
- Download Java {selectedJavaVersion} {selectedImageType.toUpperCase()}
- {/if}
- </button>
-
- <p class="text-xs text-zinc-500 text-center">
- Provided by <a href="https://adoptium.net" class="text-indigo-400 hover:underline" onclick={(e) => { e.preventDefault(); openLink("https://adoptium.net"); }}>Eclipse Adoptium</a>
- </p>
- </div>
+
+ <!-- Bottom Bar -->
+ <BottomBar />
</div>
- </div>
- {/if}
+ </main>
+ </div>
- <style>
- @keyframes progress {
- from {
- transform: scaleX(1);
- }
- to {
- transform: scaleX(0);
- }
- }
- </style>
<LoginModal />
<StatusToast />
-
- <GameConsole visible={uiState.showConsole} />
+
+ {#if uiState.showConsole}
+ <!-- Assuming GameConsole handles its own display mode or overlay -->
+ <div class="fixed inset-0 z-[100] bg-black/80 flex items-center justify-center p-10">
+ <div class="w-full h-full bg-[#1e1e1e] rounded-xl overflow-hidden border border-white/10 shadow-2xl relative">
+ <button class="absolute top-4 right-4 text-white hover:text-red-400 z-10" onclick={() => uiState.toggleConsole()}>✕</button>
+ <GameConsole />
+ </div>
+ </div>
+ {/if}
</div>
+
+<style>
+ :global(body) {
+ margin: 0;
+ padding: 0;
+ background: #000;
+ }
+
+ /* Modern Scrollbar */
+ :global(*::-webkit-scrollbar) {
+ width: 6px;
+ height: 6px;
+ }
+
+ :global(*::-webkit-scrollbar-track) {
+ background: transparent;
+ }
+
+ :global(*::-webkit-scrollbar-thumb) {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 999px;
+ }
+
+ :global(*::-webkit-scrollbar-thumb:hover) {
+ background: rgba(255, 255, 255, 0.25);
+ }
+</style>
diff --git a/ui/src/app.css b/ui/src/app.css
index f1d8c73..2ea9a8c 100644
--- a/ui/src/app.css
+++ b/ui/src/app.css
@@ -1 +1,3 @@
@import "tailwindcss";
+
+@variant dark (&:where(.dark, .dark *));
diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte
index dcad9e8..0178111 100644
--- a/ui/src/components/BottomBar.svelte
+++ b/ui/src/components/BottomBar.svelte
@@ -5,18 +5,19 @@
</script>
<div
- class="h-24 bg-zinc-900 border-t border-zinc-800 flex items-center px-8 justify-between z-20 shadow-2xl"
+ class="h-24 bg-gradient-to-t from-black/50 to-transparent dark:from-black/50 dark:to-transparent from-white/90 to-transparent border-t dark:border-white/5 border-black/5 flex items-center px-8 justify-between z-20 backdrop-blur-md"
>
- <div class="flex items-center gap-4">
+ <!-- Account Area -->
+ <div class="flex items-center gap-6">
<div
- class="flex items-center gap-4 cursor-pointer hover:opacity-80 transition-opacity"
+ class="group flex items-center gap-4 cursor-pointer transition-all duration-300 hover:scale-105"
onclick={() => authState.openLoginModal()}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Enter" && authState.openLoginModal()}
>
<div
- class="w-12 h-12 rounded bg-gradient-to-tr from-indigo-500 to-purple-500 shadow-lg flex items-center justify-center text-white font-bold text-xl overflow-hidden"
+ class="w-12 h-12 rounded-xl bg-gradient-to-tr from-indigo-500 to-purple-500 shadow-lg shadow-indigo-500/20 flex items-center justify-center text-white font-bold text-xl overflow-hidden ring-2 ring-transparent dark:group-hover:ring-white/20 group-hover:ring-black/10 transition-all"
>
{#if authState.currentAccount}
<img
@@ -25,63 +26,73 @@
class="w-full h-full"
/>
{:else}
- ?
+ <span class="text-white/50 text-2xl">?</span>
{/if}
</div>
<div>
- <div class="font-bold text-white text-lg">
- {authState.currentAccount ? authState.currentAccount.username : "Click to Login"}
+ <div class="font-bold dark:text-white text-gray-900 text-lg group-hover:text-indigo-500 dark:group-hover:text-indigo-300 transition-colors">
+ {authState.currentAccount ? authState.currentAccount.username : "Login"}
</div>
- <div class="text-xs text-zinc-400 flex items-center gap-1">
+ <div class="text-xs dark:text-zinc-400 text-gray-500 flex items-center gap-1.5">
<span
- class="w-1.5 h-1.5 rounded-full {authState.currentAccount
- ? 'bg-green-500'
- : 'bg-zinc-500'}"
+ class="w-2 h-2 rounded-full {authState.currentAccount
+ ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]'
+ : 'dark:bg-zinc-600 bg-gray-400'}"
></span>
- {authState.currentAccount ? "Ready" : "Guest"}
+ {authState.currentAccount ? "Ready to play" : "Guest Mode"}
</div>
</div>
</div>
+
+ <div class="h-8 w-px dark:bg-white/10 bg-black/10"></div>
+
<!-- Console Toggle -->
<button
- class="ml-4 text-xs text-zinc-500 hover:text-zinc-300 transition"
+ class="text-xs font-mono dark:text-zinc-500 text-gray-500 dark:hover:text-white hover:text-black transition-colors flex items-center gap-2"
onclick={() => uiState.toggleConsole()}
>
+ <span class="text-lg">_</span>
{uiState.showConsole ? "Hide Logs" : "Show Logs"}
</button>
</div>
- <div class="flex items-center gap-4">
+ <!-- Action Area -->
+ <div class="flex items-center gap-6">
<div class="flex flex-col items-end mr-2">
<label
for="version-select"
- class="text-xs text-zinc-500 mb-1 uppercase font-bold tracking-wider"
- >Version</label
+ class="text-[10px] dark:text-white/40 text-black/40 mb-1 uppercase font-bold tracking-wider"
+ >Selected Version</label
>
- <select
- id="version-select"
- bind:value={gameState.selectedVersion}
- class="bg-zinc-950 text-zinc-200 border border-zinc-700 rounded px-4 py-2 hover:border-zinc-500 transition-colors cursor-pointer outline-none focus:ring-1 focus:ring-indigo-500 w-48"
- >
- {#if gameState.versions.length === 0}
- <option>Loading...</option>
- {:else}
- {#each gameState.versions as version}
- <option value={version.id}>{version.id} ({version.type})</option
- >
- {/each}
- {/if}
- </select>
+ <div class="relative group">
+ <select
+ id="version-select"
+ bind:value={gameState.selectedVersion}
+ class="appearance-none dark:bg-black/40 bg-white/60 dark:text-white text-gray-900 border dark:border-white/10 border-black/10 rounded-xl pl-4 pr-10 py-2.5 dark:hover:border-white/30 hover:border-black/30 transition-all cursor-pointer outline-none focus:ring-2 focus:ring-indigo-500/50 w-56 text-sm font-mono backdrop-blur-sm shadow-inner"
+ >
+ {#if gameState.versions.length === 0}
+ <option>Loading...</option>
+ {:else}
+ {#each gameState.versions as version}
+ <option value={version.id}>{version.id} {version.type !== 'release' ? `(${version.type})` : ''}</option>
+ {/each}
+ {/if}
+ </select>
+ <div class="absolute right-3 top-1/2 -translate-y-1/2 dark:text-white/20 text-black/20 pointer-events-none dark:group-hover:text-white/50 group-hover:text-black/50 transition-colors">▼</div>
+ </div>
</div>
<button
onclick={() => gameState.startGame()}
- class="bg-green-600 hover:bg-green-500 text-white font-bold h-14 px-12 rounded transition-all transform active:scale-95 shadow-[0_0_15px_rgba(22,163,74,0.4)] hover:shadow-[0_0_25px_rgba(22,163,74,0.6)] flex flex-col items-center justify-center uppercase tracking-wider text-lg"
+ class="bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-500 hover:to-green-500 text-white font-bold h-14 px-10 rounded-xl transition-all duration-300 transform hover:scale-105 active:scale-95 shadow-[0_0_20px_rgba(16,185,129,0.3)] hover:shadow-[0_0_40px_rgba(16,185,129,0.5)] flex flex-col items-center justify-center uppercase tracking-widest text-xl relative overflow-hidden group"
>
- Play
+ <div class="absolute inset-0 bg-white/20 translate-y-full group-hover:translate-y-0 transition-transform duration-300 skew-y-12"></div>
+ <span class="relative z-10 flex items-center gap-2">
+ PLAY
+ </span>
<span
- class="text-[10px] font-normal opacity-80 normal-case tracking-normal"
- >Click to launch</span
+ class="relative z-10 text-[9px] font-normal opacity-70 normal-case tracking-wide -mt-1"
+ >Launch Game</span
>
</button>
</div>
diff --git a/ui/src/components/HomeView.svelte b/ui/src/components/HomeView.svelte
index e876c14..9cd8014 100644
--- a/ui/src/components/HomeView.svelte
+++ b/ui/src/components/HomeView.svelte
@@ -1,26 +1,47 @@
<script lang="ts">
- // No script needed currently, just static markup mostly
+ type Props = {
+ mouseX: number;
+ mouseY: number;
+ };
+ let { mouseX = 0, mouseY = 0 }: Props = $props();
</script>
-<!-- Background Image - Using gradient fallback -->
-<div
- class="absolute inset-0 z-0 opacity-60 bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 transition-transform duration-[10s] ease-linear group-hover:scale-105"
-></div>
-<div
- class="absolute inset-0 z-0 bg-gradient-to-t from-zinc-900 via-transparent to-black/50"
-></div>
+<div class="absolute inset-0 z-0 overflow-hidden">
+ <!-- Parallax Background Layers -->
+
+ <div class="absolute inset-0 bg-gradient-to-t from-[#09090b] via-[#09090b]/40 to-transparent"></div>
+</div>
-<div class="absolute bottom-24 left-8 z-10 p-4">
- <h1
- class="text-6xl font-black mb-2 tracking-tight text-white drop-shadow-lg"
+<div class="relative z-10 h-full flex flex-col justify-end p-12 pb-24">
+ <!-- 3D Floating Hero Text -->
+ <div
+ class="transition-transform duration-200 ease-out origin-bottom-left"
+ style:transform={`perspective(1000px) rotateX(${mouseY * -2}deg) rotateY(${mouseX * 2}deg)`}
>
- MINECRAFT
- </h1>
- <div class="flex items-center gap-2 text-zinc-300">
- <span
- class="bg-zinc-800 text-xs px-2 py-1 rounded border border-zinc-600"
- >JAVA EDITION</span
+ <h1
+ class="text-8xl font-black tracking-tighter dark:text-white text-gray-900 drop-shadow-2xl mb-4"
+ style:text-shadow="0 10px 30px rgba(0,0,0,0.5)"
>
- <span class="text-lg">Release 1.20.4</span>
+ MINECRAFT
+ </h1>
+
+ <div class="flex items-center gap-4">
+ <div
+ class="bg-white/10 dark:bg-white/10 bg-black/5 backdrop-blur-md border dark:border-white/10 border-black/10 px-4 py-1.5 rounded-full text-sm font-bold uppercase tracking-widest text-emerald-500 dark:text-emerald-400 shadow-xl"
+ >
+ Java Edition
+ </div>
+ <div class="text-2xl font-light dark:text-zinc-300 text-gray-600">
+ Latest Release 1.21
+ </div>
+ </div>
+ </div>
+
+ <!-- Action Area -->
+ <div class="mt-8 flex gap-4">
+ <!-- Quick Play Button (Visual only here, logic is in BottomBar usually) -->
+ <div class="dark:text-zinc-400 text-gray-500 text-sm italic">
+ Ready to play. Select version below or hit Launch.
+ </div>
</div>
</div>
diff --git a/ui/src/components/LoginModal.svelte b/ui/src/components/LoginModal.svelte
index f1ac0d5..1886cd9 100644
--- a/ui/src/components/LoginModal.svelte
+++ b/ui/src/components/LoginModal.svelte
@@ -9,16 +9,16 @@
{#if authState.isLoginModalOpen}
<div
- class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
+ class="fixed inset-0 z-[60] flex items-center justify-center bg-white/80 dark:bg-black/80 backdrop-blur-sm p-4"
>
<div
- class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200"
+ class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200"
>
<div class="flex justify-between items-center mb-6">
- <h2 class="text-2xl font-bold text-white">Login</h2>
+ <h2 class="text-2xl font-bold text-gray-900 dark:text-white">Login</h2>
<button
onclick={() => authState.closeLoginModal()}
- class="text-zinc-500 hover:text-white transition group"
+ class="text-zinc-500 hover:text-black dark:hover:text-white transition group"
>
</button>
@@ -28,7 +28,7 @@
<div class="space-y-4">
<button
onclick={() => authState.startMicrosoftLogin()}
- class="w-full flex items-center justify-center gap-3 bg-[#2F2F2F] hover:bg-[#3F3F3F] text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-500 transition-all group"
+ class="w-full flex items-center justify-center gap-3 bg-gray-100 hover:bg-gray-200 dark:bg-[#2F2F2F] dark:hover:bg-[#3F3F3F] text-gray-900 dark:text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-400 dark:hover:border-zinc-500 transition-all group"
>
<!-- Microsoft Logo SVG -->
<svg
@@ -49,10 +49,10 @@
<div class="relative py-2">
<div class="absolute inset-0 flex items-center">
- <div class="w-full border-t border-zinc-700"></div>
+ <div class="w-full border-t border-zinc-200 dark:border-zinc-700"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
- <span class="bg-zinc-900 px-2 text-zinc-500">OR</span>
+ <span class="bg-white dark:bg-zinc-900 px-2 text-zinc-400 dark:text-zinc-500">OR</span>
</div>
</div>
@@ -61,12 +61,12 @@
type="text"
bind:value={authState.offlineUsername}
placeholder="Offline Username"
- class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-white focus:border-indigo-500 outline-none"
+ class="w-full bg-gray-50 border-zinc-200 dark:bg-zinc-950 dark:border-zinc-700 rounded p-3 text-gray-900 dark:text-white focus:border-indigo-500 outline-none"
onkeydown={(e) => e.key === "Enter" && authState.performOfflineLogin()}
/>
<button
onclick={() => authState.performOfflineLogin()}
- class="w-full bg-zinc-800 hover:bg-zinc-700 text-zinc-300 p-3 rounded font-medium transition-colors"
+ class="w-full bg-gray-200 hover:bg-gray-300 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 p-3 rounded font-medium transition-colors"
>
Offline Login
</button>
@@ -80,18 +80,18 @@
</div>
{:else if authState.deviceCodeData}
<div class="space-y-4">
- <p class="text-sm text-zinc-400">1. Go to this URL:</p>
+ <p class="text-sm text-gray-500 dark:text-zinc-400">1. Go to this URL:</p>
<button
onclick={() =>
authState.deviceCodeData && openLink(authState.deviceCodeData.verification_uri)}
- class="text-indigo-400 hover:text-indigo-300 underline break-all font-mono text-sm"
+ class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 underline break-all font-mono text-sm"
>
{authState.deviceCodeData.verification_uri}
</button>
- <p class="text-sm text-zinc-400 mt-2">2. Enter this code:</p>
+ <p class="text-sm text-gray-500 dark:text-zinc-400 mt-2">2. Enter this code:</p>
<div
- class="bg-zinc-950 p-4 rounded border border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors"
+ class="bg-gray-50 dark:bg-zinc-950 p-4 rounded border border-zinc-200 dark:border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors dark:text-white text-gray-900"
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && navigator.clipboard.writeText(authState.deviceCodeData?.user_code || "")}
@@ -106,8 +106,8 @@
<div class="pt-6 space-y-3">
<div class="flex flex-col items-center gap-3">
- <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-600 border-t-indigo-500"></div>
- <span class="text-sm text-zinc-400 font-medium break-all text-center">{authState.msLoginStatus}</span>
+ <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-300 dark:border-zinc-600 border-t-indigo-500"></div>
+ <span class="text-sm text-gray-600 dark:text-zinc-400 font-medium break-all text-center">{authState.msLoginStatus}</span>
</div>
<p class="text-xs text-zinc-600">This window will update automatically.</p>
</div>
diff --git a/ui/src/components/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte
new file mode 100644
index 0000000..fd26382
--- /dev/null
+++ b/ui/src/components/ModLoaderSelector.svelte
@@ -0,0 +1,245 @@
+<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
+ import type {
+ FabricGameVersion,
+ FabricLoaderVersion,
+ ForgeVersion,
+ ModLoaderType,
+ } from "../types";
+
+ interface Props {
+ selectedGameVersion: string;
+ onInstall: (versionId: string) => void;
+ }
+
+ let { selectedGameVersion, onInstall }: Props = $props();
+
+ // State
+ let selectedLoader = $state<ModLoaderType>("vanilla");
+ let isLoading = $state(false);
+ let error = $state<string | null>(null);
+
+ // Fabric state
+ let fabricLoaders = $state<FabricLoaderVersion[]>([]);
+ let selectedFabricLoader = $state("");
+
+ // Forge state
+ let forgeVersions = $state<ForgeVersion[]>([]);
+ let selectedForgeVersion = $state("");
+
+ // Load mod loader versions when game version changes
+ $effect(() => {
+ if (selectedGameVersion && selectedLoader !== "vanilla") {
+ loadModLoaderVersions();
+ }
+ });
+
+ async function loadModLoaderVersions() {
+ isLoading = true;
+ error = null;
+
+ try {
+ if (selectedLoader === "fabric") {
+ const loaders = await invoke<any[]>("get_fabric_loaders_for_version", {
+ gameVersion: selectedGameVersion,
+ });
+ fabricLoaders = loaders.map((l) => l.loader);
+ if (fabricLoaders.length > 0) {
+ // Select first stable version or first available
+ const stable = fabricLoaders.find((l) => l.stable);
+ selectedFabricLoader = stable?.version || fabricLoaders[0].version;
+ }
+ } else if (selectedLoader === "forge") {
+ forgeVersions = await invoke<ForgeVersion[]>(
+ "get_forge_versions_for_game",
+ {
+ gameVersion: selectedGameVersion,
+ }
+ );
+ if (forgeVersions.length > 0) {
+ // Select recommended version first, then latest
+ const recommended = forgeVersions.find((v) => v.recommended);
+ const latest = forgeVersions.find((v) => v.latest);
+ selectedForgeVersion =
+ recommended?.version || latest?.version || forgeVersions[0].version;
+ }
+ }
+ } catch (e) {
+ error = `Failed to load ${selectedLoader} versions: ${e}`;
+ console.error(e);
+ } finally {
+ isLoading = false;
+ }
+ }
+
+ async function installModLoader() {
+ if (!selectedGameVersion) {
+ error = "Please select a Minecraft version first";
+ return;
+ }
+
+ isLoading = true;
+ error = null;
+
+ try {
+ if (selectedLoader === "fabric" && selectedFabricLoader) {
+ const result = await invoke<any>("install_fabric", {
+ gameVersion: selectedGameVersion,
+ loaderVersion: selectedFabricLoader,
+ });
+ onInstall(result.id);
+ } else if (selectedLoader === "forge" && selectedForgeVersion) {
+ const result = await invoke<any>("install_forge", {
+ gameVersion: selectedGameVersion,
+ forgeVersion: selectedForgeVersion,
+ });
+ onInstall(result.id);
+ }
+ } catch (e) {
+ error = `Failed to install ${selectedLoader}: ${e}`;
+ console.error(e);
+ } finally {
+ isLoading = false;
+ }
+ }
+
+ function onLoaderChange(loader: ModLoaderType) {
+ selectedLoader = loader;
+ error = null;
+ if (loader !== "vanilla" && selectedGameVersion) {
+ loadModLoaderVersions();
+ }
+ }
+</script>
+
+<div class="space-y-4">
+ <div class="flex items-center justify-between">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-gray-500 dark:text-white/40">Select Mod Loader</h3>
+ </div>
+
+ <!-- Loader Type Tabs - Segmented Control -->
+ <div class="flex p-1 bg-white/60 dark:bg-black/40 rounded-xl border border-black/5 dark:border-white/5 backdrop-blur-sm">
+ <button
+ class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
+ {selectedLoader === 'vanilla'
+ ? 'bg-white shadow-lg border border-black/5 text-black dark:bg-white/10 dark:text-white dark:border-white/10'
+ : 'text-gray-500 dark:text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
+ onclick={() => onLoaderChange("vanilla")}
+ >
+ Vanilla
+ </button>
+ <button
+ class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
+ {selectedLoader === 'fabric'
+ ? 'bg-indigo-100 text-indigo-700 border border-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:shadow-lg dark:border-indigo-500/20'
+ : 'text-gray-500 dark:text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
+ onclick={() => onLoaderChange("fabric")}
+ >
+ Fabric
+ </button>
+ <button
+ class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
+ {selectedLoader === 'forge'
+ ? 'bg-orange-100 text-orange-700 border border-orange-200 dark:bg-orange-500/20 dark:text-orange-300 dark:shadow-lg dark:border-orange-500/20'
+ : 'text-gray-500 dark:text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
+ onclick={() => onLoaderChange("forge")}
+ >
+ Forge
+ </button>
+ </div>
+
+ <!-- Content Area -->
+ <div class="min-h-[100px] flex flex-col justify-center">
+ {#if selectedLoader === "vanilla"}
+ <div class="text-center p-4 rounded-xl bg-white/40 dark:bg-white/5 border border-dashed border-black/5 dark:border-white/10 text-gray-500 dark:text-white/40 text-sm">
+ No mod loader selected. <br> Pure vanilla experience.
+ </div>
+
+ {:else if !selectedGameVersion}
+ <div class="text-center p-4 rounded-xl bg-red-50 border border-red-200 text-red-700 dark:bg-red-500/10 dark:border-red-500/20 dark:text-red-300 text-sm">
+ ⚠️ Please select a base Minecraft version first.
+ </div>
+
+ {:else if isLoading}
+ <div class="flex flex-col items-center gap-2 text-sm text-gray-500 dark:text-white/50 py-4">
+ <div class="w-6 h-6 border-2 border-gray-200 border-t-gray-500 dark:border-white/20 dark:border-t-white rounded-full animate-spin"></div>
+ Loading {selectedLoader} versions...
+ </div>
+
+ {:else if error}
+ <div class="p-4 bg-red-50 border border-red-200 text-red-700 dark:bg-red-500/10 dark:border-red-500/20 dark:text-red-300 rounded-xl text-sm break-words">
+ {error}
+ </div>
+
+ {:else if selectedLoader === "fabric"}
+ <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
+ <div>
+ <label for="fabric-loader-select" class="block text-xs text-gray-500 dark:text-white/40 mb-2 pl-1"
+ >Loader Version</label
+ >
+ <div class="relative">
+ <select
+ id="fabric-loader-select"
+ class="w-full appearance-none bg-white/80 dark:bg-black/40 border border-black/10 dark:border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-indigo-500/50 text-gray-900 dark:text-white transition-colors"
+ bind:value={selectedFabricLoader}
+ >
+ {#each fabricLoaders as loader}
+ <option value={loader.version}>
+ {loader.version} {loader.stable ? "(stable)" : ""}
+ </option>
+ {/each}
+ </select>
+ <div class="absolute right-4 top-1/2 -translate-y-1/2 text-black/30 dark:text-white/20 pointer-events-none">▼</div>
+ </div>
+ </div>
+
+ <button
+ class="w-full bg-indigo-600 hover:bg-indigo-500 text-white py-3 px-4 rounded-xl font-bold text-sm transition-all shadow-lg shadow-indigo-500/20 disabled:opacity-50 disabled:shadow-none hover:scale-[1.02] active:scale-[0.98]"
+ onclick={installModLoader}
+ disabled={isLoading || !selectedFabricLoader}
+ >
+ Install Fabric {selectedFabricLoader}
+ </button>
+ </div>
+
+ {:else if selectedLoader === "forge"}
+ <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
+ {#if forgeVersions.length === 0}
+ <div class="text-center p-4 text-sm text-gray-500 dark:text-white/40 italic">
+ No Forge versions available for {selectedGameVersion}
+ </div>
+ {:else}
+ <div>
+ <label for="forge-version-select" class="block text-xs text-gray-500 dark:text-white/40 mb-2 pl-1"
+ >Forge Version</label
+ >
+ <div class="relative">
+ <select
+ id="forge-version-select"
+ class="w-full appearance-none bg-white/80 dark:bg-black/40 border border-black/10 dark:border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-orange-500/50 text-gray-900 dark:text-white transition-colors"
+ bind:value={selectedForgeVersion}
+ >
+ {#each forgeVersions as version}
+ <option value={version.version}>
+ {version.version}
+ {version.recommended ? "⭐ recommended" : ""}
+ {version.latest ? "(latest)" : ""}
+ </option>
+ {/each}
+ </select>
+ <div class="absolute right-4 top-1/2 -translate-y-1/2 text-black/30 dark:text-white/20 pointer-events-none">▼</div>
+ </div>
+ </div>
+
+ <button
+ class="w-full bg-orange-600 hover:bg-orange-500 text-white py-3 px-4 rounded-xl font-bold text-sm transition-all shadow-lg shadow-orange-500/20 disabled:opacity-50 disabled:shadow-none hover:scale-[1.02] active:scale-[0.98]"
+ onclick={installModLoader}
+ disabled={isLoading || !selectedForgeVersion}
+ >
+ Install Forge {selectedForgeVersion}
+ </button>
+ {/if}
+ </div>
+ {/if}
+ </div>
+</div>
diff --git a/ui/src/components/ParticleBackground.svelte b/ui/src/components/ParticleBackground.svelte
new file mode 100644
index 0000000..080f1f2
--- /dev/null
+++ b/ui/src/components/ParticleBackground.svelte
@@ -0,0 +1,57 @@
+<script lang="ts">
+ import { onMount, onDestroy } from "svelte";
+ import { ConstellationEffect } from "../lib/effects/ConstellationEffect";
+ import { SaturnEffect } from "../lib/effects/SaturnEffect";
+ import { settingsState } from "../stores/settings.svelte";
+
+ let canvas: HTMLCanvasElement;
+ let activeEffect: any;
+
+ function loadEffect() {
+ if (activeEffect) {
+ activeEffect.destroy();
+ }
+
+ if (!canvas) return;
+
+ if (settingsState.settings.active_effect === "saturn") {
+ activeEffect = new SaturnEffect(canvas);
+ } else {
+ activeEffect = new ConstellationEffect(canvas);
+ }
+
+ // Ensure correct size immediately
+ activeEffect.resize(window.innerWidth, window.innerHeight);
+ }
+
+ $effect(() => {
+ const _ = settingsState.settings.active_effect;
+ if (canvas) {
+ loadEffect();
+ }
+ });
+
+ onMount(() => {
+ const resizeObserver = new ResizeObserver(() => {
+ if (canvas && activeEffect) {
+ activeEffect.resize(window.innerWidth, window.innerHeight);
+ }
+ });
+
+ resizeObserver.observe(document.body);
+
+ return () => {
+ resizeObserver.disconnect();
+ if (activeEffect) activeEffect.destroy();
+ };
+ });
+
+ onDestroy(() => {
+ if (activeEffect) activeEffect.destroy();
+ });
+</script>
+
+<canvas
+ bind:this={canvas}
+ class="absolute inset-0 z-0 pointer-events-none"
+></canvas>
diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte
index 9f260c1..86bcce1 100644
--- a/ui/src/components/SettingsView.svelte
+++ b/ui/src/components/SettingsView.svelte
@@ -1,126 +1,296 @@
<script lang="ts">
import { settingsState } from "../stores/settings.svelte";
+ import { open } from "@tauri-apps/plugin-dialog";
+ import { convertFileSrc } from "@tauri-apps/api/core";
+
+ async function selectBackground() {
+ try {
+ const selected = await open({
+ multiple: false,
+ filters: [
+ {
+ name: "Images",
+ extensions: ["png", "jpg", "jpeg", "webp", "gif"],
+ },
+ ],
+ });
+
+ if (selected && typeof selected === "string") {
+ settingsState.settings.custom_background_path = selected;
+ settingsState.saveSettings();
+ }
+ } catch (e) {
+ console.error("Failed to select background:", e);
+ }
+ }
+
+ function clearBackground() {
+ settingsState.settings.custom_background_path = undefined;
+ settingsState.saveSettings();
+ }
</script>
-<div class="p-8 bg-zinc-900 h-full overflow-y-auto">
- <h2 class="text-3xl font-bold mb-8">Settings</h2>
+<div class="h-full flex flex-col p-6 overflow-hidden">
+ <div class="flex items-center justify-between mb-6">
+ <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">Settings</h2>
+ </div>
- <div class="space-y-6 max-w-2xl">
- <!-- Java Path -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <label
- for="java-path"
- class="block text-sm font-bold text-zinc-400 mb-2 uppercase tracking-wide"
- >Java Executable Path</label
- >
- <div class="flex gap-2">
- <input
- id="java-path"
- bind:value={settingsState.settings.java_path}
- type="text"
- class="bg-zinc-950 text-white flex-1 p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none font-mono text-sm"
- placeholder="e.g. java, /usr/bin/java"
- />
- <button
- onclick={() => settingsState.detectJava()}
- disabled={settingsState.isDetectingJava}
- class="bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-white px-4 py-2 rounded transition-colors whitespace-nowrap"
- >
- {settingsState.isDetectingJava ? "Detecting..." : "Auto Detect"}
- </button>
+ <div class="flex-1 overflow-y-auto pr-2 space-y-6 custom-scrollbar pb-10">
+
+ <!-- Appearance / Background -->
+ <div class="dark:bg-black/20 bg-white/60 p-6 rounded-2xl border dark:border-white/5 border-black/5 shadow-sm backdrop-blur-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-6 flex items-center gap-2">
+ Appearance
+ </h3>
+
+ <div class="space-y-4">
+ <div>
+ <label class="block text-sm font-medium dark:text-white/70 text-black/70 mb-3">Custom Background Image</label>
+
+ <div class="flex items-center gap-6">
+ <!-- Preview -->
+ <div class="w-40 h-24 rounded-xl overflow-hidden dark:bg-black/50 bg-gray-200 border dark:border-white/10 border-black/10 relative group shadow-lg">
+ {#if settingsState.settings.custom_background_path}
+ <img
+ src={convertFileSrc(settingsState.settings.custom_background_path)}
+ alt="Background Preview"
+ class="w-full h-full object-cover"
+ />
+ {:else}
+ <div class="w-full h-full bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 opacity-100"></div>
+ <div class="absolute inset-0 flex items-center justify-center text-[10px] text-white/50 bg-black/20 ">Default Gradient</div>
+ {/if}
+ </div>
+
+ <!-- Actions -->
+ <div class="flex flex-col gap-2">
+ <button
+ onclick={selectBackground}
+ class="dark:bg-white/10 dark:hover:bg-white/20 bg-black/5 hover:bg-black/10 dark:text-white text-black px-4 py-2 rounded-lg text-sm transition-colors border dark:border-white/5 border-black/5"
+ >
+ Select Image
+ </button>
+
+ {#if settingsState.settings.custom_background_path}
+ <button
+ onclick={clearBackground}
+ class="text-red-400 hover:text-red-300 text-sm px-4 py-1 text-left transition-colors"
+ >
+ Reset to Default
+ </button>
+ {/if}
+ </div>
+ </div>
+ <p class="text-xs dark:text-white/30 text-black/40 mt-3">
+ Select an image from your computer to replace the default gradient background.
+ Supported formats: PNG, JPG, WEBP, GIF.
+ </p>
+ </div>
+
+ <!-- Visual Settings -->
+ <div class="pt-4 border-t dark:border-white/5 border-black/5 space-y-4">
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="visual-effects-label">Visual Effects</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Enable particle effects and animated gradients. (Default: On)</p>
+ </div>
+ <button
+ aria-labelledby="visual-effects-label"
+ onclick={() => { settingsState.settings.enable_visual_effects = !settingsState.settings.enable_visual_effects; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.enable_visual_effects ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.enable_visual_effects ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ {#if settingsState.settings.enable_visual_effects}
+ <div class="flex items-center justify-between pl-2 border-l-2 dark:border-white/5 border-black/5 ml-1">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="theme-effect-label">Theme Effect</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Select the active visual theme.</p>
+ </div>
+ <select
+ aria-labelledby="theme-effect-label"
+ bind:value={settingsState.settings.active_effect}
+ onchange={() => settingsState.saveSettings()}
+ class="dark:bg-black/40 bg-white dark:text-white text-black text-xs px-3 py-2 rounded-lg border dark:border-white/10 border-black/10 outline-none focus:border-indigo-500/50 appearance-none cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
+ >
+ <option value="saturn">Saturn (Saturn)</option>
+ <option value="constellation">Network (Constellation)</option>
+ </select>
+ </div>
+ {/if}
+
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="gpu-acceleration-label">GPU Acceleration</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Enable GPU acceleration for the interface. (Default: Off, Requires Restart)</p>
+ </div>
+ <button
+ aria-labelledby="gpu-acceleration-label"
+ onclick={() => { settingsState.settings.enable_gpu_acceleration = !settingsState.settings.enable_gpu_acceleration; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.enable_gpu_acceleration ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.enable_gpu_acceleration ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ <!-- Color Theme Switcher -->
+ <div class="flex items-center justify-between pt-4 border-t dark:border-white/5 border-black/5 opacity-50 cursor-not-allowed">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="color-theme-label">Color Theme</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Interface color mode. (Locked to Dark)</p>
+ </div>
+ <div class="flex items-center bg-black/5 dark:bg-white/5 rounded-lg p-1 pointer-events-none">
+ <button
+ disabled
+ class="px-3 py-1 rounded-md text-xs font-medium transition-all text-gray-500 dark:text-gray-600"
+ >
+ Light
+ </button>
+ <button
+ disabled
+ class="px-3 py-1 rounded-md text-xs font-medium transition-all bg-indigo-500 text-white shadow-sm"
+ >
+ Dark
+ </button>
+ </div>
+ </div>
+ </div>
</div>
+ </div>
+
+ <!-- Java Path -->
+ <div class="dark:bg-black/20 bg-white/60 p-6 rounded-2xl border dark:border-white/5 border-black/5 shadow-sm backdrop-blur-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ Java Environment
+ </h3>
+ <div class="space-y-4">
+ <div>
+ <label for="java-path" class="block text-sm font-medium text-white/70 mb-2">Java Executable Path</label>
+ <div class="flex gap-2">
+ <input
+ id="java-path"
+ bind:value={settingsState.settings.java_path}
+ type="text"
+ class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+ placeholder="e.g. java, /usr/bin/java"
+ />
+ <button
+ onclick={() => settingsState.detectJava()}
+ disabled={settingsState.isDetectingJava}
+ class="bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white px-4 py-2 rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium"
+ >
+ {settingsState.isDetectingJava ? "Detecting..." : "Auto Detect"}
+ </button>
+ </div>
+ </div>
{#if settingsState.javaInstallations.length > 0}
<div class="mt-4 space-y-2">
- <p class="text-xs text-zinc-400 uppercase font-bold">Detected Java Installations:</p>
+ <p class="text-[10px] text-white/30 uppercase font-bold tracking-wider">Detected Installations</p>
{#each settingsState.javaInstallations as java}
<button
onclick={() => settingsState.selectJava(java.path)}
- class="w-full text-left p-3 bg-zinc-950 rounded border transition-colors {settingsState.settings.java_path === java.path ? 'border-indigo-500 bg-indigo-950/30' : 'border-zinc-700 hover:border-zinc-500'}"
+ class="w-full text-left p-3 rounded-lg border transition-all duration-200 group
+ {settingsState.settings.java_path === java.path
+ ? 'bg-indigo-500/20 border-indigo-500/30'
+ : 'bg-black/20 border-white/5 hover:bg-white/5 hover:border-white/10'}"
>
<div class="flex justify-between items-center">
<div>
- <span class="text-white font-mono text-sm">{java.version}</span>
- <span class="text-zinc-500 text-xs ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span>
+ <span class="text-white font-mono text-xs font-bold">{java.version}</span>
+ <span class="text-white/40 text-[10px] ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span>
</div>
{#if settingsState.settings.java_path === java.path}
- <span class="text-indigo-400 text-xs">Selected</span>
+ <span class="text-indigo-300 text-[10px] font-bold uppercase tracking-wider">Selected</span>
{/if}
</div>
- <div class="text-zinc-500 text-xs font-mono truncate mt-1">{java.path}</div>
+ <div class="text-white/30 text-[10px] font-mono truncate mt-1 group-hover:text-white/50">{java.path}</div>
</button>
{/each}
</div>
{/if}
-
- <p class="text-xs text-zinc-500 mt-2">
- The command or path to the Java Runtime Environment. Click "Auto Detect" to find installed Java versions.
- </p>
+ </div>
</div>
<!-- Memory -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <h3
- class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide"
- >Memory Allocation (RAM)</h3>
-
+ <div class="bg-black/20 p-6 rounded-2xl border border-white/5 ">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ Memory Allocation (RAM)
+ </h3>
<div class="grid grid-cols-2 gap-6">
<div>
- <label for="min-memory" class="block text-xs text-zinc-500 mb-1"
- >Minimum (MB)</label
- >
+ <label for="min-memory" class="block text-sm font-medium text-white/70 mb-2">Minimum (MB)</label>
<input
id="min-memory"
bind:value={settingsState.settings.min_memory}
type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
/>
</div>
<div>
- <label for="max-memory" class="block text-xs text-zinc-500 mb-1"
- >Maximum (MB)</label
- >
+ <label for="max-memory" class="block text-sm font-medium text-white/70 mb-2">Maximum (MB)</label>
<input
id="max-memory"
bind:value={settingsState.settings.max_memory}
type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
/>
</div>
</div>
</div>
<!-- Resolution -->
- <div class="bg-zinc-800/50 p-6 rounded-lg border border-zinc-700">
- <h3
- class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide"
- >Game Window Size</h3>
+ <div class="bg-black/20 p-6 rounded-2xl border border-white/5 ">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ Game Window Size
+ </h3>
<div class="grid grid-cols-2 gap-6">
<div>
- <label for="window-width" class="block text-xs text-zinc-500 mb-1">Width</label>
+ <label for="window-width" class="block text-sm font-medium text-white/70 mb-2">Width</label>
<input
id="window-width"
bind:value={settingsState.settings.width}
type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
/>
</div>
<div>
- <label for="window-height" class="block text-xs text-zinc-500 mb-1">Height</label>
+ <label for="window-height" class="block text-sm font-medium text-white/70 mb-2">Height</label>
<input
id="window-height"
bind:value={settingsState.settings.height}
type="number"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
/>
</div>
</div>
</div>
- <div class="pt-4">
+ <!-- Download Settings -->
+ <div class="bg-black/20 p-6 rounded-2xl border border-white/5 ">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ Network
+ </h3>
+ <div>
+ <label for="download-threads" class="block text-sm font-medium text-white/70 mb-2">Concurrent Download Threads</label>
+ <input
+ id="download-threads"
+ bind:value={settingsState.settings.download_threads}
+ type="number"
+ min="1"
+ max="128"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
+ />
+ <p class="text-xs text-white/30 mt-2">Higher values usually mean faster downloads but use more CPU/Network.</p>
+ </div>
+ </div>
+
+ <div class="pt-4 flex justify-end">
<button
onclick={() => settingsState.saveSettings()}
- class="bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-8 rounded shadow-lg transition-transform active:scale-95"
+ class="bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-500 hover:to-green-500 text-white font-bold py-3 px-8 rounded-xl shadow-lg shadow-emerald-500/20 transition-all hover:scale-105 active:scale-95"
>
Save Settings
</button>
diff --git a/ui/src/components/Sidebar.svelte b/ui/src/components/Sidebar.svelte
index a4f4e35..e6fbf43 100644
--- a/ui/src/components/Sidebar.svelte
+++ b/ui/src/components/Sidebar.svelte
@@ -3,64 +3,56 @@
</script>
<aside
- class="w-20 lg:w-64 bg-zinc-950 flex flex-col items-center lg:items-start transition-all duration-300 border-r border-zinc-800 shrink-0"
+ class="w-20 lg:w-64 dark:bg-black bg-white/80 border-r dark:border-white/5 border-gray-200/50 flex flex-col items-center lg:items-start transition-all duration-300 shrink-0 py-6 backdrop-blur-md"
>
+ <!-- Logo Area -->
<div
- class="h-20 w-full flex items-center justify-center lg:justify-start lg:px-6 border-b border-zinc-800/50"
+ class="h-16 w-full flex items-center justify-center lg:justify-start lg:px-8 mb-6"
>
- <!-- Icon Logo (Visible on small) -->
+ <!-- Icon Logo (Small) -->
<div
- class="lg:hidden text-2xl font-black bg-clip-text text-transparent bg-gradient-to-tr from-indigo-400 to-purple-400"
+ class="lg:hidden text-3xl font-black bg-clip-text text-transparent bg-gradient-to-tr from-indigo-400 to-fuchsia-400 drop-shadow-lg"
>
D
</div>
- <!-- Full Logo (Visible on large) -->
+ <!-- Full Logo (Large) -->
<div
- class="hidden lg:block font-bold text-xl tracking-wider text-indigo-400"
+ class="hidden lg:block font-bold text-2xl tracking-wider dark:text-white text-gray-900"
>
- DROP<span class="text-white">OUT</span>
+ <span class="bg-clip-text text-transparent bg-gradient-to-r from-indigo-400 to-fuchsia-400">DROP</span>OUT
</div>
</div>
- <nav class="flex-1 w-full flex flex-col gap-2 p-3">
- <button
- class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView ===
- 'home'
- ? 'bg-zinc-800/80 text-white'
- : 'text-zinc-400'} transition-all relative"
- onclick={() => uiState.setView("home")}
- >
- <span class="text-xl relative z-10">🏠</span>
- <span
- class="hidden lg:block font-medium relative z-10 transition-opacity"
- >Home</span
+ <!-- Navigation -->
+ <nav class="flex-1 w-full flex flex-col gap-3 px-3">
+ <!-- Nav Item Helper -->
+ {#snippet navItem(view, icon, label)}
+ <button
+ class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-5 py-3.5 rounded-xl transition-all duration-200 relative overflow-hidden
+ {uiState.currentView === view
+ ? 'bg-gradient-to-r from-indigo-500/20 to-purple-500/20 dark:text-white text-indigo-900 shadow-lg shadow-indigo-500/10 dark:border border-white/10'
+ : 'dark:text-zinc-400 text-gray-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
+ onclick={() => uiState.setView(view)}
>
- </button>
- <button
- class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView ===
- 'versions'
- ? 'bg-zinc-800/80 text-white'
- : 'text-zinc-400'} transition-all"
- onclick={() => uiState.setView("versions")}
- >
- <span class="text-xl">📦</span>
- <span class="hidden lg:block font-medium">Versions</span>
- </button>
- <button
- class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView ===
- 'settings'
- ? 'bg-zinc-800/80 text-white'
- : 'text-zinc-400'} transition-all"
- onclick={() => uiState.setView("settings")}
- >
- <span class="text-xl">⚙️</span>
- <span class="hidden lg:block font-medium">Settings</span>
- </button>
+ <span class="text-xl relative z-10 transition-transform group-hover:scale-110 duration-200">{icon}</span>
+ <span class="hidden lg:block font-medium relative z-10">{label}</span>
+
+ <!-- Active Indicator Line -->
+ {#if uiState.currentView === view}
+ <div class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-indigo-500 rounded-r-full lg:hidden"></div>
+ {/if}
+ </button>
+ {/snippet}
+
+ {@render navItem('home', '🏠', 'Home')}
+ {@render navItem('versions', '📦', 'Versions')}
+ {@render navItem('settings', '⚙️', 'Settings')}
</nav>
+ <!-- Footer Info -->
<div
- class="p-4 w-full border-t border-zinc-800 flex justify-center lg:justify-start"
+ class="p-4 w-full flex justify-center lg:justify-start lg:px-8 opacity-50 hover:opacity-100 transition-opacity"
>
- <div class="text-xs text-zinc-600 font-mono">v{uiState.appVersion}</div>
+ <div class="text-xs font-mono tracking-widest text-zinc-500">v{uiState.appVersion}</div>
</div>
</aside>
diff --git a/ui/src/components/StatusToast.svelte b/ui/src/components/StatusToast.svelte
index b1feffc..4c981c7 100644
--- a/ui/src/components/StatusToast.svelte
+++ b/ui/src/components/StatusToast.svelte
@@ -3,28 +3,34 @@
</script>
{#if uiState.status !== "Ready"}
- <div
- class="absolute top-12 right-12 bg-zinc-800/90 backdrop-blur border border-zinc-600 p-4 rounded-lg shadow-2xl max-w-sm animate-in fade-in slide-in-from-top-4 duration-300 z-50 group"
- >
- <div class="flex justify-between items-start mb-1">
- <div class="text-xs text-zinc-400 uppercase font-bold">Status</div>
- <button
- onclick={() => uiState.setStatus("Ready")}
- class="text-zinc-500 hover:text-white transition -mt-1 -mr-1 p-1"
- >
- ✕
- </button>
+ {#key uiState.status}
+ <div
+ class="absolute top-12 right-12 bg-white/90 dark:bg-zinc-800/90 backdrop-blur border border-zinc-200 dark:border-zinc-600 p-4 rounded-lg shadow-2xl max-w-sm animate-in fade-in slide-in-from-top-4 duration-300 z-50 group"
+ >
+ <div class="flex justify-between items-start mb-1">
+ <div class="text-xs text-zinc-500 dark:text-zinc-400 uppercase font-bold">Status</div>
+ <button
+ onclick={() => uiState.setStatus("Ready")}
+ class="text-zinc-400 hover:text-black dark:text-zinc-500 dark:hover:text-white transition -mt-1 -mr-1 p-1"
+ >
+ ✕
+ </button>
+ </div>
+ <div class="font-mono text-sm whitespace-pre-wrap mb-2 text-gray-900 dark:text-gray-100">{uiState.status}</div>
+ <div class="w-full bg-gray-200 dark:bg-zinc-700/50 h-1 rounded-full overflow-hidden">
+ <div
+ class="h-full bg-indigo-500 origin-left w-full progress-bar"
+ ></div>
+ </div>
</div>
- <div class="font-mono text-sm whitespace-pre-wrap mb-2">{uiState.status}</div>
- <div class="w-full bg-zinc-700/50 h-1 rounded-full overflow-hidden">
- <div
- class="h-full bg-indigo-500 animate-[progress_5s_linear_forwards] origin-left w-full"
- ></div>
- </div>
- </div>
+ {/key}
{/if}
<style>
+ .progress-bar {
+ animation: progress 5s linear forwards;
+ }
+
@keyframes progress {
from {
transform: scaleX(1);
diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte
index 8c0ddfe..8f3a568 100644
--- a/ui/src/components/VersionsView.svelte
+++ b/ui/src/components/VersionsView.svelte
@@ -1,34 +1,237 @@
<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
import { gameState } from "../stores/game.svelte";
+ import ModLoaderSelector from "./ModLoaderSelector.svelte";
+
+ let searchQuery = $state("");
+ let normalizedQuery = $derived(
+ searchQuery.trim().toLowerCase().replace(/。/g, ".")
+ );
+
+ // Filter by version type
+ let typeFilter = $state<"all" | "release" | "snapshot" | "modded">("all");
+
+ // Installed modded versions
+ let installedFabricVersions = $state<string[]>([]);
+ let isLoadingModded = $state(false);
+
+ // Load installed modded versions
+ async function loadInstalledModdedVersions() {
+ isLoadingModded = true;
+ try {
+ installedFabricVersions = await invoke<string[]>(
+ "list_installed_fabric_versions"
+ );
+ } catch (e) {
+ console.error("Failed to load installed fabric versions:", e);
+ } finally {
+ isLoadingModded = false;
+ }
+ }
+
+ // Load on mount
+ $effect(() => {
+ loadInstalledModdedVersions();
+ });
+
+ // Combined versions list (vanilla + modded)
+ let allVersions = $derived(() => {
+ const moddedVersions = installedFabricVersions.map((id) => ({
+ id,
+ type: "fabric",
+ url: "",
+ time: "",
+ releaseTime: new Date().toISOString(),
+ }));
+ return [...moddedVersions, ...gameState.versions];
+ });
+
+ let filteredVersions = $derived(() => {
+ let versions = allVersions();
+
+ // Apply type filter
+ if (typeFilter === "release") {
+ versions = versions.filter((v) => v.type === "release");
+ } else if (typeFilter === "snapshot") {
+ versions = versions.filter((v) => v.type === "snapshot");
+ } else if (typeFilter === "modded") {
+ versions = versions.filter(
+ (v) => v.type === "fabric" || v.type === "forge"
+ );
+ }
+
+ // Apply search filter
+ if (normalizedQuery.length > 0) {
+ versions = versions.filter((v) =>
+ v.id.toLowerCase().includes(normalizedQuery)
+ );
+ }
+
+ return versions;
+ });
+
+ function getVersionBadge(type: string) {
+ switch (type) {
+ case "release":
+ return { text: "Release", class: "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-300 dark:border-emerald-500/30" };
+ case "snapshot":
+ return { text: "Snapshot", class: "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/20 dark:text-amber-300 dark:border-amber-500/30" };
+ case "fabric":
+ return { text: "Fabric", class: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:border-indigo-500/30" };
+ case "forge":
+ return { text: "Forge", class: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-300 dark:border-orange-500/30" };
+ default:
+ return { text: type, class: "bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-500/20 dark:text-zinc-300 dark:border-zinc-500/30" };
+ }
+ }
+
+ function handleModLoaderInstall(versionId: string) {
+ // Refresh the installed versions list
+ loadInstalledModdedVersions();
+ // Select the newly installed version
+ gameState.selectedVersion = versionId;
+ }
+
+ // Get the base Minecraft version from selected version (for mod loader selector)
+ let selectedBaseVersion = $derived(() => {
+ const selected = gameState.selectedVersion;
+ if (!selected) return "";
+
+ // If it's a modded version, extract the base version
+ if (selected.startsWith("fabric-loader-")) {
+ // Format: fabric-loader-X.X.X-1.20.4
+ const parts = selected.split("-");
+ return parts[parts.length - 1];
+ }
+ if (selected.includes("-forge-")) {
+ // Format: 1.20.4-forge-49.0.38
+ return selected.split("-forge-")[0];
+ }
+
+ // Check if it's a valid vanilla version
+ const version = gameState.versions.find((v) => v.id === selected);
+ return version ? selected : "";
+ });
</script>
-<div class="p-8 h-full overflow-y-auto bg-zinc-900">
- <h2 class="text-3xl font-bold mb-6">Versions</h2>
- <div class="grid gap-2">
- {#if gameState.versions.length === 0}
- <div class="text-zinc-500">Loading versions...</div>
- {:else}
- {#each gameState.versions as version}
- <button
- class="flex items-center justify-between p-4 bg-zinc-800 rounded hover:bg-zinc-700 transition text-left border border-zinc-700 {gameState.selectedVersion ===
- version.id
- ? 'border-green-500 bg-zinc-800/80 ring-1 ring-green-500'
- : ''}"
- onclick={() => (gameState.selectedVersion = version.id)}
- >
- <div>
- <div class="font-bold font-mono text-lg">{version.id}</div>
- <div class="text-xs text-zinc-400 capitalize">
- {version.type} • {new Date(
- version.releaseTime
- ).toLocaleDateString()}
- </div>
+<div class="h-full flex flex-col p-6 overflow-hidden">
+ <div class="flex items-center justify-between mb-6">
+ <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60">Version Manager</h2>
+ <div class="text-sm dark:text-white/40 text-black/50">Select a version to play or modify</div>
+ </div>
+
+ <div class="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden">
+ <!-- Left: Version List -->
+ <div class="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
+ <!-- Search and Filters (Glass Bar) -->
+ <div class="flex gap-3">
+ <div class="relative flex-1">
+ <span class="absolute left-3 top-1/2 -translate-y-1/2 dark:text-white/30 text-black/30">🔍</span>
+ <input
+ type="text"
+ placeholder="Search versions..."
+ class="w-full pl-9 pr-4 py-3 bg-white/60 dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-xl dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:outline-none focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 transition-all backdrop-blur-sm"
+ bind:value={searchQuery}
+ />
+ </div>
+ </div>
+
+ <!-- Type Filter Tabs (Glass Caps) -->
+ <div class="flex p-1 bg-white/60 dark:bg-black/20 rounded-xl border border-black/5 dark:border-white/5">
+ {#each ['all', 'release', 'snapshot', 'modded'] as filter}
+ <button
+ class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 capitalize
+ {typeFilter === filter
+ ? 'bg-white shadow-sm border border-black/5 dark:bg-white/10 dark:text-white dark:shadow-lg dark:border-white/10 text-black'
+ : 'text-black/40 dark:text-white/40 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
+ onclick={() => (typeFilter = filter as any)}
+ >
+ {filter}
+ </button>
+ {/each}
+ </div>
+
+ <!-- Version List SCROLL -->
+ <div class="flex-1 overflow-y-auto pr-2 space-y-2 custom-scrollbar">
+ {#if gameState.versions.length === 0}
+ <div class="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse">
+ Fetching manifest...
</div>
- {#if gameState.selectedVersion === version.id}
- <div class="text-green-500 font-bold text-sm">SELECTED</div>
+ {:else if filteredVersions().length === 0}
+ <div class="flex flex-col items-center justify-center -40 dark:text-white/30 text-black/30 gap-2">
+ <span class="text-2xl">👻</span>
+ <span>No matching versions found</span>
+ </div>
+ {:else}
+ {#each filteredVersions() as version}
+ {@const badge = getVersionBadge(version.type)}
+ {@const isSelected = gameState.selectedVersion === version.id}
+ <button
+ class="w-full group flex items-center justify-between p-4 rounded-xl text-left border transition-all duration-200 relative overflow-hidden
+ {isSelected
+ ? 'bg-indigo-50 border-indigo-200 dark:bg-indigo-600/20 dark:border-indigo-500/50 shadow-[0_0_20px_rgba(99,102,241,0.2)]'
+ : 'bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/10 dark:hover:border-white/10 hover:translate-x-1'}"
+ onclick={() => (gameState.selectedVersion = version.id)}
+ >
+ <!-- Selection Glow -->
+ {#if isSelected}
+ <div class="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-transparent pointer-events-none"></div>
+ {/if}
+
+ <div class="relative z-10 flex items-center gap-4">
+ <span
+ class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border {badge.class}"
+ >
+ {badge.text}
+ </span>
+ <div>
+ <div class="font-bold font-mono text-lg tracking-tight {isSelected ? 'text-black dark:text-white' : 'text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white'}">
+ {version.id}
+ </div>
+ {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"}
+ <div class="text-xs dark:text-white/30 text-black/30">
+ {new Date(version.releaseTime).toLocaleDateString()}
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ {#if isSelected}
+ <div class="relative z-10 text-indigo-500 dark:text-indigo-400">
+ <span class="text-lg">Selected</span>
+ </div>
+ {/if}
+ </button>
+ {/each}
+ {/if}
+ </div>
+ </div>
+
+ <!-- Right: Mod Loader Panel -->
+ <div class="flex flex-col gap-4">
+ <!-- Selected Version Info Card -->
+ <div class="bg-gradient-to-br from-white/40 to-white/20 dark:from-white/10 dark:to-white/5 p-6 rounded-2xl border border-black/5 dark:border-white/10 backdrop-blur-md relative overflow-hidden group">
+ <div class="absolute top-0 right-0 p-8 bg-indigo-500/20 blur-[60px] rounded-full group-hover:bg-indigo-500/30 transition-colors"></div>
+
+ <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-2 relative z-10">Current Selection</h3>
+ {#if gameState.selectedVersion}
+ <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate">
+ {gameState.selectedVersion}
+ </p>
+ {:else}
+ <p class="dark:text-white/20 text-black/20 italic relative z-10">None selected</p>
{/if}
- </button>
- {/each}
- {/if}
+ </div>
+
+ <!-- Mod Loader Selector Card -->
+ <div class="bg-white/60 dark:bg-black/20 p-4 rounded-2xl border border-black/5 dark:border-white/5 backdrop-blur-sm flex-1 flex flex-col">
+ <ModLoaderSelector
+ selectedGameVersion={selectedBaseVersion()}
+ onInstall={handleModLoaderInstall}
+ />
+ </div>
+
+ </div>
</div>
</div>
+
diff --git a/ui/src/lib/DownloadMonitor.svelte b/ui/src/lib/DownloadMonitor.svelte
index b796591..860952c 100644
--- a/ui/src/lib/DownloadMonitor.svelte
+++ b/ui/src/lib/DownloadMonitor.svelte
@@ -9,11 +9,16 @@
downloaded: number; // in bytes
total: number; // in bytes
status: string;
+ completed_files: number;
+ total_files: number;
+ total_downloaded_bytes: number;
}
let currentFile = "";
- let progress = 0; // percentage 0-100
+ let progress = 0; // percentage 0-100 (current file)
+ let totalProgress = 0; // percentage 0-100 (all files)
let totalFiles = 0;
+ let completedFiles = 0;
let statusText = "Preparing...";
let unlistenProgress: () => void;
let unlistenStart: () => void;
@@ -21,13 +26,30 @@
let downloadedBytes = 0;
let totalBytes = 0;
+ // Speed and ETA tracking
+ let downloadSpeed = 0; // bytes per second
+ let etaSeconds = 0;
+ let startTime = 0;
+ let totalDownloadedBytes = 0;
+ let lastUpdateTime = 0;
+ let lastTotalBytes = 0;
+
onMount(async () => {
unlistenStart = await listen<number>("download-start", (event) => {
visible = true;
totalFiles = event.payload;
+ completedFiles = 0;
progress = 0;
+ totalProgress = 0;
statusText = "Starting download...";
currentFile = "";
+ // Reset speed tracking
+ startTime = Date.now();
+ totalDownloadedBytes = 0;
+ downloadSpeed = 0;
+ etaSeconds = 0;
+ lastUpdateTime = Date.now();
+ lastTotalBytes = 0;
});
unlistenProgress = await listen<DownloadEvent>(
@@ -36,8 +58,7 @@
const payload = event.payload;
currentFile = payload.file;
- // Simple file progress for now. Global progress would require tracking all files.
- // For single file (Client jar), this is accurate.
+ // Current file progress
downloadedBytes = payload.downloaded;
totalBytes = payload.total;
@@ -46,12 +67,54 @@
if (payload.total > 0) {
progress = (payload.downloaded / payload.total) * 100;
}
+
+ // Total progress (all files)
+ completedFiles = payload.completed_files;
+ totalFiles = payload.total_files;
+ if (totalFiles > 0) {
+ const currentFileFraction =
+ payload.total > 0 ? payload.downloaded / payload.total : 0;
+ totalProgress = ((completedFiles + currentFileFraction) / totalFiles) * 100;
+ }
+
+ // Calculate download speed (using moving average)
+ totalDownloadedBytes = payload.total_downloaded_bytes;
+ const now = Date.now();
+ const timeDiff = (now - lastUpdateTime) / 1000; // seconds
+
+ if (timeDiff >= 0.5) { // Update speed every 0.5 seconds
+ const bytesDiff = totalDownloadedBytes - lastTotalBytes;
+ const instantSpeed = bytesDiff / timeDiff;
+ // Smooth the speed with exponential moving average
+ downloadSpeed = downloadSpeed === 0 ? instantSpeed : downloadSpeed * 0.7 + instantSpeed * 0.3;
+ lastUpdateTime = now;
+ lastTotalBytes = totalDownloadedBytes;
+ }
+
+ // Estimate remaining time
+ if (downloadSpeed > 0 && completedFiles < totalFiles) {
+ const remainingFiles = totalFiles - completedFiles;
+ let estimatedRemainingBytes: number;
+
+ if (completedFiles > 0) {
+ // Use average size of completed files to estimate remaining files
+ const avgBytesPerCompletedFile = totalDownloadedBytes / completedFiles;
+ estimatedRemainingBytes = avgBytesPerCompletedFile * remainingFiles;
+ } else {
+ // No completed files yet: estimate based only on current file's remaining bytes
+ estimatedRemainingBytes = Math.max(totalBytes - downloadedBytes, 0);
+ }
+ etaSeconds = estimatedRemainingBytes / downloadSpeed;
+ } else {
+ etaSeconds = 0;
+ }
}
);
unlistenComplete = await listen("download-complete", () => {
statusText = "Done!";
progress = 100;
+ totalProgress = 100;
setTimeout(() => {
visible = false;
}, 2000);
@@ -71,22 +134,58 @@
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
+
+ function formatSpeed(bytesPerSecond: number) {
+ if (bytesPerSecond === 0) return "-- /s";
+ return formatBytes(bytesPerSecond) + "/s";
+ }
+
+ function formatTime(seconds: number) {
+ if (seconds <= 0 || !isFinite(seconds)) return "--";
+ if (seconds < 60) return `${Math.round(seconds)}s`;
+ if (seconds < 3600) {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.round(seconds % 60);
+ return `${mins}m ${secs}s`;
+ }
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ return `${hours}h ${mins}m`;
+ }
</script>
{#if visible}
<div
- class="fixed bottom-28 right-8 z-50 w-80 bg-zinc-900/90 backdrop-blur-md border border-zinc-700 rounded-lg shadow-2xl p-4 animate-in slide-in-from-right-10 fade-in duration-300"
+ class="fixed bottom-28 right-8 z-50 w-80 bg-zinc-900/90 border border-zinc-700 rounded-lg shadow-2xl p-4 animate-in slide-in-from-right-10 fade-in duration-300"
>
<div class="flex items-center justify-between mb-2">
<h3 class="text-white font-bold text-sm">Downloads</h3>
<span class="text-xs text-zinc-400">{statusText}</span>
</div>
+ <!-- Total Progress Bar -->
+ <div class="mb-3">
+ <div class="flex justify-between text-[10px] text-zinc-400 mb-1">
+ <span>Total Progress</span>
+ <span>{completedFiles} / {totalFiles} files</span>
+ </div>
+ <div class="w-full bg-zinc-800 rounded-full h-2.5 overflow-hidden">
+ <div
+ class="bg-gradient-to-r from-blue-500 to-cyan-400 h-2.5 rounded-full transition-all duration-200"
+ style="width: {totalProgress}%"
+ ></div>
+ </div>
+ <div class="flex justify-between text-[10px] text-zinc-500 font-mono mt-0.5">
+ <span>{formatSpeed(downloadSpeed)} · ETA: {formatTime(etaSeconds)}</span>
+ <span>{completedFiles < totalFiles ? Math.floor(totalProgress) : 100}%</span>
+ </div>
+ </div>
+
<div class="text-xs text-zinc-300 truncate mb-1" title={currentFile}>
{currentFile || "Waiting..."}
</div>
- <!-- Progress Bar -->
+ <!-- Current File Progress Bar -->
<div class="w-full bg-zinc-800 rounded-full h-2 mb-2 overflow-hidden">
<div
class="bg-gradient-to-r from-green-500 to-emerald-400 h-2 rounded-full transition-all duration-200"
diff --git a/ui/src/lib/GameConsole.svelte b/ui/src/lib/GameConsole.svelte
index 281dc85..8d5e0ce 100644
--- a/ui/src/lib/GameConsole.svelte
+++ b/ui/src/lib/GameConsole.svelte
@@ -75,7 +75,7 @@
</script>
{#if visible}
-<div class="fixed bottom-0 left-0 right-0 h-64 bg-zinc-950/95 border-t border-zinc-700 backdrop-blur flex flex-col z-50 transition-transform duration-300 transform translate-y-0">
+<div class="fixed bottom-0 left-0 right-0 h-64 bg-zinc-950/95 border-t border-zinc-700 flex flex-col z-50 transition-transform duration-300 transform translate-y-0">
<div class="flex items-center justify-between px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
<div class="flex items-center gap-4">
<span class="text-xs font-bold text-zinc-400 uppercase tracking-widest">Logs</span>
diff --git a/ui/src/lib/effects/ConstellationEffect.ts b/ui/src/lib/effects/ConstellationEffect.ts
new file mode 100644
index 0000000..2cc702e
--- /dev/null
+++ b/ui/src/lib/effects/ConstellationEffect.ts
@@ -0,0 +1,163 @@
+
+export class ConstellationEffect {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private width: number = 0;
+ private height: number = 0;
+ private particles: Particle[] = [];
+ private animationId: number = 0;
+ private mouseX: number = -1000;
+ private mouseY: number = -1000;
+
+ // Configuration
+ private readonly particleCount = 100;
+ private readonly connectionDistance = 150;
+ private readonly particleSpeed = 0.5;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext("2d", { alpha: true })!;
+
+ // Bind methods
+ this.animate = this.animate.bind(this);
+ this.handleMouseMove = this.handleMouseMove.bind(this);
+
+ // Initial setup
+ this.resize(window.innerWidth, window.innerHeight);
+ this.initParticles();
+
+ // Mouse interaction
+ window.addEventListener("mousemove", this.handleMouseMove);
+
+ // Start animation
+ this.animate();
+ }
+
+ resize(width: number, height: number) {
+ const dpr = window.devicePixelRatio || 1;
+ this.width = width;
+ this.height = height;
+
+ this.canvas.width = width * dpr;
+ this.canvas.height = height * dpr;
+ this.canvas.style.width = `${width}px`;
+ this.canvas.style.height = `${height}px`;
+
+ this.ctx.scale(dpr, dpr);
+
+ // Re-initialize if screen size changes significantly to maintain density
+ if (this.particles.length === 0) {
+ this.initParticles();
+ }
+ }
+
+ private initParticles() {
+ this.particles = [];
+ // Adjust density based on screen area
+ const area = this.width * this.height;
+ const density = Math.floor(area / 15000); // 1 particle per 15000px²
+ const count = Math.min(Math.max(density, 50), 200); // Clamp between 50 and 200
+
+ for (let i = 0; i < count; i++) {
+ this.particles.push(new Particle(this.width, this.height, this.particleSpeed));
+ }
+ }
+
+ private handleMouseMove(e: MouseEvent) {
+ const rect = this.canvas.getBoundingClientRect();
+ this.mouseX = e.clientX - rect.left;
+ this.mouseY = e.clientY - rect.top;
+ }
+
+ animate() {
+ this.ctx.clearRect(0, 0, this.width, this.height);
+
+ // Update and draw particles
+ this.particles.forEach(p => {
+ p.update(this.width, this.height);
+ p.draw(this.ctx);
+ });
+
+ // Draw lines
+ this.drawConnections();
+
+ this.animationId = requestAnimationFrame(this.animate);
+ }
+
+ private drawConnections() {
+ this.ctx.lineWidth = 1;
+
+ for (let i = 0; i < this.particles.length; i++) {
+ const p1 = this.particles[i];
+
+ // Connect to mouse if close
+ const distMouse = Math.hypot(p1.x - this.mouseX, p1.y - this.mouseY);
+ if (distMouse < this.connectionDistance + 50) {
+ const alpha = 1 - (distMouse / (this.connectionDistance + 50));
+ this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.4})`; // Brighter near mouse
+ this.ctx.beginPath();
+ this.ctx.moveTo(p1.x, p1.y);
+ this.ctx.lineTo(this.mouseX, this.mouseY);
+ this.ctx.stroke();
+
+ // Gently attract to mouse
+ if (distMouse > 10) {
+ p1.x += (this.mouseX - p1.x) * 0.005;
+ p1.y += (this.mouseY - p1.y) * 0.005;
+ }
+ }
+
+ // Connect to other particles
+ for (let j = i + 1; j < this.particles.length; j++) {
+ const p2 = this.particles[j];
+ const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
+
+ if (dist < this.connectionDistance) {
+ const alpha = 1 - (dist / this.connectionDistance);
+ this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.15})`;
+ this.ctx.beginPath();
+ this.ctx.moveTo(p1.x, p1.y);
+ this.ctx.lineTo(p2.x, p2.y);
+ this.ctx.stroke();
+ }
+ }
+ }
+ }
+
+ destroy() {
+ cancelAnimationFrame(this.animationId);
+ window.removeEventListener("mousemove", this.handleMouseMove);
+ }
+}
+
+class Particle {
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ size: number;
+
+ constructor(w: number, h: number, speed: number) {
+ this.x = Math.random() * w;
+ this.y = Math.random() * h;
+ this.vx = (Math.random() - 0.5) * speed;
+ this.vy = (Math.random() - 0.5) * speed;
+ this.size = Math.random() * 2 + 1;
+ }
+
+ update(w: number, h: number) {
+ this.x += this.vx;
+ this.y += this.vy;
+
+ // Bounce off walls
+ if (this.x < 0 || this.x > w) this.vx *= -1;
+ if (this.y < 0 || this.y > h) this.vy *= -1;
+ }
+
+ draw(ctx: CanvasRenderingContext2D) {
+ ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
+ ctx.beginPath();
+ ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
+ ctx.fill();
+ }
+}
diff --git a/ui/src/lib/effects/SaturnEffect.ts b/ui/src/lib/effects/SaturnEffect.ts
new file mode 100644
index 0000000..8a1c11f
--- /dev/null
+++ b/ui/src/lib/effects/SaturnEffect.ts
@@ -0,0 +1,194 @@
+// Optimized Saturn Effect for low-end hardware
+// Uses TypedArrays for memory efficiency and reduced particle density
+
+export class SaturnEffect {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private width: number = 0;
+ private height: number = 0;
+
+ // Data-oriented design for performance
+ // xyz: Float32Array where [i*3, i*3+1, i*3+2] corresponds to x, y, z
+ private xyz: Float32Array | null = null;
+ // types: Uint8Array where 0 = planet, 1 = ring
+ private types: Uint8Array | null = null;
+ private count: number = 0;
+
+ private animationId: number = 0;
+ private angle: number = 0;
+ private scaleFactor: number = 1;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d', {
+ alpha: true,
+ desynchronized: false // default is usually fine, 'desynchronized' can help latency but might flicker
+ })!;
+
+ // Initial resize will set up everything
+ this.resize(window.innerWidth, window.innerHeight);
+ this.initParticles();
+
+ this.animate = this.animate.bind(this);
+ this.animate();
+ }
+
+ resize(width: number, height: number) {
+ const dpr = window.devicePixelRatio || 1;
+ this.width = width;
+ this.height = height;
+
+ this.canvas.width = width * dpr;
+ this.canvas.height = height * dpr;
+ this.canvas.style.width = `${width}px`;
+ this.canvas.style.height = `${height}px`;
+
+ this.ctx.scale(dpr, dpr);
+
+ // Dynamic scaling based on screen size
+ const minDim = Math.min(width, height);
+ this.scaleFactor = minDim * 0.45;
+ }
+
+ initParticles() {
+ // Significantly reduced particle count for CPU optimization
+ // Planet: 1800 -> 1000
+ // Rings: 5000 -> 2500
+ // Total approx 3500 vs 6800 previously (approx 50% reduction)
+ const planetCount = 1000;
+ const ringCount = 2500;
+ this.count = planetCount + ringCount;
+
+ // Use TypedArrays for better memory locality
+ this.xyz = new Float32Array(this.count * 3);
+ this.types = new Uint8Array(this.count);
+
+ let idx = 0;
+
+ // 1. Planet
+ for (let i = 0; i < planetCount; i++) {
+ const theta = Math.random() * Math.PI * 2;
+ const phi = Math.acos((Math.random() * 2) - 1);
+ const r = 1.0;
+
+ // x, y, z
+ this.xyz[idx * 3] = r * Math.sin(phi) * Math.cos(theta);
+ this.xyz[idx * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
+ this.xyz[idx * 3 + 2] = r * Math.cos(phi);
+
+ this.types[idx] = 0; // 0 for planet
+ idx++;
+ }
+
+ // 2. Rings
+ const ringInner = 1.4;
+ const ringOuter = 2.3;
+
+ for (let i = 0; i < ringCount; i++) {
+ const angle = Math.random() * Math.PI * 2;
+ const dist = Math.sqrt(Math.random() * (ringOuter*ringOuter - ringInner*ringInner) + ringInner*ringInner);
+
+ // x, y, z
+ this.xyz[idx * 3] = dist * Math.cos(angle);
+ this.xyz[idx * 3 + 1] = (Math.random() - 0.5) * 0.05;
+ this.xyz[idx * 3 + 2] = dist * Math.sin(angle);
+
+ this.types[idx] = 1; // 1 for ring
+ idx++;
+ }
+ }
+
+ animate() {
+ this.ctx.clearRect(0, 0, this.width, this.height);
+
+ // Normal blending
+ this.ctx.globalCompositeOperation = 'source-over';
+
+ // Slower rotation (from 0.0015 to 0.0005)
+ this.angle += 0.0005;
+
+ const cx = this.width * 0.6;
+ const cy = this.height * 0.5;
+
+ // Pre-calculate rotation matrices
+ const rotationY = this.angle;
+ const rotationX = 0.4;
+ const rotationZ = 0.15;
+
+ const sinY = Math.sin(rotationY);
+ const cosY = Math.cos(rotationY);
+ const sinX = Math.sin(rotationX);
+ const cosX = Math.cos(rotationX);
+ const sinZ = Math.sin(rotationZ);
+ const cosZ = Math.cos(rotationZ);
+
+ const fov = 1500;
+ const scaleFactor = this.scaleFactor;
+
+ if (!this.xyz || !this.types) return;
+
+ for (let i = 0; i < this.count; i++) {
+ const x = this.xyz[i * 3];
+ const y = this.xyz[i * 3 + 1];
+ const z = this.xyz[i * 3 + 2];
+
+ // Apply Scale
+ const px = x * scaleFactor;
+ const py = y * scaleFactor;
+ const pz = z * scaleFactor;
+
+ // 1. Rotate Y
+ const x1 = px * cosY - pz * sinY;
+ const z1 = pz * cosY + px * sinY;
+ // y1 = py
+
+ // 2. Rotate X
+ const y2 = py * cosX - z1 * sinX;
+ const z2 = z1 * cosX + py * sinX;
+ // x2 = x1
+
+ // 3. Rotate Z
+ const x3 = x1 * cosZ - y2 * sinZ;
+ const y3 = y2 * cosZ + x1 * sinZ;
+ const z3 = z2;
+
+ const scale = fov / (fov + z3);
+
+ if (z3 > -fov) {
+ const x2d = cx + x3 * scale;
+ const y2d = cy + y3 * scale;
+
+ // Size calculation - slightly larger dots to compensate for lower count
+ // Previously Planet 2.0 -> 2.4, Ring 1.3 -> 1.5
+ const type = this.types[i];
+ const sizeBase = type === 0 ? 2.4 : 1.5;
+ const size = sizeBase * scale;
+
+ // Opacity
+ let alpha = (scale * scale * scale);
+ if (alpha > 1) alpha = 1;
+ if (alpha < 0.15) continue; // Skip very faint particles for performance
+
+ // Optimization: Planet color vs Ring color
+ if (type === 0) {
+ // Planet: Warn White
+ this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`;
+ } else {
+ // Ring: Cool White
+ this.ctx.fillStyle = `rgba(220, 240, 255, ${alpha})`;
+ }
+
+ // Render as squares (fillRect) instead of circles (arc)
+ // This is significantly faster for software rendering and reduces GPU usage.
+ this.ctx.fillRect(x2d, y2d, size, size);
+ }
+ }
+
+ this.animationId = requestAnimationFrame(this.animate);
+ }
+
+ destroy() {
+ cancelAnimationFrame(this.animationId);
+ }
+}
+
diff --git a/ui/src/lib/modLoaderApi.ts b/ui/src/lib/modLoaderApi.ts
new file mode 100644
index 0000000..9d0d09d
--- /dev/null
+++ b/ui/src/lib/modLoaderApi.ts
@@ -0,0 +1,108 @@
+/**
+ * Mod Loader API service for Fabric and Forge integration.
+ * This module provides functions to interact with the Tauri backend
+ * for mod loader version management.
+ */
+
+import { invoke } from "@tauri-apps/api/core";
+import type {
+ FabricGameVersion,
+ FabricLoaderVersion,
+ FabricLoaderEntry,
+ InstalledFabricVersion,
+ ForgeVersion,
+ InstalledForgeVersion,
+} from "../types";
+
+// ==================== Fabric API ====================
+
+/**
+ * Get all Minecraft versions supported by Fabric.
+ */
+export async function getFabricGameVersions(): Promise<FabricGameVersion[]> {
+ return invoke<FabricGameVersion[]>("get_fabric_game_versions");
+}
+
+/**
+ * Get all available Fabric loader versions.
+ */
+export async function getFabricLoaderVersions(): Promise<FabricLoaderVersion[]> {
+ return invoke<FabricLoaderVersion[]>("get_fabric_loader_versions");
+}
+
+/**
+ * Get Fabric loaders available for a specific Minecraft version.
+ */
+export async function getFabricLoadersForVersion(
+ gameVersion: string
+): Promise<FabricLoaderEntry[]> {
+ return invoke<FabricLoaderEntry[]>("get_fabric_loaders_for_version", {
+ gameVersion,
+ });
+}
+
+/**
+ * Install Fabric loader for a specific Minecraft version.
+ */
+export async function installFabric(
+ gameVersion: string,
+ loaderVersion: string
+): Promise<InstalledFabricVersion> {
+ return invoke<InstalledFabricVersion>("install_fabric", {
+ gameVersion,
+ loaderVersion,
+ });
+}
+
+/**
+ * List all installed Fabric versions.
+ */
+export async function listInstalledFabricVersions(): Promise<string[]> {
+ return invoke<string[]>("list_installed_fabric_versions");
+}
+
+/**
+ * Check if Fabric is installed for a specific version combination.
+ */
+export async function isFabricInstalled(
+ gameVersion: string,
+ loaderVersion: string
+): Promise<boolean> {
+ return invoke<boolean>("is_fabric_installed", {
+ gameVersion,
+ loaderVersion,
+ });
+}
+
+// ==================== Forge API ====================
+
+/**
+ * Get all Minecraft versions supported by Forge.
+ */
+export async function getForgeGameVersions(): Promise<string[]> {
+ return invoke<string[]>("get_forge_game_versions");
+}
+
+/**
+ * Get Forge versions available for a specific Minecraft version.
+ */
+export async function getForgeVersionsForGame(
+ gameVersion: string
+): Promise<ForgeVersion[]> {
+ return invoke<ForgeVersion[]>("get_forge_versions_for_game", {
+ gameVersion,
+ });
+}
+
+/**
+ * Install Forge for a specific Minecraft version.
+ */
+export async function installForge(
+ gameVersion: string,
+ forgeVersion: string
+): Promise<InstalledForgeVersion> {
+ return invoke<InstalledForgeVersion>("install_forge", {
+ gameVersion,
+ forgeVersion,
+ });
+}
diff --git a/ui/src/stores/settings.svelte.ts b/ui/src/stores/settings.svelte.ts
index 989172c..b67cdc3 100644
--- a/ui/src/stores/settings.svelte.ts
+++ b/ui/src/stores/settings.svelte.ts
@@ -9,6 +9,11 @@ export class SettingsState {
java_path: "java",
width: 854,
height: 480,
+ download_threads: 32,
+ enable_gpu_acceleration: false,
+ enable_visual_effects: true,
+ active_effect: "constellation",
+ theme: "dark",
});
javaInstallations = $state<JavaInstallation[]>([]);
isDetectingJava = $state(false);
@@ -17,6 +22,11 @@ export class SettingsState {
try {
const result = await invoke<LauncherConfig>("get_settings");
this.settings = result;
+ // Force dark mode
+ if (this.settings.theme !== "dark") {
+ this.settings.theme = "dark";
+ this.saveSettings();
+ }
} catch (e) {
console.error("Failed to load settings:", e);
}
diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts
index b7ff0a0..7e2cc67 100644
--- a/ui/src/types/index.ts
+++ b/ui/src/types/index.ts
@@ -29,6 +29,12 @@ export interface LauncherConfig {
java_path: string;
width: number;
height: number;
+ download_threads: number;
+ custom_background_path?: string;
+ enable_gpu_acceleration: boolean;
+ enable_visual_effects: boolean;
+ active_effect: string;
+ theme: string;
}
export interface JavaInstallation {
@@ -36,3 +42,62 @@ export interface JavaInstallation {
version: string;
is_64bit: boolean;
}
+
+// ==================== Fabric Types ====================
+
+export interface FabricGameVersion {
+ version: string;
+ stable: boolean;
+}
+
+export interface FabricLoaderVersion {
+ separator: string;
+ build: number;
+ maven: string;
+ version: string;
+ stable: boolean;
+}
+
+export interface FabricLoaderEntry {
+ loader: FabricLoaderVersion;
+ intermediary: {
+ maven: string;
+ version: string;
+ stable: boolean;
+ };
+ launcherMeta: {
+ version: number;
+ mainClass: {
+ client: string;
+ server: string;
+ };
+ };
+}
+
+export interface InstalledFabricVersion {
+ id: string;
+ minecraft_version: string;
+ loader_version: string;
+ path: string;
+}
+
+// ==================== Forge Types ====================
+
+export interface ForgeVersion {
+ version: string;
+ minecraft_version: string;
+ recommended: boolean;
+ latest: boolean;
+}
+
+export interface InstalledForgeVersion {
+ id: string;
+ minecraft_version: string;
+ forge_version: string;
+ path: string;
+}
+
+// ==================== Mod Loader Type ====================
+
+export type ModLoaderType = "vanilla" | "fabric" | "forge";
+