aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--.github/dependabot.yml24
-rw-r--r--.github/workflows/check.yml34
-rw-r--r--.github/workflows/codeql.yml103
-rw-r--r--.github/workflows/release.yml45
-rw-r--r--.github/workflows/test.yml35
-rw-r--r--LICENSE21
-rw-r--r--README.md49
-rw-r--r--assets/128x128.pngbin0 -> 6188 bytes
-rw-r--r--assets/image.jpgbin172906 -> 0 bytes
-rw-r--r--assets/image.pngbin0 -> 163436 bytes
-rw-r--r--src-tauri/Cargo.toml16
-rw-r--r--src-tauri/capabilities/default.json16
-rwxr-xr-xsrc-tauri/scripts/fix-appimage.sh32
-rw-r--r--src-tauri/src/core/config.rs14
-rw-r--r--src-tauri/src/core/downloader.rs444
-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.rs727
-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.rs508
-rw-r--r--src-tauri/src/utils/zip.rs75
-rw-r--r--src-tauri/tauri.conf.json15
-rw-r--r--ui/package-lock.json41
-rw-r--r--ui/package.json7
-rw-r--r--ui/pnpm-lock.yaml87
-rw-r--r--ui/src/App.svelte186
-rw-r--r--ui/src/app.css164
-rw-r--r--ui/src/components/BottomBar.svelte142
-rw-r--r--ui/src/components/CustomSelect.svelte136
-rw-r--r--ui/src/components/HomeView.svelte221
-rw-r--r--ui/src/components/LoginModal.svelte30
-rw-r--r--ui/src/components/ModLoaderSelector.svelte332
-rw-r--r--ui/src/components/ParticleBackground.svelte57
-rw-r--r--ui/src/components/SettingsView.svelte690
-rw-r--r--ui/src/components/Sidebar.svelte113
-rw-r--r--ui/src/components/StatusToast.svelte10
-rw-r--r--ui/src/components/VersionsView.svelte264
-rw-r--r--ui/src/lib/DownloadMonitor.svelte2
-rw-r--r--ui/src/lib/GameConsole.svelte367
-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/game.svelte.ts4
-rw-r--r--ui/src/stores/logs.svelte.ts139
-rw-r--r--ui/src/stores/releases.svelte.ts36
-rw-r--r--ui/src/stores/settings.svelte.ts335
-rw-r--r--ui/src/types/index.ts120
-rw-r--r--ui/vite.config.ts1
53 files changed, 6931 insertions, 505 deletions
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..b2cce07
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,24 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+
+multi-ecosystem-groups:
+ infrastructure:
+ schedule:
+ interval: "weekly"
+
+updates:
+ - package-ecosystem: "npm"
+ directory: "/ui"
+ multi-ecosystem-group: "infrastructure"
+
+ - package-ecosystem: "cargo"
+ directory: "/src-tauri"
+ multi-ecosystem-group: "infrastructure"
+
+ - package-ecosystem: "uv"
+ directory: "/"
+ multi-ecosystem-group: "infrastructure"
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
new file mode 100644
index 0000000..0cbcf35
--- /dev/null
+++ b/.github/workflows/check.yml
@@ -0,0 +1,34 @@
+name: UI Checker
+
+on:
+ push:
+ paths:
+ - "ui/**"
+ - ".github/workflows/ui_check.yml"
+ pull_request:
+ branches: ["main", "master", "dev"]
+ workflow_dispatch:
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+
+ - name: Install Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: "pnpm"
+ cache-dependency-path: "ui/pnpm-lock.yaml"
+
+ - run: pnpm install
+ working-directory: ui
+
+ - run: pnpm check
+ working-directory: ui
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..734c4c5
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,103 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL Advanced"
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+ schedule:
+ - cron: '25 0 * * 3'
+
+jobs:
+ analyze:
+ name: Analyze (${{ matrix.language }})
+ # Runner size impacts CodeQL analysis time. To learn more, please see:
+ # - https://gh.io/recommended-hardware-resources-for-running-codeql
+ # - https://gh.io/supported-runners-and-hardware-resources
+ # - https://gh.io/using-larger-runners (GitHub.com only)
+ # Consider using larger runners or machines with greater resources for possible analysis time improvements.
+ runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
+ permissions:
+ # required for all workflows
+ security-events: write
+
+ # required to fetch internal or private CodeQL packs
+ packages: read
+
+ # only required for workflows in private repositories
+ actions: read
+ contents: read
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - language: actions
+ build-mode: none
+ - language: javascript-typescript
+ build-mode: none
+ - language: rust
+ build-mode: none
+ # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
+ # Use `c-cpp` to analyze code written in C, C++ or both
+ # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
+ # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
+ # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
+ # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
+ # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
+ # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ # Add any setup steps before running the `github/codeql-action/init` action.
+ # This includes steps like installing compilers or runtimes (`actions/setup-node`
+ # or others). This is typically only required for manual builds.
+ # - name: Setup runtime (example)
+ # uses: actions/setup-example@v1
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+ build-mode: ${{ matrix.build-mode }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+
+ # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+ # queries: security-extended,security-and-quality
+
+ # If the analyze step fails for one of the languages you are analyzing with
+ # "We were unable to automatically build your code", modify the matrix above
+ # to set the build mode to "manual" for that language. Then modify this step
+ # to build your code.
+ # â„šī¸ Command-line programs to run using the OS shell.
+ # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
+ - name: Run manual build steps
+ if: matrix.build-mode == 'manual'
+ shell: bash
+ run: |
+ echo 'If you are using a "manual" build mode for one or more of the' \
+ 'languages you are analyzing, replace this with the commands to build' \
+ 'your code, for example:'
+ echo ' make bootstrap'
+ echo ' make release'
+ exit 1
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 774e547..82399e5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -82,6 +82,9 @@ jobs:
args: "--target aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }}
+ container:
+ image: ${{ matrix.container }}
+ options: --user root
steps:
- uses: actions/checkout@v4
@@ -89,13 +92,13 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
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 pnpm
uses: pnpm/action-setup@v4
@@ -123,6 +126,18 @@ jobs:
with:
workspaces: "./src-tauri -> target"
shared-key: ${{ matrix.target }}
+
+ - name: Setup appimagetool (Linux)
+ if: startsWith(matrix.platform, 'ubuntu') && !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
@@ -131,3 +146,29 @@ jobs:
with:
releaseId: ${{ needs.promote-release.outputs.release_id }}
args: ${{ matrix.args }}
+
+ - name: Fix AppImage for Wayland (Linux)
+ if: startsWith(matrix.platform, 'ubuntu') && !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
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/LICENSE b/LICENSE
new file mode 100644
index 0000000..8287bc6
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 įŽ€åž‹įē¯
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index c64db3d..d702fe9 100644
--- a/README.md
+++ b/README.md
@@ -1,45 +1,42 @@
# 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.
+DropOut is a modern, minimalist, and efficient Minecraft launcher built with the latest web and system technologies. It leverages **Tauri v2** to deliver 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" alt="DropOut Launcher Interface" />
</div>
## Features
-- **High Performance**: Built with Rust and Tauri for minimal resource usage.
-- **Microsoft Authentication**: Secure login support via official Xbox Live & Microsoft OAuth flows.
-- **Cross-Platform**: Native support for **Windows**, **Linux**, and **macOS**.
-- **Modern UI**: Clean and responsive interface built with Svelte 5 and Tailwind CSS 4.
+- **High Performance**: Built with Rust and Tauri for minimal resource usage and fast startup times.
+- **Modern Industrial UI**: A clean, distraction-free interface designed with **Svelte 5** and **Tailwind CSS 4**.
+- **Microsoft Authentication**: Secure login support via official Xbox Live & Microsoft OAuth flows (Device Code Flow).
+- **Mod Loader Support**:
+ - **Fabric**: Built-in installer and version management.
+ - **Forge**: Support for installing and launching Forge versions.
+- **Java Management**:
+ - Automatic detection of installed Java versions.
+ - Built-in downloader for Adoptium JDK/JRE.
+- **GitHub Integration**: View the latest project updates and changelogs directly from the launcher home screen.
- **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`
+ - Complete version isolation.
+ - Efficient concurrent asset and library downloading.
+ - Customizable memory allocation and resolution settings.
## Roadmap
- [X] **Account Persistence** — Save login state between sessions
- [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] **JVM Arguments Parsing** — Full support for `arguments.jvm` and `arguments.game` parsing
+- [X] **Java Auto-detection & Download** — Scan system and download Java runtimes
+- [X] **Fabric Loader Support** — Install and launch with Fabric
+- [X] **Forge Loader Support** — Install and launch with Forge
+- [X] **GitHub Releases Integration** — View changelogs in-app
- [ ] **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
+- [ ] **Multi-account Support** — Switch between multiple accounts seamlessly
- [ ] **Custom Game Directory** — Allow users to choose game files location
- [ ] **Launcher Auto-updater** — Self-update mechanism via Tauri updater plugin
-- [ ] **Mods Manager** — Enable/disable mods without deletion
-- [ ] **Resource Packs Manager** — Browse and manage resource packs
-- [ ] **Quilt Loader Support** — Install and launch with Quilt
+- [ ] **Mods Manager** — Enable/disable mods directly in the launcher
- [ ] **Import from Other Launchers** — Migration tool for MultiMC/Prism profiles
## Installation
@@ -59,7 +56,7 @@ Download the latest release for your platform from the [Releases](https://github
### Prerequisites
1. **Rust**: Install from [rustup.rs](https://rustup.rs/).
-2. **Node.js** & **pnpm**: Used for the frontend.
+2. **Node.js** & **pnpm**: Used for the frontend dependencies.
3. **System Dependencies**: Follow the [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS.
### Steps
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..db6ada3
--- /dev/null
+++ b/assets/image.png
Binary files differ
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 8d4a612..0eb143c 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "dropout"
-version = "0.1.14"
+version = "0.1.21"
edition = "2021"
authors = ["HsiangNianian"]
description = "The DropOut Minecraft Game Launcher"
@@ -8,21 +8,27 @@ license = "MIT"
repository = "https://github.com/HsiangNianian/DropOut"
[dependencies]
-serde = { version = "1.0", features = ["derive"] }
-toml = "0.5"
+serde = { version = "1.0", features = ["derive"] }
+toml = "0.5"
log = "0.4"
-env_logger = "0.9"
+env_logger = "0.9"
tokio = { version = "1.49.0", features = ["full"] }
-reqwest = { version = "0.13.1", features = ["json", "blocking"] }
+reqwest = { version = "0.11", features = ["json", "blocking", "stream", "multipart"] }
serde_json = "1.0.149"
tauri = { version = "2.9", features = [] }
tauri-plugin-shell = "2.3"
uuid = { version = "1.10.0", features = ["v3", "v4", "serde"] }
futures = "0.3"
sha1 = "0.10"
+sha2 = "0.10"
hex = "0.4"
zip = "2.2.2"
+flate2 = "1.0"
+tar = "0.4"
+dirs = "5.0"
serde_urlencoded = "0.7.1"
+tauri-plugin-dialog = "2.5.0"
+tauri-plugin-fs = "2.4.5"
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 894b905..ea3fd7b 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -12,6 +12,20 @@
"core:app:allow-version",
"core:path:default",
"core:window:default",
- "shell:allow-open"
+ "shell:allow-open",
+ "dialog:default",
+ "fs:default",
+ {
+ "identifier": "fs:allow-read",
+ "allow": [{ "path": "**" }]
+ },
+ {
+ "identifier": "fs:allow-exists",
+ "allow": [{ "path": "**" }]
+ },
+ {
+ "identifier": "fs:allow-write-text-file",
+ "allow": [{ "path": "**" }]
+ }
]
}
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/config.rs b/src-tauri/src/core/config.rs
index d6d594f..43c8145 100644
--- a/src-tauri/src/core/config.rs
+++ b/src-tauri/src/core/config.rs
@@ -13,6 +13,13 @@ pub struct LauncherConfig {
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,
+ pub log_upload_service: String, // "paste.rs" or "pastebin.com"
+ pub pastebin_api_key: Option<String>,
}
impl Default for LauncherConfig {
@@ -24,6 +31,13 @@ impl Default for LauncherConfig {
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(),
+ log_upload_service: "paste.rs".to_string(),
+ pastebin_api_key: None,
}
}
}
diff --git a/src-tauri/src/core/downloader.rs b/src-tauri/src/core/downloader.rs
index 3add9b7..bf6334f 100644
--- a/src-tauri/src/core/downloader.rs
+++ b/src-tauri/src/core/downloader.rs
@@ -1,17 +1,415 @@
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::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
-use tauri::{Emitter, Window};
-use tokio::io::AsyncWriteExt;
+use tauri::{AppHandle, Emitter, Manager, Window};
+use tokio::io::{AsyncSeekExt, AsyncWriteExt};
use tokio::sync::Semaphore;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadTask {
pub url: String,
pub path: PathBuf,
+ #[serde(default)]
pub sha1: Option<String>,
+ #[serde(default)]
+ pub sha256: Option<String>,
+}
+
+/// Metadata for resumable downloads stored in .part.meta file
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DownloadMetadata {
+ pub url: String,
+ pub file_name: String,
+ pub total_size: u64,
+ pub downloaded_bytes: u64,
+ pub checksum: Option<String>,
+ pub timestamp: u64,
+ pub segments: Vec<DownloadSegment>,
+}
+
+/// A download segment for multi-segment parallel downloading
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DownloadSegment {
+ pub start: u64,
+ pub end: u64,
+ pub downloaded: u64,
+ pub completed: bool,
+}
+
+/// Progress event for Java download
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct JavaDownloadProgress {
+ pub file_name: String,
+ pub downloaded_bytes: u64,
+ pub total_bytes: u64,
+ pub speed_bytes_per_sec: u64,
+ pub eta_seconds: u64,
+ pub status: String, // "Downloading", "Extracting", "Verifying", "Completed", "Paused", "Error"
+ pub percentage: f32,
+}
+
+/// Pending download task for queue persistence
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PendingJavaDownload {
+ pub major_version: u32,
+ pub image_type: String,
+ pub download_url: String,
+ pub file_name: String,
+ pub file_size: u64,
+ pub checksum: Option<String>,
+ pub install_path: String,
+ pub created_at: u64,
+}
+
+/// Download queue for persistence
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct DownloadQueue {
+ pub pending_downloads: Vec<PendingJavaDownload>,
+}
+
+impl DownloadQueue {
+ /// Load download queue from file
+ pub fn load(app_handle: &AppHandle) -> Self {
+ let queue_path = app_handle
+ .path()
+ .app_data_dir()
+ .unwrap()
+ .join("download_queue.json");
+ if queue_path.exists() {
+ if let Ok(content) = std::fs::read_to_string(&queue_path) {
+ if let Ok(queue) = serde_json::from_str(&content) {
+ return queue;
+ }
+ }
+ }
+ Self::default()
+ }
+
+ /// Save download queue to file
+ pub fn save(&self, app_handle: &AppHandle) -> Result<(), String> {
+ let queue_path = app_handle
+ .path()
+ .app_data_dir()
+ .unwrap()
+ .join("download_queue.json");
+ let content = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
+ std::fs::write(&queue_path, content).map_err(|e| e.to_string())?;
+ Ok(())
+ }
+
+ /// Add a pending download
+ pub fn add(&mut self, download: PendingJavaDownload) {
+ // Remove existing download for same version/type
+ self.pending_downloads.retain(|d| {
+ !(d.major_version == download.major_version && d.image_type == download.image_type)
+ });
+ self.pending_downloads.push(download);
+ }
+
+ /// Remove a completed or cancelled download
+ pub fn remove(&mut self, major_version: u32, image_type: &str) {
+ self.pending_downloads.retain(|d| {
+ !(d.major_version == major_version && d.image_type == image_type)
+ });
+ }
+}
+
+/// Global cancel flag for Java downloads
+pub static JAVA_DOWNLOAD_CANCELLED: AtomicBool = AtomicBool::new(false);
+
+/// Reset the cancel flag
+pub fn reset_java_download_cancel() {
+ JAVA_DOWNLOAD_CANCELLED.store(false, Ordering::SeqCst);
+}
+
+/// Cancel the current Java download
+pub fn cancel_java_download() {
+ JAVA_DOWNLOAD_CANCELLED.store(true, Ordering::SeqCst);
+}
+
+/// Check if download is cancelled
+pub fn is_java_download_cancelled() -> bool {
+ JAVA_DOWNLOAD_CANCELLED.load(Ordering::SeqCst)
+}
+
+/// Determine optimal segment count based on file size
+fn get_segment_count(file_size: u64) -> usize {
+ if file_size < 20 * 1024 * 1024 {
+ 1 // < 20MB: single segment
+ } else if file_size < 100 * 1024 * 1024 {
+ 4 // 20-100MB: 4 segments
+ } else {
+ 8 // > 100MB: 8 segments
+ }
+}
+
+/// Download a large file with resume support and progress events
+pub async fn download_with_resume(
+ app_handle: &AppHandle,
+ url: &str,
+ dest_path: &PathBuf,
+ checksum: Option<&str>,
+ total_size: u64,
+) -> Result<(), String> {
+ reset_java_download_cancel();
+
+ let part_path = dest_path.with_extension(
+ dest_path
+ .extension()
+ .map(|e| format!("{}.part", e.to_string_lossy()))
+ .unwrap_or_else(|| "part".to_string()),
+ );
+ let meta_path = PathBuf::from(format!("{}.meta", part_path.display()));
+ let file_name = dest_path
+ .file_name()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .to_string();
+
+ // Load or create metadata
+ let mut metadata = if meta_path.exists() {
+ let content = tokio::fs::read_to_string(&meta_path)
+ .await
+ .map_err(|e| e.to_string())?;
+ serde_json::from_str(&content).unwrap_or_else(|_| create_new_metadata(url, &file_name, total_size, checksum))
+ } else {
+ create_new_metadata(url, &file_name, total_size, checksum)
+ };
+
+ // Create parent directory
+ if let Some(parent) = dest_path.parent() {
+ tokio::fs::create_dir_all(parent)
+ .await
+ .map_err(|e| e.to_string())?;
+ }
+
+ // Open or create part file
+ let file = tokio::fs::OpenOptions::new()
+ .create(true)
+ .write(true)
+ .read(true)
+ .open(&part_path)
+ .await
+ .map_err(|e| format!("Failed to open part file: {}", e))?;
+
+ let file = Arc::new(tokio::sync::Mutex::new(file));
+ let client = reqwest::Client::new();
+ let progress = Arc::new(AtomicU64::new(metadata.downloaded_bytes));
+ let start_time = std::time::Instant::now();
+ let last_progress_bytes = Arc::new(AtomicU64::new(metadata.downloaded_bytes));
+
+ // Download segments concurrently
+ let segment_count = metadata.segments.len();
+ let semaphore = Arc::new(Semaphore::new(segment_count.min(8)));
+ let mut handles = Vec::new();
+
+ for (idx, segment) in metadata.segments.iter().enumerate() {
+ if segment.completed {
+ continue;
+ }
+
+ let client = client.clone();
+ let url = url.to_string();
+ let file = file.clone();
+ let progress = progress.clone();
+ let semaphore = semaphore.clone();
+ let segment_start = segment.start + segment.downloaded;
+ let segment_end = segment.end;
+ let app_handle = app_handle.clone();
+ let file_name = file_name.clone();
+ let total_size = total_size;
+ let last_progress_bytes = last_progress_bytes.clone();
+ let start_time = start_time.clone();
+
+ let handle = tokio::spawn(async move {
+ let _permit = semaphore.acquire().await.unwrap();
+
+ if is_java_download_cancelled() {
+ return Err("Download cancelled".to_string());
+ }
+
+ // Send Range request
+ let range = format!("bytes={}-{}", segment_start, segment_end);
+ let response = client
+ .get(&url)
+ .header("Range", &range)
+ .send()
+ .await
+ .map_err(|e| format!("Request failed: {}", e))?;
+
+ if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
+ return Err(format!("Server returned error: {}", response.status()));
+ }
+
+ let mut stream = response.bytes_stream();
+ let mut current_pos = segment_start;
+
+ while let Some(chunk_result) = stream.next().await {
+ if is_java_download_cancelled() {
+ return Err("Download cancelled".to_string());
+ }
+
+ let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?;
+ let chunk_len = chunk.len() as u64;
+
+ // Write to file at correct position
+ {
+ let mut file_guard = file.lock().await;
+ file_guard
+ .seek(std::io::SeekFrom::Start(current_pos))
+ .await
+ .map_err(|e| format!("Seek error: {}", e))?;
+ file_guard
+ .write_all(&chunk)
+ .await
+ .map_err(|e| format!("Write error: {}", e))?;
+ }
+
+ current_pos += chunk_len;
+ let total_downloaded = progress.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
+
+ // Emit progress event (throttled)
+ let last_bytes = last_progress_bytes.load(Ordering::Relaxed);
+ if total_downloaded - last_bytes > 100 * 1024 || total_downloaded >= total_size {
+ last_progress_bytes.store(total_downloaded, Ordering::Relaxed);
+
+ let elapsed = start_time.elapsed().as_secs_f64();
+ let speed = if elapsed > 0.0 {
+ (total_downloaded as f64 / elapsed) as u64
+ } else {
+ 0
+ };
+ let remaining = total_size.saturating_sub(total_downloaded);
+ let eta = if speed > 0 { remaining / speed } else { 0 };
+ let percentage = (total_downloaded as f32 / total_size as f32) * 100.0;
+
+ let _ = app_handle.emit(
+ "java-download-progress",
+ JavaDownloadProgress {
+ file_name: file_name.clone(),
+ downloaded_bytes: total_downloaded,
+ total_bytes: total_size,
+ speed_bytes_per_sec: speed,
+ eta_seconds: eta,
+ status: "Downloading".to_string(),
+ percentage,
+ },
+ );
+ }
+ }
+
+ Ok::<usize, String>(idx)
+ });
+
+ handles.push(handle);
+ }
+
+ // Wait for all segments
+ let mut all_success = true;
+ for handle in handles {
+ match handle.await {
+ Ok(Ok(idx)) => {
+ metadata.segments[idx].completed = true;
+ }
+ Ok(Err(e)) => {
+ all_success = false;
+ if e.contains("cancelled") {
+ // Save progress for resume
+ metadata.downloaded_bytes = progress.load(Ordering::Relaxed);
+ let meta_content = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?;
+ tokio::fs::write(&meta_path, meta_content).await.ok();
+ return Err(e);
+ }
+ }
+ Err(e) => {
+ all_success = false;
+ eprintln!("Segment task panicked: {}", e);
+ }
+ }
+ }
+
+ if !all_success {
+ // Save progress
+ metadata.downloaded_bytes = progress.load(Ordering::Relaxed);
+ let meta_content = serde_json::to_string_pretty(&metadata).map_err(|e| e.to_string())?;
+ tokio::fs::write(&meta_path, meta_content).await.ok();
+ return Err("Some segments failed".to_string());
+ }
+
+ // Verify checksum if provided
+ if let Some(expected) = checksum {
+ let _ = app_handle.emit(
+ "java-download-progress",
+ JavaDownloadProgress {
+ file_name: file_name.clone(),
+ downloaded_bytes: total_size,
+ total_bytes: total_size,
+ speed_bytes_per_sec: 0,
+ eta_seconds: 0,
+ status: "Verifying".to_string(),
+ percentage: 100.0,
+ },
+ );
+
+ let data = tokio::fs::read(&part_path)
+ .await
+ .map_err(|e| format!("Failed to read file for verification: {}", e))?;
+
+ if !verify_checksum(&data, Some(expected), None) {
+ // Checksum failed, delete files and retry
+ tokio::fs::remove_file(&part_path).await.ok();
+ tokio::fs::remove_file(&meta_path).await.ok();
+ return Err("Checksum verification failed".to_string());
+ }
+ }
+
+ // Rename part file to final destination
+ tokio::fs::rename(&part_path, dest_path)
+ .await
+ .map_err(|e| format!("Failed to rename file: {}", e))?;
+
+ // Clean up metadata file
+ tokio::fs::remove_file(&meta_path).await.ok();
+
+ Ok(())
+}
+
+/// Create new download metadata with segments
+fn create_new_metadata(url: &str, file_name: &str, total_size: u64, checksum: Option<&str>) -> DownloadMetadata {
+ let segment_count = get_segment_count(total_size);
+ let segment_size = total_size / segment_count as u64;
+ let mut segments = Vec::new();
+
+ for i in 0..segment_count {
+ let start = i as u64 * segment_size;
+ let end = if i == segment_count - 1 {
+ total_size - 1
+ } else {
+ (i as u64 + 1) * segment_size - 1
+ };
+ segments.push(DownloadSegment {
+ start,
+ end,
+ downloaded: 0,
+ completed: false,
+ });
+ }
+
+ DownloadMetadata {
+ url: url.to_string(),
+ file_name: file_name.to_string(),
+ total_size,
+ downloaded_bytes: 0,
+ checksum: checksum.map(|s| s.to_string()),
+ timestamp: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ segments,
+ }
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -25,6 +423,32 @@ pub struct ProgressEvent {
pub total_downloaded_bytes: u64,
}
+/// calculate SHA256 hash of data
+pub fn compute_sha256(data: &[u8]) -> String {
+ let mut hasher = sha2::Sha256::new();
+ hasher.update(data);
+ hex::encode(hasher.finalize())
+}
+
+/// calculate SHA1 hash of data
+pub fn compute_sha1(data: &[u8]) -> String {
+ let mut hasher = sha1::Sha1::new();
+ hasher.update(data);
+ hex::encode(hasher.finalize())
+}
+
+/// verify file checksum, prefer SHA256, fallback to SHA1
+pub fn verify_checksum(data: &[u8], sha256: Option<&str>, sha1: Option<&str>) -> bool {
+ if let Some(expected) = sha256 {
+ return compute_sha256(data) == expected;
+ }
+ if let Some(expected) = sha1 {
+ return compute_sha1(data) == expected;
+ }
+ // No checksum provided, default to true
+ true
+}
+
/// Snapshot of global progress state
struct ProgressSnapshot {
completed_files: usize,
@@ -129,17 +553,17 @@ pub async fn download_files(
let _permit = semaphore.acquire().await.unwrap();
let file_name = task.path.file_name().unwrap().to_string_lossy().to_string();
- // 1. Check if file exists and verify SHA1
+ // 1. Check if file exists and verify checksum
if task.path.exists() {
emit_progress(&window, &file_name, "Verifying", 0, 0, &progress.snapshot());
- if let Some(expected_sha1) = &task.sha1 {
+ if task.sha256.is_some() || task.sha1.is_some() {
if let Ok(data) = tokio::fs::read(&task.path).await {
- let mut hasher = sha1::Sha1::new();
- use sha1::Digest;
- hasher.update(&data);
- let result = hex::encode(hasher.finalize());
- if &result == expected_sha1 {
+ if verify_checksum(
+ &data,
+ task.sha256.as_deref(),
+ task.sha1.as_deref(),
+ ) {
// Already valid, skip download
let skipped_size = tokio::fs::metadata(&task.path)
.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 9cf3053..8341138 100644
--- a/src-tauri/src/core/java.rs
+++ b/src-tauri/src/core/java.rs
@@ -1,6 +1,15 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process::Command;
+use tauri::AppHandle;
+use tauri::Emitter;
+use tauri::Manager;
+
+use crate::core::downloader::{self, JavaDownloadProgress, DownloadQueue, PendingJavaDownload};
+use crate::utils::zip;
+
+const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3";
+const CACHE_DURATION_SECS: u64 = 24 * 60 * 60; // 24 hours
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JavaInstallation {
@@ -9,6 +18,590 @@ pub struct JavaInstallation {
pub is_64bit: bool,
}
+/// Java image type: JRE or JDK
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ImageType {
+ Jre,
+ Jdk,
+}
+
+impl Default for ImageType {
+ fn default() -> Self {
+ Self::Jre
+ }
+}
+
+impl std::fmt::Display for ImageType {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Jre => write!(f, "jre"),
+ Self::Jdk => write!(f, "jdk"),
+ }
+ }
+}
+
+/// Java release information for UI display
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct JavaReleaseInfo {
+ pub major_version: u32,
+ pub image_type: String,
+ pub version: String,
+ pub release_name: String,
+ pub release_date: Option<String>,
+ pub file_size: u64,
+ pub checksum: Option<String>,
+ pub download_url: String,
+ pub is_lts: bool,
+ pub is_available: bool,
+ pub architecture: String,
+}
+
+/// Java catalog containing all available versions
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct JavaCatalog {
+ pub releases: Vec<JavaReleaseInfo>,
+ pub available_major_versions: Vec<u32>,
+ pub lts_versions: Vec<u32>,
+ pub cached_at: u64,
+}
+
+impl Default for JavaCatalog {
+ fn default() -> Self {
+ Self {
+ releases: Vec::new(),
+ available_major_versions: Vec::new(),
+ lts_versions: Vec::new(),
+ cached_at: 0,
+ }
+ }
+}
+
+/// Adoptium `/v3/assets/latest/{version}/hotspot` API response structures
+#[derive(Debug, Clone, Deserialize)]
+pub struct AdoptiumAsset {
+ pub binary: AdoptiumBinary,
+ pub release_name: String,
+ pub version: AdoptiumVersionData,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct AdoptiumBinary {
+ pub os: String,
+ pub architecture: String,
+ pub image_type: String,
+ pub package: AdoptiumPackage,
+ #[serde(default)]
+ pub updated_at: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct AdoptiumPackage {
+ pub name: String,
+ pub link: String,
+ pub size: u64,
+ pub checksum: Option<String>, // SHA256
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct AdoptiumVersionData {
+ pub major: u32,
+ pub minor: u32,
+ pub security: u32,
+ pub semver: String,
+ pub openjdk_version: String,
+}
+
+/// Adoptium available releases response
+#[derive(Debug, Clone, Deserialize)]
+pub struct AvailableReleases {
+ pub available_releases: Vec<u32>,
+ pub available_lts_releases: Vec<u32>,
+ pub most_recent_lts: Option<u32>,
+ pub most_recent_feature_release: Option<u32>,
+}
+
+/// Java download information from Adoptium
+#[derive(Debug, Clone, Serialize)]
+pub struct JavaDownloadInfo {
+ pub version: String,
+ pub release_name: String,
+ pub download_url: String,
+ pub file_name: String,
+ pub file_size: u64,
+ pub checksum: Option<String>,
+ pub image_type: String,
+}
+
+/// Get the Adoptium OS name for the current platform
+pub fn get_adoptium_os() -> &'static str {
+ #[cfg(target_os = "linux")]
+ {
+ // Check if Alpine Linux (musl libc)
+ if std::path::Path::new("/etc/alpine-release").exists() {
+ return "alpine-linux";
+ }
+ "linux"
+ }
+ #[cfg(target_os = "macos")]
+ {
+ "mac"
+ }
+ #[cfg(target_os = "windows")]
+ {
+ "windows"
+ }
+ #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
+ {
+ "linux" // fallback
+ }
+}
+
+/// Get the Adoptium Architecture name for the current architecture
+pub fn get_adoptium_arch() -> &'static str {
+ #[cfg(target_arch = "x86_64")]
+ {
+ "x64"
+ }
+ #[cfg(target_arch = "aarch64")]
+ {
+ "aarch64"
+ }
+ #[cfg(target_arch = "x86")]
+ {
+ "x86"
+ }
+ #[cfg(target_arch = "arm")]
+ {
+ "arm"
+ }
+ #[cfg(not(any(
+ target_arch = "x86_64",
+ target_arch = "aarch64",
+ target_arch = "x86",
+ target_arch = "arm"
+ )))]
+ {
+ "x64" // fallback
+ }
+}
+
+/// Get the default Java installation directory for DropOut
+pub fn get_java_install_dir(app_handle: &AppHandle) -> PathBuf {
+ app_handle.path().app_data_dir().unwrap().join("java")
+}
+
+/// Get the cache file path for Java catalog
+fn get_catalog_cache_path(app_handle: &AppHandle) -> PathBuf {
+ app_handle
+ .path()
+ .app_data_dir()
+ .unwrap()
+ .join("java_catalog_cache.json")
+}
+
+/// Load cached Java catalog if not expired
+pub fn load_cached_catalog(app_handle: &AppHandle) -> Option<JavaCatalog> {
+ let cache_path = get_catalog_cache_path(app_handle);
+ if !cache_path.exists() {
+ return None;
+ }
+
+ let content = std::fs::read_to_string(&cache_path).ok()?;
+ let catalog: JavaCatalog = serde_json::from_str(&content).ok()?;
+
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
+
+ if now - catalog.cached_at < CACHE_DURATION_SECS {
+ Some(catalog)
+ } else {
+ None
+ }
+}
+
+/// Save Java catalog to cache
+pub fn save_catalog_cache(app_handle: &AppHandle, catalog: &JavaCatalog) -> Result<(), String> {
+ let cache_path = get_catalog_cache_path(app_handle);
+ let content = serde_json::to_string_pretty(catalog).map_err(|e| e.to_string())?;
+ std::fs::write(&cache_path, content).map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+/// Clear Java catalog cache
+pub fn clear_catalog_cache(app_handle: &AppHandle) -> Result<(), String> {
+ let cache_path = get_catalog_cache_path(app_handle);
+ if cache_path.exists() {
+ std::fs::remove_file(&cache_path).map_err(|e| e.to_string())?;
+ }
+ Ok(())
+}
+
+/// Fetch complete Java catalog from Adoptium API with platform availability check
+pub async fn fetch_java_catalog(app_handle: &AppHandle, force_refresh: bool) -> Result<JavaCatalog, String> {
+ // Check cache first unless force refresh
+ if !force_refresh {
+ if let Some(cached) = load_cached_catalog(app_handle) {
+ return Ok(cached);
+ }
+ }
+
+ let os = get_adoptium_os();
+ let arch = get_adoptium_arch();
+ let client = reqwest::Client::new();
+
+ // 1. Fetch available releases
+ let releases_url = format!("{}/info/available_releases", ADOPTIUM_API_BASE);
+ let available: AvailableReleases = client
+ .get(&releases_url)
+ .header("Accept", "application/json")
+ .send()
+ .await
+ .map_err(|e| format!("Failed to fetch available releases: {}", e))?
+ .json()
+ .await
+ .map_err(|e| format!("Failed to parse available releases: {}", e))?;
+
+ let mut releases = Vec::new();
+
+ // 2. Fetch details for each major version
+ for major_version in &available.available_releases {
+ for image_type in &["jre", "jdk"] {
+ let url = format!(
+ "{}/assets/latest/{}/hotspot?os={}&architecture={}&image_type={}",
+ ADOPTIUM_API_BASE, major_version, os, arch, image_type
+ );
+
+ match client
+ .get(&url)
+ .header("Accept", "application/json")
+ .send()
+ .await
+ {
+ Ok(response) => {
+ if response.status().is_success() {
+ if let Ok(assets) = response.json::<Vec<AdoptiumAsset>>().await {
+ if let Some(asset) = assets.into_iter().next() {
+ let release_date = asset.binary.updated_at.clone();
+ releases.push(JavaReleaseInfo {
+ major_version: *major_version,
+ image_type: image_type.to_string(),
+ version: asset.version.semver.clone(),
+ release_name: asset.release_name.clone(),
+ release_date,
+ file_size: asset.binary.package.size,
+ checksum: asset.binary.package.checksum,
+ download_url: asset.binary.package.link,
+ is_lts: available.available_lts_releases.contains(major_version),
+ is_available: true,
+ architecture: asset.binary.architecture.clone(),
+ });
+ }
+ }
+ } else {
+ // Platform not available for this version/type
+ releases.push(JavaReleaseInfo {
+ major_version: *major_version,
+ image_type: image_type.to_string(),
+ version: format!("{}.x", major_version),
+ release_name: format!("jdk-{}", major_version),
+ release_date: None,
+ file_size: 0,
+ checksum: None,
+ download_url: String::new(),
+ is_lts: available.available_lts_releases.contains(major_version),
+ is_available: false,
+ architecture: arch.to_string(),
+ });
+ }
+ }
+ Err(_) => {
+ // Network error, mark as unavailable
+ releases.push(JavaReleaseInfo {
+ major_version: *major_version,
+ image_type: image_type.to_string(),
+ version: format!("{}.x", major_version),
+ release_name: format!("jdk-{}", major_version),
+ release_date: None,
+ file_size: 0,
+ checksum: None,
+ download_url: String::new(),
+ is_lts: available.available_lts_releases.contains(major_version),
+ is_available: false,
+ architecture: arch.to_string(),
+ });
+ }
+ }
+ }
+ }
+
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
+
+ let catalog = JavaCatalog {
+ releases,
+ available_major_versions: available.available_releases,
+ lts_versions: available.available_lts_releases,
+ cached_at: now,
+ };
+
+ // Save to cache
+ let _ = save_catalog_cache(app_handle, &catalog);
+
+ Ok(catalog)
+}
+
+/// Get Adoptium API download info for a specific Java version and image type
+///
+/// # Arguments
+/// * `major_version` - Java major version (e.g., 8, 11, 17)
+/// * `image_type` - JRE or JDK
+///
+/// # Returns
+/// * `Ok(JavaDownloadInfo)` - Download information
+/// * `Err(String)` - Error message
+pub async fn fetch_java_release(
+ major_version: u32,
+ image_type: ImageType,
+) -> Result<JavaDownloadInfo, String> {
+ let os = get_adoptium_os();
+ let arch = get_adoptium_arch();
+
+ let url = format!(
+ "{}/assets/latest/{}/hotspot?os={}&architecture={}&image_type={}",
+ ADOPTIUM_API_BASE, major_version, os, arch, image_type
+ );
+
+ let client = reqwest::Client::new();
+ let response = client
+ .get(&url)
+ .header("Accept", "application/json")
+ .send()
+ .await
+ .map_err(|e| format!("Network request failed: {}", e))?;
+
+ if !response.status().is_success() {
+ return Err(format!(
+ "Adoptium API returned error: {} - The version/platform might be unavailable",
+ response.status()
+ ));
+ }
+
+ let assets: Vec<AdoptiumAsset> = response
+ .json()
+ .await
+ .map_err(|e| format!("Failed to parse API response: {}", e))?;
+
+ let asset = assets
+ .into_iter()
+ .next()
+ .ok_or_else(|| format!("Java {} {} download not found", major_version, image_type))?;
+
+ Ok(JavaDownloadInfo {
+ version: asset.version.semver.clone(),
+ release_name: asset.release_name,
+ download_url: asset.binary.package.link,
+ file_name: asset.binary.package.name,
+ file_size: asset.binary.package.size,
+ checksum: asset.binary.package.checksum,
+ image_type: asset.binary.image_type,
+ })
+}
+
+/// Fetch available Java versions from Adoptium API
+pub async fn fetch_available_versions() -> Result<Vec<u32>, String> {
+ let url = format!("{}/info/available_releases", ADOPTIUM_API_BASE);
+
+ let response = reqwest::get(url)
+ .await
+ .map_err(|e| format!("Network request failed: {}", e))?;
+
+ #[derive(Deserialize)]
+ struct AvailableReleases {
+ available_releases: Vec<u32>,
+ }
+
+ let releases: AvailableReleases = response
+ .json()
+ .await
+ .map_err(|e| format!("Failed to parse response: {}", e))?;
+
+ Ok(releases.available_releases)
+}
+
+/// Download and install Java with resume support and progress events
+///
+/// # Arguments
+/// * `app_handle` - Tauri app handle for accessing app directories
+/// * `major_version` - Java major version (e.g., 8, 11, 17)
+/// * `image_type` - JRE or JDK
+/// * `custom_path` - Optional custom installation path
+///
+/// # Returns
+/// * `Ok(JavaInstallation)` - Information about the successfully installed Java
+pub async fn download_and_install_java(
+ app_handle: &AppHandle,
+ major_version: u32,
+ image_type: ImageType,
+ custom_path: Option<PathBuf>,
+) -> Result<JavaInstallation, String> {
+ // 1. Fetch download information
+ let info = fetch_java_release(major_version, image_type).await?;
+ let file_name = info.file_name.clone();
+
+ // 2. Prepare installation directory
+ let install_base = custom_path.unwrap_or_else(|| get_java_install_dir(app_handle));
+ let version_dir = install_base.join(format!("temurin-{}-{}", major_version, image_type));
+
+ std::fs::create_dir_all(&install_base)
+ .map_err(|e| format!("Failed to create installation directory: {}", e))?;
+
+ // 3. Add to download queue for persistence
+ let mut queue = DownloadQueue::load(app_handle);
+ queue.add(PendingJavaDownload {
+ major_version,
+ image_type: image_type.to_string(),
+ download_url: info.download_url.clone(),
+ file_name: info.file_name.clone(),
+ file_size: info.file_size,
+ checksum: info.checksum.clone(),
+ install_path: install_base.to_string_lossy().to_string(),
+ created_at: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ });
+ queue.save(app_handle)?;
+
+ // 4. Download the archive with resume support
+ let archive_path = install_base.join(&info.file_name);
+
+ // Check if we need to download
+ let need_download = if archive_path.exists() {
+ if let Some(expected_checksum) = &info.checksum {
+ let data = std::fs::read(&archive_path)
+ .map_err(|e| format!("Failed to read downloaded file: {}", e))?;
+ !downloader::verify_checksum(&data, Some(expected_checksum), None)
+ } else {
+ false
+ }
+ } else {
+ true
+ };
+
+ if need_download {
+ // Use resumable download
+ downloader::download_with_resume(
+ app_handle,
+ &info.download_url,
+ &archive_path,
+ info.checksum.as_deref(),
+ info.file_size,
+ )
+ .await?;
+ }
+
+ // 5. Emit extracting status
+ let _ = app_handle.emit(
+ "java-download-progress",
+ JavaDownloadProgress {
+ file_name: file_name.clone(),
+ downloaded_bytes: info.file_size,
+ total_bytes: info.file_size,
+ speed_bytes_per_sec: 0,
+ eta_seconds: 0,
+ status: "Extracting".to_string(),
+ percentage: 100.0,
+ },
+ );
+
+ // 6. Extract
+ // If the target directory exists, remove it first
+ if version_dir.exists() {
+ std::fs::remove_dir_all(&version_dir)
+ .map_err(|e| format!("Failed to remove old version directory: {}", e))?;
+ }
+
+ std::fs::create_dir_all(&version_dir)
+ .map_err(|e| format!("Failed to create version directory: {}", e))?;
+
+ let top_level_dir = if info.file_name.ends_with(".tar.gz") || info.file_name.ends_with(".tgz") {
+ zip::extract_tar_gz(&archive_path, &version_dir)?
+ } else if info.file_name.ends_with(".zip") {
+ zip::extract_zip(&archive_path, &version_dir)?;
+ // Find the top-level directory inside the extracted folder
+ find_top_level_dir(&version_dir)?
+ } else {
+ return Err(format!("Unsupported archive format: {}", info.file_name));
+ };
+
+ // 7. Clean up downloaded archive
+ let _ = std::fs::remove_file(&archive_path);
+
+ // 8. Locate java executable
+ // macOS has a different structure: jdk-xxx/Contents/Home/bin/java
+ // Linux/Windows: jdk-xxx/bin/java
+ let java_home = version_dir.join(&top_level_dir);
+ let java_bin = if cfg!(target_os = "macos") {
+ java_home.join("Contents").join("Home").join("bin").join("java")
+ } else if cfg!(windows) {
+ java_home.join("bin").join("java.exe")
+ } else {
+ java_home.join("bin").join("java")
+ };
+
+ if !java_bin.exists() {
+ return Err(format!(
+ "Installation completed but Java executable not found: {}",
+ java_bin.display()
+ ));
+ }
+
+ // 9. Verify installation
+ let installation = check_java_installation(&java_bin)
+ .ok_or_else(|| "Failed to verify Java installation".to_string())?;
+
+ // 10. Remove from download queue
+ queue.remove(major_version, &image_type.to_string());
+ queue.save(app_handle)?;
+
+ // 11. Emit completed status
+ let _ = app_handle.emit(
+ "java-download-progress",
+ JavaDownloadProgress {
+ file_name,
+ downloaded_bytes: info.file_size,
+ total_bytes: info.file_size,
+ speed_bytes_per_sec: 0,
+ eta_seconds: 0,
+ status: "Completed".to_string(),
+ percentage: 100.0,
+ },
+ );
+
+ Ok(installation)
+}
+
+/// Find the top-level directory inside the extracted folder
+fn find_top_level_dir(extract_dir: &PathBuf) -> Result<String, String> {
+ let entries: Vec<_> = std::fs::read_dir(extract_dir)
+ .map_err(|e| format!("Failed to read directory: {}", e))?
+ .filter_map(|e| e.ok())
+ .filter(|e| e.path().is_dir())
+ .collect();
+
+ if entries.len() == 1 {
+ Ok(entries[0].file_name().to_string_lossy().to_string())
+ } else {
+ // No single top-level directory, return empty string
+ Ok(String::new())
+ }
+}
+
/// Detect Java installations on the system
pub fn detect_java_installations() -> Vec<JavaInstallation> {
let mut installations = Vec::new();
@@ -256,3 +849,137 @@ pub fn get_recommended_java(required_major_version: Option<u64>) -> Option<JavaI
installations.into_iter().next()
}
}
+
+/// Detect all installed Java versions (including system installations and DropOut downloads)
+pub fn detect_all_java_installations(app_handle: &AppHandle) -> Vec<JavaInstallation> {
+ let mut installations = detect_java_installations();
+
+ // Add DropOut downloaded Java versions
+ let dropout_java_dir = get_java_install_dir(app_handle);
+ if dropout_java_dir.exists() {
+ if let Ok(entries) = std::fs::read_dir(&dropout_java_dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ // Find the java executable in this directory
+ let java_bin = find_java_executable(&path);
+ if let Some(java_path) = java_bin {
+ if let Some(java) = check_java_installation(&java_path) {
+ if !installations.iter().any(|j| j.path == java.path) {
+ installations.push(java);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Sort by version
+ installations.sort_by(|a, b| {
+ let v_a = parse_java_version(&a.version);
+ let v_b = parse_java_version(&b.version);
+ v_b.cmp(&v_a)
+ });
+
+ installations
+}
+
+//// Find the java executable in a directory using a limited-depth search
+fn find_java_executable(dir: &PathBuf) -> Option<PathBuf> {
+ let bin_name = if cfg!(windows) { "java.exe" } else { "java" };
+
+ // Directly look in the bin directory
+ let direct_bin = dir.join("bin").join(bin_name);
+ if direct_bin.exists() {
+ return Some(direct_bin);
+ }
+
+ // macOS: Contents/Home/bin/java
+ #[cfg(target_os = "macos")]
+ {
+ let macos_bin = dir.join("Contents").join("Home").join("bin").join(bin_name);
+ if macos_bin.exists() {
+ return Some(macos_bin);
+ }
+ }
+
+ // Look in subdirectories (handle nested directories after Adoptium extraction)
+ if let Ok(entries) = std::fs::read_dir(dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ // Try direct bin path
+ let nested_bin = path.join("bin").join(bin_name);
+ if nested_bin.exists() {
+ return Some(nested_bin);
+ }
+
+ // macOS: nested/Contents/Home/bin/java
+ #[cfg(target_os = "macos")]
+ {
+ let macos_nested = path.join("Contents").join("Home").join("bin").join(bin_name);
+ if macos_nested.exists() {
+ return Some(macos_nested);
+ }
+ }
+ }
+ }
+ }
+
+ None
+}
+
+/// Resume pending Java downloads from queue
+pub async fn resume_pending_downloads(app_handle: &AppHandle) -> Result<Vec<JavaInstallation>, String> {
+ let queue = DownloadQueue::load(app_handle);
+ let mut installed = Vec::new();
+
+ for pending in queue.pending_downloads.iter() {
+ let image_type = if pending.image_type == "jdk" {
+ ImageType::Jdk
+ } else {
+ ImageType::Jre
+ };
+
+ // Try to resume the download
+ match download_and_install_java(
+ app_handle,
+ pending.major_version,
+ image_type,
+ Some(PathBuf::from(&pending.install_path)),
+ )
+ .await
+ {
+ Ok(installation) => {
+ installed.push(installation);
+ }
+ Err(e) => {
+ eprintln!(
+ "Failed to resume Java {} {} download: {}",
+ pending.major_version, pending.image_type, e
+ );
+ }
+ }
+ }
+
+ Ok(installed)
+}
+
+/// Cancel current Java download
+pub fn cancel_current_download() {
+ downloader::cancel_java_download();
+}
+
+/// Get pending downloads from queue
+pub fn get_pending_downloads(app_handle: &AppHandle) -> Vec<PendingJavaDownload> {
+ let queue = DownloadQueue::load(app_handle);
+ queue.pending_downloads
+}
+
+/// Clear a specific pending download
+pub fn clear_pending_download(app_handle: &AppHandle, major_version: u32, image_type: &str) -> Result<(), String> {
+ let mut queue = DownloadQueue::load(app_handle);
+ queue.remove(major_version, image_type);
+ queue.save(app_handle)
+}
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 ae74a03..b69912e 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -6,6 +6,7 @@ use std::sync::Mutex;
use tauri::{Emitter, Manager, State, Window}; // Added Emitter
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
+use serde::Serialize; // Added Serialize
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
@@ -40,6 +41,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,
@@ -93,35 +115,16 @@ 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())?;
+ // 1. Load version (supports both vanilla and modded versions with inheritance)
emit_log!(
window,
- format!("Found {} versions in manifest", manifest.versions.len())
+ format!("Loading version details for {}...", version_id)
);
- // 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()
+ let version_details = core::manifest::load_version(&game_dir, &version_id)
.await
.map_err(|e| e.to_string())?;
+
emit_log!(
window,
format!(
@@ -130,20 +133,33 @@ async fn start_game(
)
);
- // 3. Prepare download tasks
+ // 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,
});
// --- Libraries ---
@@ -153,7 +169,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
@@ -167,7 +183,8 @@ 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,
});
}
@@ -200,13 +217,30 @@ 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,
});
native_libs_paths.push(native_path);
}
}
}
+ } 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
+ sha256: None,
+ });
+ }
+ }
}
}
}
@@ -217,8 +251,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.
@@ -230,11 +270,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()
@@ -254,6 +291,7 @@ async fn start_game(
#[derive(serde::Deserialize, Debug)]
struct AssetObject {
hash: String,
+ #[allow(dead_code)]
size: u64,
}
@@ -280,6 +318,7 @@ async fn start_game(
url,
path,
sha1: Some(hash),
+ sha256: None,
});
}
@@ -394,10 +433,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());
@@ -449,7 +485,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() {
@@ -457,7 +496,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);
+ }
}
}
}
@@ -785,7 +827,7 @@ async fn refresh_account(
.map_err(|e| e.to_string())?;
let storage = core::account_storage::AccountStorage::new(app_dir.clone());
- let (stored_account, ms_refresh) = storage
+ let (_stored_account, ms_refresh) = storage
.get_active_account()
.ok_or("No active account found")?;
@@ -807,8 +849,8 @@ async fn refresh_account(
/// Detect Java installations on the system
#[tauri::command]
-async fn detect_java() -> Result<Vec<core::java::JavaInstallation>, String> {
- Ok(core::java::detect_java_installations())
+async fn detect_java(app_handle: tauri::AppHandle) -> Result<Vec<core::java::JavaInstallation>, String> {
+ Ok(core::java::detect_all_java_installations(&app_handle))
}
/// Get recommended Java for a specific Minecraft version
@@ -819,8 +861,349 @@ async fn get_recommended_java(
Ok(core::java::get_recommended_java(required_major_version))
}
+/// Get Adoptium Java download info
+#[tauri::command]
+async fn fetch_adoptium_java(
+ major_version: u32,
+ image_type: String,
+) -> Result<core::java::JavaDownloadInfo, String> {
+ let img_type = match image_type.to_lowercase().as_str() {
+ "jdk" => core::java::ImageType::Jdk,
+ _ => core::java::ImageType::Jre,
+ };
+ core::java::fetch_java_release(major_version, img_type).await
+}
+
+/// Download and install Adoptium Java
+#[tauri::command]
+async fn download_adoptium_java(
+ app_handle: tauri::AppHandle,
+ major_version: u32,
+ image_type: String,
+ custom_path: Option<String>,
+) -> Result<core::java::JavaInstallation, String> {
+ let img_type = match image_type.to_lowercase().as_str() {
+ "jdk" => core::java::ImageType::Jdk,
+ _ => core::java::ImageType::Jre,
+ };
+ let path = custom_path.map(std::path::PathBuf::from);
+ core::java::download_and_install_java(&app_handle, major_version, img_type, path).await
+}
+
+/// Get available Adoptium Java versions
+#[tauri::command]
+async fn fetch_available_java_versions() -> Result<Vec<u32>, String> {
+ core::java::fetch_available_versions().await
+}
+
+/// Fetch Java catalog with platform availability (uses cache)
+#[tauri::command]
+async fn fetch_java_catalog(
+ app_handle: tauri::AppHandle,
+) -> Result<core::java::JavaCatalog, String> {
+ core::java::fetch_java_catalog(&app_handle, false).await
+}
+
+/// Refresh Java catalog (bypass cache)
+#[tauri::command]
+async fn refresh_java_catalog(
+ app_handle: tauri::AppHandle,
+) -> Result<core::java::JavaCatalog, String> {
+ core::java::fetch_java_catalog(&app_handle, true).await
+}
+
+/// Cancel current Java download
+#[tauri::command]
+async fn cancel_java_download() -> Result<(), String> {
+ core::java::cancel_current_download();
+ Ok(())
+}
+
+/// Get pending Java downloads
+#[tauri::command]
+async fn get_pending_java_downloads(
+ app_handle: tauri::AppHandle,
+) -> Result<Vec<core::downloader::PendingJavaDownload>, String> {
+ Ok(core::java::get_pending_downloads(&app_handle))
+}
+
+/// Resume pending Java downloads
+#[tauri::command]
+async fn resume_java_downloads(
+ app_handle: tauri::AppHandle,
+) -> Result<Vec<core::java::JavaInstallation>, String> {
+ core::java::resume_pending_downloads(&app_handle).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)
+}
+
+#[derive(serde::Serialize)]
+struct GithubRelease {
+ tag_name: String,
+ name: String,
+ published_at: String,
+ body: String,
+ html_url: String,
+}
+
+#[tauri::command]
+async fn get_github_releases() -> Result<Vec<GithubRelease>, String> {
+ let client = reqwest::Client::new();
+ let res = client
+ .get("https://api.github.com/repos/HsiangNianian/DropOut/releases")
+ .header("User-Agent", "DropOut-Launcher")
+ .send()
+ .await
+ .map_err(|e| e.to_string())?;
+
+ if !res.status().is_success() {
+ return Err(format!("GitHub API returned status: {}", res.status()));
+ }
+
+ let releases: Vec<serde_json::Value> = res.json().await.map_err(|e| e.to_string())?;
+
+ let mut result = Vec::new();
+ for r in releases {
+ if let (Some(tag), Some(name), Some(date), Some(body), Some(url)) = (
+ r["tag_name"].as_str(),
+ r["name"].as_str(),
+ r["published_at"].as_str(),
+ r["body"].as_str(),
+ r["html_url"].as_str()
+ ) {
+ result.push(GithubRelease {
+ tag_name: tag.to_string(),
+ name: name.to_string(),
+ published_at: date.to_string(),
+ body: body.to_string(),
+ html_url: url.to_string(),
+ });
+ }
+ }
+ Ok(result)
+}
+
+#[derive(Serialize)]
+struct PastebinResponse {
+ url: String,
+}
+
+#[tauri::command]
+async fn upload_to_pastebin(
+ state: State<'_, core::config::ConfigState>,
+ content: String,
+) -> Result<PastebinResponse, String> {
+ // Check content length limit
+ if content.len() > 500 * 1024 {
+ return Err("Log file too large (max 500KB)".to_string());
+ }
+
+ // Extract config values before any async calls to avoid holding MutexGuard across await
+ let (service, api_key) = {
+ let config = state.config.lock().unwrap();
+ (
+ config.log_upload_service.clone(),
+ config.pastebin_api_key.clone(),
+ )
+ };
+
+ let client = reqwest::Client::new();
+
+ match service.as_str() {
+ "pastebin.com" => {
+ let api_key = api_key
+ .ok_or("Pastebin API Key not configured in settings")?;
+
+ let res = client
+ .post("https://pastebin.com/api/api_post.php")
+ .form(&[
+ ("api_dev_key", api_key.as_str()),
+ ("api_option", "paste"),
+ ("api_paste_code", content.as_str()),
+ ("api_paste_private", "1"), // Unlisted
+ ("api_paste_name", "DropOut Launcher Log"),
+ ("api_paste_expire_date", "1W"),
+ ])
+ .send()
+ .await
+ .map_err(|e| e.to_string())?;
+
+ if !res.status().is_success() {
+ return Err(format!("Pastebin upload failed: {}", res.status()));
+ }
+
+ let url = res.text().await.map_err(|e| e.to_string())?;
+ if url.starts_with("Bad API Request") {
+ return Err(format!("Pastebin API error: {}", url));
+ }
+ Ok(PastebinResponse { url })
+ }
+ // Default to paste.rs
+ _ => {
+ let res = client
+ .post("https://paste.rs/")
+ .body(content)
+ .send()
+ .await
+ .map_err(|e| e.to_string())?;
+
+ if !res.status().is_success() {
+ return Err(format!("paste.rs upload failed: {}", res.status()));
+ }
+
+ let url = res.text().await.map_err(|e| e.to_string())?;
+ let url = url.trim().to_string();
+ Ok(PastebinResponse { url })
+ }
+ }
+}
+
fn main() {
tauri::Builder::default()
+ .plugin(tauri_plugin_fs::init())
+ .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.manage(core::auth::AccountState::new())
.manage(MsRefreshTokenState::new())
@@ -846,6 +1229,13 @@ fn main() {
println!("[Startup] Loaded saved account");
}
+ // Check for pending Java downloads and notify frontend
+ let pending = core::java::get_pending_downloads(&app.app_handle());
+ if !pending.is_empty() {
+ println!("[Startup] Found {} pending Java download(s)", pending.len());
+ let _ = app.emit("pending-java-downloads", pending.len());
+ }
+
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -859,8 +1249,30 @@ fn main() {
start_microsoft_login,
complete_microsoft_login,
refresh_account,
+ // Java commands
detect_java,
- get_recommended_java
+ get_recommended_java,
+ fetch_adoptium_java,
+ download_adoptium_java,
+ fetch_available_java_versions,
+ fetch_java_catalog,
+ refresh_java_catalog,
+ cancel_java_download,
+ get_pending_java_downloads,
+ resume_java_downloads,
+ // 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,
+ get_github_releases,
+ upload_to_pastebin
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/src-tauri/src/utils/zip.rs b/src-tauri/src/utils/zip.rs
index a03c975..dfe1214 100644
--- a/src-tauri/src/utils/zip.rs
+++ b/src-tauri/src/utils/zip.rs
@@ -1,5 +1,7 @@
+use flate2::read::GzDecoder;
use std::fs;
use std::path::Path;
+use tar::Archive;
pub fn extract_zip(zip_path: &Path, extract_to: &Path) -> Result<(), String> {
let file = fs::File::open(zip_path)
@@ -38,3 +40,76 @@ pub fn extract_zip(zip_path: &Path, extract_to: &Path) -> Result<(), String> {
Ok(())
}
+
+/// Extract a tar.gz archive
+///
+/// Adoptium's tar.gz archives usually contain a top-level directory, such as `jdk-21.0.5+11-jre/`.
+/// This function returns the name of that directory to facilitate locating `bin/java` afterwards.
+pub fn extract_tar_gz(archive_path: &Path, extract_to: &Path) -> Result<String, String> {
+ let file = fs::File::open(archive_path)
+ .map_err(|e| format!("Failed to open tar.gz {}: {}", archive_path.display(), e))?;
+
+ let decoder = GzDecoder::new(file);
+ let mut archive = Archive::new(decoder);
+
+ // Ensure the target directory exists
+ fs::create_dir_all(extract_to)
+ .map_err(|e| format!("Failed to create extract directory: {}", e))?;
+
+ // Track the top-level directory name
+ let mut top_level_dir: Option<String> = None;
+
+ for entry in archive
+ .entries()
+ .map_err(|e| format!("Failed to read tar entries: {}", e))?
+ {
+ let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {}", e))?;
+ let entry_path = entry
+ .path()
+ .map_err(|e| format!("Failed to get entry path: {}", e))?
+ .into_owned();
+
+ // Extract the top-level directory name (the first path component)
+ if top_level_dir.is_none() {
+ if let Some(first_component) = entry_path.components().next() {
+ let component_str = first_component.as_os_str().to_string_lossy().to_string();
+ if !component_str.is_empty() && component_str != "." {
+ top_level_dir = Some(component_str);
+ }
+ }
+ }
+
+ let outpath = extract_to.join(&entry_path);
+
+ if entry.header().entry_type().is_dir() {
+ fs::create_dir_all(&outpath)
+ .map_err(|e| format!("Failed to create directory {}: {}", outpath.display(), e))?;
+ } else {
+ // Ensure parent directory exists
+ if let Some(parent) = outpath.parent() {
+ if !parent.exists() {
+ fs::create_dir_all(parent)
+ .map_err(|e| format!("Failed to create parent dir: {}", e))?;
+ }
+ }
+
+ let mut outfile = fs::File::create(&outpath)
+ .map_err(|e| format!("Failed to create file {}: {}", outpath.display(), e))?;
+
+ std::io::copy(&mut entry, &mut outfile)
+ .map_err(|e| format!("Failed to extract file: {}", e))?;
+
+ // Set executable permissions on Unix systems
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ if let Ok(mode) = entry.header().mode() {
+ let permissions = fs::Permissions::from_mode(mode);
+ let _ = fs::set_permissions(&outpath, permissions);
+ }
+ }
+ }
+ }
+
+ top_level_dir.ok_or_else(|| "Archive appears to be empty".to_string())
+}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 9aa3b68..ce54ca8 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
{
"productName": "dropout",
- "version": "0.1.14",
+ "version": "0.1.21",
"identifier": "com.dropout.launcher",
"build": {
"beforeDevCommand": "pnpm -C ../ui 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
index 2682169..a142994 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -9,7 +9,9 @@
"version": "0.0.0",
"dependencies": {
"@tauri-apps/api": "^2.9.1",
- "@tauri-apps/plugin-shell": "^2.3.4"
+ "@tauri-apps/plugin-dialog": "^2.5.0",
+ "@tauri-apps/plugin-shell": "^2.3.4",
+ "lucide-svelte": "^0.562.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
@@ -63,7 +65,6 @@
"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",
@@ -74,7 +75,6 @@
"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",
@@ -85,7 +85,6 @@
"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"
@@ -95,14 +94,12 @@
"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",
@@ -395,7 +392,6 @@
"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"
@@ -722,6 +718,15 @@
"url": "https://opencollective.com/tauri"
}
},
+ "node_modules/@tauri-apps/plugin-dialog": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
+ "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.8.0"
+ }
+ },
"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",
@@ -753,7 +758,6 @@
"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": {
@@ -770,7 +774,6 @@
"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"
@@ -783,7 +786,6 @@
"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"
@@ -830,7 +832,6 @@
"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"
@@ -921,7 +922,6 @@
"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"
@@ -951,7 +951,6 @@
"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": {
@@ -989,14 +988,12 @@
"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"
@@ -1060,7 +1057,6 @@
"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"
@@ -1341,14 +1337,21 @@
"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/lucide-svelte": {
+ "version": "0.562.0",
+ "resolved": "https://registry.npmmirror.com/lucide-svelte/-/lucide-svelte-0.562.0.tgz",
+ "integrity": "sha512-kSJDH/55lf0mun/o4nqWBXOcq0fWYzPeIjbTD97ywoeumAB9kWxtM06gC7oynqjtK3XhAljWSz5RafIzPEYIQA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "svelte": "^3 || ^4 || ^5.0.0-next.42"
+ }
+ },
"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"
@@ -1531,7 +1534,6 @@
"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",
@@ -1777,7 +1779,6 @@
"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..5848109 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -29,6 +29,11 @@
},
"dependencies": {
"@tauri-apps/api": "^2.9.1",
- "@tauri-apps/plugin-shell": "^2.3.4"
+ "@tauri-apps/plugin-dialog": "^2.5.0",
+ "@tauri-apps/plugin-fs": "^2.4.5",
+ "@tauri-apps/plugin-shell": "^2.3.4",
+ "lucide-svelte": "^0.562.0",
+ "marked": "^17.0.1",
+ "node-emoji": "^2.2.0"
}
}
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index d48c01e..67e9cbc 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -14,9 +14,24 @@ 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-fs':
+ specifier: ^2.4.5
+ version: 2.4.5
'@tauri-apps/plugin-shell':
specifier: ^2.3.4
version: 2.3.4
+ lucide-svelte:
+ specifier: ^0.562.0
+ version: 0.562.0(svelte@5.46.3)
+ marked:
+ specifier: ^17.0.1
+ version: 17.0.1
+ node-emoji:
+ specifier: ^2.2.0
+ version: 2.2.0
devDependencies:
'@sveltejs/vite-plugin-svelte':
specifier: ^6.2.1
@@ -179,6 +194,10 @@ packages:
'@rolldown/pluginutils@1.0.0-beta.50':
resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==}
+ '@sindresorhus/is@4.6.0':
+ resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
+ engines: {node: '>=10'}
+
'@sveltejs/acorn-typescript@1.0.8':
resolution: {integrity: sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==}
peerDependencies:
@@ -296,6 +315,12 @@ packages:
'@tauri-apps/api@2.9.1':
resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==}
+ '@tauri-apps/plugin-dialog@2.5.0':
+ resolution: {integrity: sha512-I0R0ygwRd9AN8Wj5GnzCogOlqu2+OWAtBd0zEC4+kQCI32fRowIyuhPCBoUv4h/lQt2bM39kHlxPHD5vDcFjiA==}
+
+ '@tauri-apps/plugin-fs@2.4.5':
+ resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
+
'@tauri-apps/plugin-shell@2.3.4':
resolution: {integrity: sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==}
@@ -343,6 +368,10 @@ packages:
caniuse-lite@1.0.30001764:
resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==}
+ char-regex@1.0.2:
+ resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
+ engines: {node: '>=10'}
+
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -365,6 +394,9 @@ packages:
electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
+ emojilib@2.4.0:
+ resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==}
+
enhanced-resolve@5.18.4:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
@@ -483,9 +515,19 @@ packages:
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
+ lucide-svelte@0.562.0:
+ resolution: {integrity: sha512-kSJDH/55lf0mun/o4nqWBXOcq0fWYzPeIjbTD97ywoeumAB9kWxtM06gC7oynqjtK3XhAljWSz5RafIzPEYIQA==}
+ peerDependencies:
+ svelte: ^3 || ^4 || ^5.0.0-next.42
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ marked@17.0.1:
+ resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -495,6 +537,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ node-emoji@2.2.0:
+ resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
+ engines: {node: '>=18'}
+
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@@ -568,6 +614,10 @@ packages:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
+ skin-tone@2.0.0:
+ resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==}
+ engines: {node: '>=8'}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -606,6 +656,10 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+ unicode-emoji-modifier-base@1.0.0:
+ resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
+ engines: {node: '>=4'}
+
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -717,6 +771,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-beta.50': {}
+ '@sindresorhus/is@4.6.0': {}
+
'@sveltejs/acorn-typescript@1.0.8(acorn@8.15.0)':
dependencies:
acorn: 8.15.0
@@ -808,6 +864,14 @@ snapshots:
'@tauri-apps/api@2.9.1': {}
+ '@tauri-apps/plugin-dialog@2.5.0':
+ dependencies:
+ '@tauri-apps/api': 2.9.1
+
+ '@tauri-apps/plugin-fs@2.4.5':
+ dependencies:
+ '@tauri-apps/api': 2.9.1
+
'@tauri-apps/plugin-shell@2.3.4':
dependencies:
'@tauri-apps/api': 2.9.1
@@ -852,6 +916,8 @@ snapshots:
caniuse-lite@1.0.30001764: {}
+ char-regex@1.0.2: {}
+
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@@ -866,6 +932,8 @@ snapshots:
electron-to-chromium@1.5.267: {}
+ emojilib@2.4.0: {}
+
enhanced-resolve@5.18.4:
dependencies:
graceful-fs: 4.2.11
@@ -947,14 +1015,27 @@ snapshots:
locate-character@3.0.0: {}
+ lucide-svelte@0.562.0(svelte@5.46.3):
+ dependencies:
+ svelte: 5.46.3
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ marked@17.0.1: {}
+
mri@1.2.0: {}
nanoid@3.3.11: {}
+ node-emoji@2.2.0:
+ dependencies:
+ '@sindresorhus/is': 4.6.0
+ char-regex: 1.0.2
+ emojilib: 2.4.0
+ skin-tone: 2.0.0
+
node-releases@2.0.27: {}
obug@2.1.1: {}
@@ -1011,6 +1092,10 @@ snapshots:
dependencies:
mri: 1.2.0
+ skin-tone@2.0.0:
+ dependencies:
+ unicode-emoji-modifier-base: 1.0.0
+
source-map-js@1.2.1: {}
svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.46.3)(typescript@5.9.3):
@@ -1059,6 +1144,8 @@ snapshots:
undici-types@7.16.0: {}
+ unicode-emoji-modifier-base@1.0.0: {}
+
update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
browserslist: 4.28.1
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index 3750f11..0bb31ae 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -1,64 +1,178 @@
<script lang="ts">
import { getVersion } from "@tauri-apps/api/app";
- import { onMount } from "svelte";
+ // import { convertFileSrc } from "@tauri-apps/api/core"; // Removed duplicate, handled by import below or inline
+ import { onDestroy, onMount } from "svelte";
import DownloadMonitor from "./lib/DownloadMonitor.svelte";
import GameConsole from "./lib/GameConsole.svelte";
-
- // Components
- import Sidebar from "./components/Sidebar.svelte";
- import HomeView from "./components/HomeView.svelte";
- import VersionsView from "./components/VersionsView.svelte";
- import SettingsView from "./components/SettingsView.svelte";
+// Components
import BottomBar from "./components/BottomBar.svelte";
+ import HomeView from "./components/HomeView.svelte";
import LoginModal from "./components/LoginModal.svelte";
+ import ParticleBackground from "./components/ParticleBackground.svelte";
+ import SettingsView from "./components/SettingsView.svelte";
+ import Sidebar from "./components/Sidebar.svelte";
import StatusToast from "./components/StatusToast.svelte";
-
- // Stores
- import { uiState } from "./stores/ui.svelte";
+ import VersionsView from "./components/VersionsView.svelte";
+// Stores
import { authState } from "./stores/auth.svelte";
- import { settingsState } from "./stores/settings.svelte";
import { gameState } from "./stores/game.svelte";
+ import { settingsState } from "./stores/settings.svelte";
+ import { uiState } from "./stores/ui.svelte";
+ import { convertFileSrc } from "@tauri-apps/api/core";
+
+ 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();
+ await settingsState.detectJava();
gameState.loadVersions();
getVersion().then((v) => (uiState.appVersion = v));
+ window.addEventListener("mousemove", handleMouseMove);
+ });
+
+ $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');
+
+ // Ensure 'light' class is never present
+ document.documentElement.classList.remove('light');
+ });
+
+ 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>
+ <!-- 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"
+ onerror={(e) => console.error("Failed to load main background:", e)}
+ />
+ <!-- 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}
- <!-- Background / Poster area -->
- <div class="flex-1 relative overflow-hidden group">
{#if uiState.currentView === "home"}
- <HomeView />
- {:else if uiState.currentView === "versions"}
- <VersionsView />
- {:else if uiState.currentView === "settings"}
- <SettingsView />
+ <ParticleBackground />
{/if}
+
+ <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>
+ </div>
+
+ <!-- Content Wrapper -->
+ <div class="relative z-10 flex h-full p-4 gap-4 text-gray-900 dark:text-white">
+ <!-- Floating Sidebar -->
+ <Sidebar />
+
+ <!-- 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="h-8 w-full absolute top-0 left-0 z-50 drag-region"
+ data-tauri-drag-region
+ ></div>
- <BottomBar />
- </main>
+ <!-- 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>
+
+ <!-- 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>
+
+ <!-- Bottom Bar -->
+ {#if uiState.currentView === "home"}
+ <BottomBar />
+ {/if}
+ </div>
+ </main>
+ </div>
<LoginModal />
<StatusToast />
-
- <GameConsole visible={uiState.showConsole} />
+
+ {#if uiState.showConsole}
+ <div class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-8">
+ <div class="w-full h-full max-w-6xl max-h-[85vh] bg-[#1e1e1e] rounded-lg overflow-hidden border border-zinc-700 shadow-2xl relative flex flex-col">
+ <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..82aa72f 100644
--- a/ui/src/app.css
+++ b/ui/src/app.css
@@ -1 +1,165 @@
@import "tailwindcss";
+
+@variant dark (&:where(.dark, .dark *));
+
+/* ==================== Custom Select/Dropdown Styles ==================== */
+
+/* Base select styling */
+select {
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2371717a'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.5rem center;
+ background-size: 1rem;
+ padding-right: 2rem;
+}
+
+/* Option styling - works in WebView/Chromium */
+select option {
+ background-color: #18181b;
+ color: #e4e4e7;
+ padding: 12px 16px;
+ font-size: 13px;
+ border: none;
+}
+
+select option:hover,
+select option:focus {
+ background-color: #3730a3 !important;
+ color: white !important;
+}
+
+select option:checked {
+ background: linear-gradient(0deg, #4f46e5 0%, #4f46e5 100%);
+ color: white;
+ font-weight: 500;
+}
+
+select option:disabled {
+ color: #52525b;
+ background-color: #18181b;
+}
+
+/* Optgroup styling */
+select optgroup {
+ background-color: #18181b;
+ color: #a1a1aa;
+ font-weight: 600;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 8px 12px 4px;
+}
+
+/* Select focus state */
+select:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
+}
+
+/* ==================== Custom Scrollbar (Global) ==================== */
+
+/* Firefox */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: #3f3f46 transparent;
+}
+
+/* Webkit browsers */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background-color: #3f3f46;
+ border-radius: 4px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: #52525b;
+}
+
+::-webkit-scrollbar-corner {
+ background: transparent;
+}
+
+/* ==================== Input/Form Element Consistency ==================== */
+
+input[type="text"],
+input[type="number"],
+input[type="password"],
+input[type="email"],
+textarea {
+ background-color: rgba(0, 0, 0, 0.4);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+input[type="text"]:focus,
+input[type="number"]:focus,
+input[type="password"]:focus,
+input[type="email"]:focus,
+textarea:focus {
+ border-color: rgba(99, 102, 241, 0.5);
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
+ outline: none;
+}
+
+/* Number input - hide spinner */
+input[type="number"]::-webkit-outer-spin-button,
+input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+input[type="number"] {
+ -moz-appearance: textfield;
+}
+
+/* ==================== Checkbox Styling ==================== */
+
+input[type="checkbox"] {
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ background-color: #18181b;
+ cursor: pointer;
+ position: relative;
+ transition: all 0.15s ease;
+}
+
+input[type="checkbox"]:hover {
+ border-color: #52525b;
+}
+
+input[type="checkbox"]:checked {
+ background-color: #4f46e5;
+ border-color: #4f46e5;
+}
+
+input[type="checkbox"]:checked::after {
+ content: '';
+ position: absolute;
+ left: 5px;
+ top: 2px;
+ width: 4px;
+ height: 8px;
+ border: solid white;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
+
+input[type="checkbox"]:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
+}
diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte
index dcad9e8..abb0b23 100644
--- a/ui/src/components/BottomBar.svelte
+++ b/ui/src/components/BottomBar.svelte
@@ -2,21 +2,55 @@
import { authState } from "../stores/auth.svelte";
import { gameState } from "../stores/game.svelte";
import { uiState } from "../stores/ui.svelte";
+ import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte';
+
+ let isVersionDropdownOpen = $state(false);
+ let dropdownRef: HTMLDivElement;
+
+ let versionOptions = $derived(
+ gameState.versions.length === 0
+ ? [{ id: "loading", type: "loading", label: "Loading..." }]
+ : gameState.versions.map(v => ({
+ ...v,
+ label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}`
+ }))
+ );
+
+ function selectVersion(id: string) {
+ if (id !== "loading") {
+ gameState.selectedVersion = id;
+ isVersionDropdownOpen = false;
+ }
+ }
+
+ function handleClickOutside(e: MouseEvent) {
+ if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
+ isVersionDropdownOpen = false;
+ }
+ }
+
+ $effect(() => {
+ if (isVersionDropdownOpen) {
+ document.addEventListener('click', handleClickOutside);
+ return () => document.removeEventListener('click', handleClickOutside);
+ }
+ });
</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-20 bg-white/80 dark:bg-[#09090b]/90 border-t dark:border-white/10 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"
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-10 h-10 rounded-sm bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 flex items-center justify-center overflow-hidden transition-all group-hover:border-zinc-400 dark:group-hover:border-zinc-500"
>
{#if authState.currentAccount}
<img
@@ -25,64 +59,98 @@
class="w-full h-full"
/>
{:else}
- ?
+ <User size={20} class="text-zinc-400" />
{/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-sm group-hover:text-black dark:group-hover:text-zinc-200 transition-colors">
+ {authState.currentAccount ? authState.currentAccount.username : "Login Account"}
</div>
- <div class="text-xs text-zinc-400 flex items-center gap-1">
+ <div class="text-[10px] uppercase tracking-wider dark:text-zinc-500 text-gray-500 flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full {authState.currentAccount
- ? 'bg-green-500'
- : 'bg-zinc-500'}"
+ ? 'bg-emerald-500'
+ : 'bg-zinc-400'}"
></span>
- {authState.currentAccount ? "Ready" : "Guest"}
+ {authState.currentAccount ? "Online" : "Guest"}
</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 px-2 py-1 rounded-sm hover:bg-black/5 dark:hover:bg-white/5"
onclick={() => uiState.toggleConsole()}
>
- {uiState.showConsole ? "Hide Logs" : "Show Logs"}
+ <Terminal size={14} />
+ {uiState.showConsole ? "HIDE LOGS" : "SHOW LOGS"}
</button>
</div>
+ <!-- Action Area -->
<div class="flex items-center gap-4">
<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
- >
- <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}
+ <!-- Custom Version Dropdown -->
+ <div class="relative" bind:this={dropdownRef}>
+ <button
+ type="button"
+ onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen}
+ class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left
+ dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md
+ text-sm font-mono dark:text-white text-gray-900
+ dark:hover:border-zinc-600 hover:border-zinc-400
+ focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+ transition-colors cursor-pointer outline-none"
+ >
+ <span class="truncate">
+ {#if gameState.versions.length === 0}
+ Loading...
+ {:else}
+ {gameState.selectedVersion || "Select version"}
+ {/if}
+ </span>
+ <ChevronDown
+ size={14}
+ class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}"
+ />
+ </button>
+
+ {#if isVersionDropdownOpen}
+ <div
+ class="absolute z-50 w-full mt-1 py-1 dark:bg-zinc-900 bg-white border dark:border-zinc-700 border-zinc-300 rounded-md shadow-xl
+ max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1"
+ >
+ {#each versionOptions as version}
+ <button
+ type="button"
+ onclick={() => selectVersion(version.id)}
+ disabled={version.id === "loading"}
+ class="w-full flex items-center justify-between px-3 py-2 text-sm font-mono text-left
+ transition-colors outline-none
+ {version.id === gameState.selectedVersion
+ ? 'bg-indigo-600 text-white'
+ : 'dark:text-zinc-300 text-gray-700 dark:hover:bg-zinc-800 hover:bg-zinc-100'}
+ {version.id === 'loading' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
+ >
+ <span class="truncate">{version.label}</span>
+ {#if version.id === gameState.selectedVersion}
+ <Check size={14} class="shrink-0 ml-2" />
+ {/if}
+ </button>
+ {/each}
+ </div>
{/if}
- </select>
+ </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-emerald-600 hover:bg-emerald-500 text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase"
>
- Play
- <span
- class="text-[10px] font-normal opacity-80 normal-case tracking-normal"
- >Click to launch</span
- >
+ <Play size={24} fill="currentColor" />
+ <span>Launch</span>
</button>
</div>
</div>
diff --git a/ui/src/components/CustomSelect.svelte b/ui/src/components/CustomSelect.svelte
new file mode 100644
index 0000000..2e89c75
--- /dev/null
+++ b/ui/src/components/CustomSelect.svelte
@@ -0,0 +1,136 @@
+<script lang="ts">
+ import { ChevronDown, Check } from 'lucide-svelte';
+
+ interface Option {
+ value: string;
+ label: string;
+ disabled?: boolean;
+ }
+
+ interface Props {
+ options: Option[];
+ value: string;
+ placeholder?: string;
+ disabled?: boolean;
+ class?: string;
+ onchange?: (value: string) => void;
+ }
+
+ let {
+ options,
+ value = $bindable(),
+ placeholder = "Select...",
+ disabled = false,
+ class: className = "",
+ onchange
+ }: Props = $props();
+
+ let isOpen = $state(false);
+ let containerRef: HTMLDivElement;
+
+ let selectedOption = $derived(options.find(o => o.value === value));
+
+ function toggle() {
+ if (!disabled) {
+ isOpen = !isOpen;
+ }
+ }
+
+ function select(option: Option) {
+ if (option.disabled) return;
+ value = option.value;
+ isOpen = false;
+ onchange?.(option.value);
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (disabled) return;
+
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ toggle();
+ } else if (e.key === 'Escape') {
+ isOpen = false;
+ } else if (e.key === 'ArrowDown' && isOpen) {
+ e.preventDefault();
+ const currentIndex = options.findIndex(o => o.value === value);
+ const nextIndex = Math.min(currentIndex + 1, options.length - 1);
+ if (!options[nextIndex].disabled) {
+ value = options[nextIndex].value;
+ }
+ } else if (e.key === 'ArrowUp' && isOpen) {
+ e.preventDefault();
+ const currentIndex = options.findIndex(o => o.value === value);
+ const prevIndex = Math.max(currentIndex - 1, 0);
+ if (!options[prevIndex].disabled) {
+ value = options[prevIndex].value;
+ }
+ }
+ }
+
+ function handleClickOutside(e: MouseEvent) {
+ if (containerRef && !containerRef.contains(e.target as Node)) {
+ isOpen = false;
+ }
+ }
+
+ $effect(() => {
+ if (isOpen) {
+ document.addEventListener('click', handleClickOutside);
+ return () => document.removeEventListener('click', handleClickOutside);
+ }
+ });
+</script>
+
+<div
+ bind:this={containerRef}
+ class="relative {className}"
+>
+ <!-- Trigger Button -->
+ <button
+ type="button"
+ onclick={toggle}
+ onkeydown={handleKeydown}
+ {disabled}
+ class="w-full flex items-center justify-between gap-2 px-3 py-2 pr-8 text-left
+ bg-zinc-900 border border-zinc-700 rounded-md text-sm text-zinc-200
+ hover:border-zinc-600 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+ transition-colors cursor-pointer outline-none
+ disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-700"
+ >
+ <span class="truncate {!selectedOption ? 'text-zinc-500' : ''}">
+ {selectedOption?.label || placeholder}
+ </span>
+ <ChevronDown
+ size={14}
+ class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}"
+ />
+ </button>
+
+ <!-- Dropdown Menu -->
+ {#if isOpen}
+ <div
+ class="absolute z-50 w-full mt-1 py-1 bg-zinc-900 border border-zinc-700 rounded-md shadow-xl
+ max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
+ >
+ {#each options as option}
+ <button
+ type="button"
+ onclick={() => select(option)}
+ disabled={option.disabled}
+ class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
+ transition-colors outline-none
+ {option.value === value
+ ? 'bg-indigo-600 text-white'
+ : 'text-zinc-300 hover:bg-zinc-800'}
+ {option.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}"
+ >
+ <span class="truncate">{option.label}</span>
+ {#if option.value === value}
+ <Check size={14} class="shrink-0 ml-2" />
+ {/if}
+ </button>
+ {/each}
+ </div>
+ {/if}
+</div>
diff --git a/ui/src/components/HomeView.svelte b/ui/src/components/HomeView.svelte
index e876c14..7bb7e44 100644
--- a/ui/src/components/HomeView.svelte
+++ b/ui/src/components/HomeView.svelte
@@ -1,26 +1,205 @@
<script lang="ts">
- // No script needed currently, just static markup mostly
+ import { onMount } from 'svelte';
+ import { gameState } from '../stores/game.svelte';
+ import { releasesState } from '../stores/releases.svelte';
+ import { Calendar, ExternalLink } from 'lucide-svelte';
+
+ type Props = {
+ mouseX: number;
+ mouseY: number;
+ };
+ let { mouseX = 0, mouseY = 0 }: Props = $props();
+
+ onMount(() => {
+ releasesState.loadReleases();
+ });
+
+ function formatDate(dateString: string) {
+ return new Date(dateString).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ });
+ }
+
+ function escapeHtml(unsafe: string) {
+ return unsafe
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#039;");
+ }
+
+ // Enhanced markdown parser with Emoji and GitHub specific features
+ function formatBody(body: string) {
+ if (!body) return '';
+
+ // Escape HTML first to prevent XSS
+ let processed = escapeHtml(body);
+
+ // Emoji map (common GitHub emojis)
+ const emojiMap: Record<string, string> = {
+ ':tada:': '🎉', ':sparkles:': '✨', ':bug:': '🐛', ':memo:': '📝',
+ ':rocket:': '🚀', ':white_check_mark:': '✅', ':construction:': '🚧',
+ ':recycle:': 'â™ģī¸', ':wrench:': '🔧', ':package:': 'đŸ“Ļ',
+ ':arrow_up:': 'âŦ†ī¸', ':arrow_down:': 'âŦ‡ī¸', ':warning:': 'âš ī¸',
+ ':fire:': 'đŸ”Ĩ', ':heart:': 'â¤ī¸', ':star:': '⭐', ':zap:': '⚡',
+ ':art:': '🎨', ':lipstick:': '💄', ':globe_with_meridians:': '🌐'
+ };
+
+ // Replace emojis
+ processed = processed.replace(/:[a-z0-9_]+:/g, (match) => emojiMap[match] || match);
+
+ // GitHub commit hash linking (simple version for 7-40 hex chars inside backticks)
+ processed = processed.replace(/`([0-9a-f]{7,40})`/g, (match, hash) => {
+ return `<a href="https://github.com/HsiangNianian/DropOut/commit/${hash}" target="_blank" class="text-emerald-500 hover:underline font-mono bg-emerald-500/10 px-1 rounded text-[10px] py-0.5 transition-colors border border-emerald-500/20 hover:border-emerald-500/50">${hash.substring(0, 7)}</a>`;
+ });
+
+ // Auto-link users (@user)
+ processed = processed.replace(/@([a-zA-Z0-9-]+)/g, '<a href="https://github.com/$1" target="_blank" class="text-zinc-300 hover:text-white hover:underline font-medium">@$1</a>');
+
+ return processed.split('\n').map(line => {
+ line = line.trim();
+
+ // Formatting helper
+ const formatLine = (text: string) => text
+ .replace(/\*\*(.*?)\*\*/g, '<strong class="text-zinc-200">$1</strong>')
+ .replace(/`([^`]+)`/g, '<code class="bg-zinc-800 px-1 py-0.5 rounded text-xs text-zinc-300 font-mono border border-white/5 break-all whitespace-normal">$1</code>')
+ .replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" class="text-indigo-400 hover:text-indigo-300 hover:underline decoration-indigo-400/30 break-all">$1</a>');
+
+ // Lists
+ if (line.startsWith('- ') || line.startsWith('* ')) {
+ return `<li class="ml-4 list-disc marker:text-zinc-600 mb-1 pl-1 text-zinc-400">${formatLine(line.substring(2))}</li>`;
+ }
+
+ // Headers
+ if (line.startsWith('##')) {
+ return `<h3 class="text-sm font-bold mt-6 mb-3 text-zinc-100 flex items-center gap-2 border-b border-white/5 pb-2 uppercase tracking-wide">${line.replace(/^#+\s+/, '')}</h3>`;
+ }
+ if (line.startsWith('#')) {
+ return `<h3 class="text-base font-bold mt-6 mb-3 text-white">${line.replace(/^#+\s+/, '')}</h3>`;
+ }
+
+ // Blockquotes
+ if (line.startsWith('> ')) {
+ return `<blockquote class="border-l-2 border-zinc-700 pl-4 py-1 my-2 text-zinc-500 italic bg-white/5 rounded-r-sm">${formatLine(line.substring(2))}</blockquote>`;
+ }
+
+ // Empty lines
+ if (line === '') return '<div class="h-2"></div>';
+
+ // Paragraphs
+ return `<p class="mb-1.5 leading-relaxed">${formatLine(line)}</p>`;
+ }).join('');
+ }
</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 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 class="absolute inset-0 z-0 overflow-hidden pointer-events-none">
+ <!-- Fixed Background -->
+ <div class="absolute inset-0 bg-gradient-to-t from-[#09090b] via-[#09090b]/60 to-transparent"></div>
+</div>
+
+<!-- Scrollable Container -->
+<div class="relative z-10 h-full {releasesState.isLoading ? 'overflow-hidden' : 'overflow-y-auto custom-scrollbar scroll-smooth'}">
+
+ <!-- Hero Section (Full Height) -->
+ <div class="min-h-full flex flex-col justify-end p-12 pb-32">
+ <!-- 3D Floating Hero Text -->
+ <div
+ class="transition-transform duration-200 ease-out origin-bottom-left"
+ style:transform={`perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`}
+ >
+ <div class="flex items-center gap-3 mb-6">
+ <div class="h-px w-12 bg-white/50"></div>
+ <span class="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">Launcher Active</span>
+ </div>
+
+ <h1
+ class="text-8xl font-black tracking-tighter text-white mb-6 leading-none"
+ >
+ MINECRAFT
+ </h1>
+
+ <div class="flex items-center gap-4">
+ <div
+ class="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm"
+ >
+ Java Edition
+ </div>
+ <div class="h-4 w-px bg-white/20"></div>
+ <div class="text-xl font-light text-zinc-400">
+ Latest Release <span class="text-white font-medium">{gameState.latestRelease?.id || '...'}</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Action Area -->
+ <div class="mt-8 flex gap-4">
+ <div class="text-zinc-500 text-sm font-mono">
+ > Ready to launch session.
+ </div>
+ </div>
+
+ <!-- Scroll Hint -->
+ {#if !releasesState.isLoading && releasesState.releases.length > 0}
+ <div class="absolute bottom-10 left-12 animate-bounce text-zinc-600 flex flex-col items-center gap-2 w-fit opacity-50 hover:opacity-100 transition-opacity">
+ <span class="text-[10px] font-mono uppercase tracking-widest">Scroll for Updates</span>
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Changelog / Updates Section -->
+ <div class="bg-[#09090b] relative z-20 px-12 pb-24 pt-12 border-t border-white/5 min-h-[50vh]">
+ <div class="max-w-4xl">
+ <h2 class="text-2xl font-bold text-white mb-10 flex items-center gap-3">
+ <span class="w-1.5 h-8 bg-emerald-500 rounded-sm"></span>
+ LATEST UPDATES
+ </h2>
+
+ {#if releasesState.isLoading}
+ <div class="flex flex-col gap-8">
+ {#each Array(3) as _}
+ <div class="h-48 bg-white/5 rounded-sm animate-pulse border border-white/5"></div>
+ {/each}
+ </div>
+ {:else if releasesState.error}
+ <div class="p-6 border border-red-500/20 bg-red-500/10 text-red-400 rounded-sm">
+ Failed to load updates: {releasesState.error}
+ </div>
+ {:else if releasesState.releases.length === 0}
+ <div class="text-zinc-500 italic">No releases found.</div>
+ {:else}
+ <div class="space-y-12">
+ {#each releasesState.releases as release}
+ <div class="group relative pl-8 border-l border-white/10 pb-4 last:pb-0 last:border-l-0">
+ <!-- Timeline Dot -->
+ <div class="absolute -left-[5px] top-1.5 w-2.5 h-2.5 rounded-full bg-zinc-800 border border-zinc-600 group-hover:bg-emerald-500 group-hover:border-emerald-400 transition-colors"></div>
+
+ <div class="flex items-baseline gap-4 mb-3">
+ <h3 class="text-xl font-bold text-white group-hover:text-emerald-400 transition-colors">
+ {release.name || release.tag_name}
+ </h3>
+ <div class="text-xs font-mono text-zinc-500 flex items-center gap-2">
+ <Calendar size={12} />
+ {formatDate(release.published_at)}
+ </div>
+ </div>
+
+ <div class="bg-zinc-900/50 border border-white/5 hover:border-white/10 rounded-sm p-6 text-zinc-400 text-sm leading-relaxed transition-colors overflow-hidden">
+ <div class="prose prose-invert prose-sm max-w-none prose-p:text-zinc-400 prose-headings:text-zinc-200 prose-ul:my-2 prose-li:my-0 break-words whitespace-normal">
+ {@html formatBody(release.body)}
+ </div>
+ </div>
+
+ <a href={release.html_url} target="_blank" class="inline-flex items-center gap-2 mt-3 text-[10px] font-bold uppercase tracking-wider text-zinc-600 hover:text-white transition-colors">
+ View full changelog on GitHub <ExternalLink size={10} />
+ </a>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ </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..cb949c5
--- /dev/null
+++ b/ui/src/components/ModLoaderSelector.svelte
@@ -0,0 +1,332 @@
+<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
+ import type {
+ FabricGameVersion,
+ FabricLoaderVersion,
+ ForgeVersion,
+ ModLoaderType,
+ } from "../types";
+ import { Loader2, Download, AlertCircle, Check, ChevronDown } from 'lucide-svelte';
+
+ 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("");
+ let isFabricDropdownOpen = $state(false);
+
+ // Forge state
+ let forgeVersions = $state<ForgeVersion[]>([]);
+ let selectedForgeVersion = $state("");
+ let isForgeDropdownOpen = $state(false);
+
+ let fabricDropdownRef = $state<HTMLDivElement | null>(null);
+ let forgeDropdownRef = $state<HTMLDivElement | null>(null);
+
+ // 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();
+ }
+ }
+
+ function handleFabricClickOutside(e: MouseEvent) {
+ if (fabricDropdownRef && !fabricDropdownRef.contains(e.target as Node)) {
+ isFabricDropdownOpen = false;
+ }
+ }
+
+ function handleForgeClickOutside(e: MouseEvent) {
+ if (forgeDropdownRef && !forgeDropdownRef.contains(e.target as Node)) {
+ isForgeDropdownOpen = false;
+ }
+ }
+
+ $effect(() => {
+ if (isFabricDropdownOpen) {
+ document.addEventListener('click', handleFabricClickOutside);
+ return () => document.removeEventListener('click', handleFabricClickOutside);
+ }
+ });
+
+ $effect(() => {
+ if (isForgeDropdownOpen) {
+ document.addEventListener('click', handleForgeClickOutside);
+ return () => document.removeEventListener('click', handleForgeClickOutside);
+ }
+ });
+
+ let selectedFabricLabel = $derived(
+ fabricLoaders.find(l => l.version === selectedFabricLoader)
+ ? `${selectedFabricLoader}${fabricLoaders.find(l => l.version === selectedFabricLoader)?.stable ? ' (stable)' : ''}`
+ : selectedFabricLoader || 'Select version'
+ );
+
+ let selectedForgeLabel = $derived(
+ forgeVersions.find(v => v.version === selectedForgeVersion)
+ ? `${selectedForgeVersion}${forgeVersions.find(v => v.version === selectedForgeVersion)?.recommended ? ' (Recommended)' : ''}`
+ : selectedForgeVersion || 'Select version'
+ );
+</script>
+
+<div class="space-y-4">
+ <div class="flex items-center justify-between">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-zinc-500">Loader Type</h3>
+ </div>
+
+ <!-- Loader Type Tabs - Simple Clean Style -->
+ <div class="flex p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-sm border border-zinc-200 dark:border-white/5">
+ {#each ['vanilla', 'fabric', 'forge'] as loader}
+ <button
+ class="flex-1 px-3 py-2 rounded-sm text-sm font-medium transition-all duration-200 capitalize
+ {selectedLoader === loader
+ ? 'bg-white dark:bg-white/10 text-black dark:text-white shadow-sm'
+ : 'text-zinc-500 dark:text-zinc-500 hover:text-black dark:hover:text-white'}"
+ onclick={() => onLoaderChange(loader as ModLoaderType)}
+ >
+ {loader}
+ </button>
+ {/each}
+ </div>
+
+ <!-- Content Area -->
+ <div class="min-h-[100px] flex flex-col justify-center">
+ {#if selectedLoader === "vanilla"}
+ <div class="text-center p-6 border border-dashed border-zinc-200 dark:border-white/10 rounded-sm text-zinc-500 text-sm">
+ Standard Minecraft experience. No modifications.
+ </div>
+
+ {:else if !selectedGameVersion}
+ <div class="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 text-amber-700 dark:text-amber-200 rounded-sm text-sm">
+ <AlertCircle size={16} />
+ <span>Please select a base Minecraft version first.</span>
+ </div>
+
+ {:else if isLoading}
+ <div class="flex flex-col items-center gap-3 text-sm text-zinc-500 py-6">
+ <Loader2 class="animate-spin" size={20} />
+ <span>Fetching {selectedLoader} manifest...</span>
+ </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-sm 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-[10px] uppercase font-bold text-zinc-500 mb-2"
+ >Loader Version</label
+ >
+ <!-- Custom Fabric Dropdown -->
+ <div class="relative" bind:this={fabricDropdownRef}>
+ <button
+ type="button"
+ onclick={() => isFabricDropdownOpen = !isFabricDropdownOpen}
+ class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
+ bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md
+ text-sm text-gray-900 dark:text-white
+ hover:border-zinc-400 dark:hover:border-zinc-600
+ focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+ transition-colors cursor-pointer outline-none"
+ >
+ <span class="truncate">{selectedFabricLabel}</span>
+ <ChevronDown
+ size={14}
+ class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isFabricDropdownOpen ? 'rotate-180' : ''}"
+ />
+ </button>
+
+ {#if isFabricDropdownOpen}
+ <div
+ class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl
+ max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
+ >
+ {#each fabricLoaders as loader}
+ <button
+ type="button"
+ onclick={() => { selectedFabricLoader = loader.version; isFabricDropdownOpen = false; }}
+ class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
+ transition-colors outline-none cursor-pointer
+ {loader.version === selectedFabricLoader
+ ? 'bg-indigo-600 text-white'
+ : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}"
+ >
+ <span class="truncate">{loader.version} {loader.stable ? "(stable)" : ""}</span>
+ {#if loader.version === selectedFabricLoader}
+ <Check size={14} class="shrink-0 ml-2" />
+ {/if}
+ </button>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <button
+ class="w-full bg-black dark:bg-white text-white dark:text-black py-2.5 px-4 rounded-sm font-bold text-sm transition-all hover:opacity-90 flex items-center justify-center gap-2"
+ onclick={installModLoader}
+ disabled={isLoading || !selectedFabricLoader}
+ >
+ <Download size={16} />
+ Install Fabric
+ </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-zinc-500 italic">
+ No Forge versions available for {selectedGameVersion}
+ </div>
+ {:else}
+ <div>
+ <label for="forge-version-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
+ >Forge Version</label
+ >
+ <!-- Custom Forge Dropdown -->
+ <div class="relative" bind:this={forgeDropdownRef}>
+ <button
+ type="button"
+ onclick={() => isForgeDropdownOpen = !isForgeDropdownOpen}
+ class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
+ bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md
+ text-sm text-gray-900 dark:text-white
+ hover:border-zinc-400 dark:hover:border-zinc-600
+ focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+ transition-colors cursor-pointer outline-none"
+ >
+ <span class="truncate">{selectedForgeLabel}</span>
+ <ChevronDown
+ size={14}
+ class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isForgeDropdownOpen ? 'rotate-180' : ''}"
+ />
+ </button>
+
+ {#if isForgeDropdownOpen}
+ <div
+ class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl
+ max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
+ >
+ {#each forgeVersions as version}
+ <button
+ type="button"
+ onclick={() => { selectedForgeVersion = version.version; isForgeDropdownOpen = false; }}
+ class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
+ transition-colors outline-none cursor-pointer
+ {version.version === selectedForgeVersion
+ ? 'bg-indigo-600 text-white'
+ : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}"
+ >
+ <span class="truncate">{version.version} {version.recommended ? "(Recommended)" : ""}</span>
+ {#if version.version === selectedForgeVersion}
+ <Check size={14} class="shrink-0 ml-2" />
+ {/if}
+ </button>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <button
+ class="w-full bg-black dark:bg-white text-white dark:text-black py-2.5 px-4 rounded-sm font-bold text-sm transition-all hover:opacity-90 flex items-center justify-center gap-2"
+ onclick={installModLoader}
+ disabled={isLoading || !selectedForgeVersion}
+ >
+ <Download size={16} />
+ Install Forge
+ </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 801970b..76d441b 100644
--- a/ui/src/components/SettingsView.svelte
+++ b/ui/src/components/SettingsView.svelte
@@ -1,153 +1,685 @@
<script lang="ts">
+ import { open } from "@tauri-apps/plugin-dialog";
import { settingsState } from "../stores/settings.svelte";
+ import CustomSelect from "./CustomSelect.svelte";
+
+ // Use convertFileSrc directly from settingsState.backgroundUrl for cleaner approach
+ // or use the imported one if passing raw path.
+ import { convertFileSrc } from "@tauri-apps/api/core";
+
+ const effectOptions = [
+ { value: "saturn", label: "Saturn" },
+ { value: "constellation", label: "Network (Constellation)" }
+ ];
+
+ const logServiceOptions = [
+ { value: "paste.rs", label: "paste.rs (Free, No Account)" },
+ { value: "pastebin.com", label: "pastebin.com (Requires API Key)" }
+ ];
+
+ 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-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-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"
+ onerror={(e) => {
+ console.error("Failed to load image:", settingsState.settings.custom_background_path, e);
+ // e.currentTarget.style.display = 'none';
+ }}
+ />
+ {: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>
+ <CustomSelect
+ options={effectOptions}
+ bind:value={settingsState.settings.active_effect}
+ onchange={() => settingsState.saveSettings()}
+ class="w-52"
+ />
+ </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-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-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>
+ <button
+ onclick={() => settingsState.openJavaDownloadModal()}
+ class="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-xl transition-colors whitespace-nowrap text-sm font-medium"
+ >
+ Download Java
+ </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="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <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="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <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>
<!-- Download Settings -->
- <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"
- >Download Settings</h3>
- <div>
- <label for="download-threads" class="block text-xs text-zinc-500 mb-1"
- >Concurrent Download Threads</label
- >
- <input
- id="download-threads"
- bind:value={settingsState.settings.download_threads}
- type="number"
- min="1"
- max="128"
- step="1"
- class="bg-zinc-950 text-white w-full p-3 rounded border border-zinc-700 focus:border-indigo-500 outline-none"
- />
- <p class="text-xs text-zinc-500 mt-2">
- Number of concurrent download threads (1-128). Higher values increase download speed but use more bandwidth and system resources. Default: 32
- </p>
- </div>
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <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">
+ <!-- Debug / Logs -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ Debug & Logs
+ </h3>
+ <div class="space-y-4">
+ <div>
+ <label for="log-service" class="block text-sm font-medium text-white/70 mb-2">Log Upload Service</label>
+ <CustomSelect
+ options={logServiceOptions}
+ bind:value={settingsState.settings.log_upload_service}
+ class="w-full"
+ />
+ </div>
+
+ {#if settingsState.settings.log_upload_service === 'pastebin.com'}
+ <div>
+ <label for="pastebin-key" class="block text-sm font-medium text-white/70 mb-2">Pastebin Dev API Key</label>
+ <input
+ id="pastebin-key"
+ type="password"
+ bind:value={settingsState.settings.pastebin_api_key}
+ placeholder="Enter your API Key"
+ class="dark:bg-zinc-900 bg-white dark:text-white text-black w-full px-4 py-3 rounded-xl border dark:border-zinc-700 border-gray-300 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 outline-none transition-colors placeholder:text-zinc-500"
+ />
+ <p class="text-xs text-white/30 mt-2">
+ Get your API key from <a href="https://pastebin.com/doc_api#1" target="_blank" class="text-indigo-400 hover:underline">Pastebin API Documentation</a>.
+ </p>
+ </div>
+ {/if}
+ </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>
</div>
</div>
</div>
+
+<!-- Java Download Modal -->
+{#if settingsState.showJavaDownloadModal}
+ <div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70">
+ <div class="bg-zinc-900 rounded-2xl border border-white/10 shadow-2xl w-[900px] max-w-[95vw] h-[600px] max-h-[90vh] flex flex-col overflow-hidden">
+ <!-- Header -->
+ <div class="flex items-center justify-between p-5 border-b border-white/10">
+ <h3 class="text-xl font-bold text-white">Download Java</h3>
+ <button
+ aria-label="Close dialog"
+ onclick={() => settingsState.closeJavaDownloadModal()}
+ disabled={settingsState.isDownloadingJava}
+ class="text-white/40 hover:text-white/80 disabled:opacity-50 transition-colors p-1"
+ >
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
+ </svg>
+ </button>
+ </div>
+
+ <!-- Main Content Area -->
+ <div class="flex flex-1 overflow-hidden">
+ <!-- Left Sidebar: Sources -->
+ <div class="w-40 border-r border-white/10 p-3 flex flex-col gap-1">
+ <span class="text-[10px] font-bold uppercase tracking-widest text-white/30 px-2 mb-2">Sources</span>
+
+ <button
+ disabled
+ class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50"
+ >
+ <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">M</div>
+ Mojang
+ </button>
+
+ <button
+ class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm bg-indigo-500/20 border border-indigo-500/40 text-white"
+ >
+ <div class="w-5 h-5 rounded bg-indigo-500 flex items-center justify-center text-[10px] font-bold">A</div>
+ Adoptium
+ </button>
+
+ <button
+ disabled
+ class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50"
+ >
+ <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">Z</div>
+ Azul Zulu
+ </button>
+ </div>
+
+ <!-- Center: Version Selection -->
+ <div class="flex-1 flex flex-col overflow-hidden">
+ <!-- Toolbar -->
+ <div class="flex items-center gap-3 p-4 border-b border-white/5">
+ <!-- Search -->
+ <div class="relative flex-1 max-w-xs">
+ <input
+ type="text"
+ bind:value={settingsState.searchQuery}
+ placeholder="Search versions..."
+ class="w-full bg-black/30 text-white text-sm px-4 py-2 pl-9 rounded-lg border border-white/10 focus:border-indigo-500/50 outline-none"
+ />
+ <svg class="absolute left-3 top-2.5 w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
+ </svg>
+ </div>
+
+ <!-- Recommended Filter -->
+ <label class="flex items-center gap-2 text-sm text-white/60 cursor-pointer select-none">
+ <input
+ type="checkbox"
+ bind:checked={settingsState.showOnlyRecommended}
+ class="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/30"
+ />
+ LTS Only
+ </label>
+
+ <!-- Image Type Toggle -->
+ <div class="flex items-center bg-black/30 rounded-lg p-0.5 border border-white/10">
+ <button
+ onclick={() => settingsState.selectedImageType = "jre"}
+ class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jre' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}"
+ >
+ JRE
+ </button>
+ <button
+ onclick={() => settingsState.selectedImageType = "jdk"}
+ class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jdk' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}"
+ >
+ JDK
+ </button>
+ </div>
+ </div>
+
+ <!-- Loading State -->
+ {#if settingsState.isLoadingCatalog}
+ <div class="flex-1 flex items-center justify-center text-white/50">
+ <div class="flex flex-col items-center gap-3">
+ <div class="w-8 h-8 border-2 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin"></div>
+ <span class="text-sm">Loading Java versions...</span>
+ </div>
+ </div>
+ {:else if settingsState.catalogError}
+ <div class="flex-1 flex items-center justify-center text-red-400">
+ <div class="flex flex-col items-center gap-3 text-center px-8">
+ <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
+ </svg>
+ <span class="text-sm">{settingsState.catalogError}</span>
+ <button
+ onclick={() => settingsState.refreshCatalog()}
+ class="mt-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-sm text-white transition-colors"
+ >
+ Retry
+ </button>
+ </div>
+ </div>
+ {:else}
+ <!-- Version List -->
+ <div class="flex-1 overflow-auto p-4">
+ <div class="space-y-2">
+ {#each settingsState.availableMajorVersions as version}
+ {@const isLts = settingsState.javaCatalog?.lts_versions.includes(version)}
+ {@const isSelected = settingsState.selectedMajorVersion === version}
+ {@const releaseInfo = settingsState.javaCatalog?.releases.find(r => r.major_version === version && r.image_type === settingsState.selectedImageType)}
+ {@const isAvailable = releaseInfo?.is_available ?? false}
+ {@const installStatus = releaseInfo ? settingsState.getInstallStatus(releaseInfo) : 'download'}
+
+ <button
+ onclick={() => settingsState.selectMajorVersion(version)}
+ disabled={!isAvailable}
+ class="w-full flex items-center gap-4 p-3 rounded-xl border transition-all text-left
+ {isSelected
+ ? 'bg-indigo-500/20 border-indigo-500/50 ring-2 ring-indigo-500/30'
+ : isAvailable
+ ? 'bg-black/20 border-white/10 hover:bg-white/5 hover:border-white/20'
+ : 'bg-black/10 border-white/5 opacity-40 cursor-not-allowed'}"
+ >
+ <!-- Version Number -->
+ <div class="w-14 text-center">
+ <span class="text-xl font-bold {isSelected ? 'text-white' : 'text-white/80'}">{version}</span>
+ </div>
+
+ <!-- Version Details -->
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center gap-2">
+ <span class="text-sm text-white/70 font-mono truncate">{releaseInfo?.version ?? '--'}</span>
+ {#if isLts}
+ <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded uppercase shrink-0">LTS</span>
+ {/if}
+ </div>
+ {#if releaseInfo}
+ <div class="text-[10px] text-white/40 truncate mt-0.5">
+ {releaseInfo.release_name} â€ĸ {settingsState.formatBytes(releaseInfo.file_size)}
+ </div>
+ {/if}
+ </div>
+
+ <!-- Install Status Badge -->
+ <div class="shrink-0">
+ {#if installStatus === 'installed'}
+ <span class="px-2 py-1 bg-emerald-500/20 text-emerald-400 text-[10px] font-bold rounded uppercase">Installed</span>
+ {:else if isAvailable}
+ <span class="px-2 py-1 bg-white/10 text-white/50 text-[10px] font-medium rounded">Download</span>
+ {:else}
+ <span class="px-2 py-1 bg-red-500/10 text-red-400/60 text-[10px] font-medium rounded">N/A</span>
+ {/if}
+ </div>
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Right Sidebar: Details -->
+ <div class="w-64 border-l border-white/10 flex flex-col">
+ <div class="p-4 border-b border-white/5">
+ <span class="text-[10px] font-bold uppercase tracking-widest text-white/30">Details</span>
+ </div>
+
+ {#if settingsState.selectedRelease}
+ <div class="flex-1 p-4 space-y-4 overflow-auto">
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Version</div>
+ <div class="text-sm text-white font-mono">{settingsState.selectedRelease.version}</div>
+ </div>
+
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Name</div>
+ <div class="text-sm text-white">{settingsState.selectedRelease.release_name}</div>
+ </div>
+
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Date</div>
+ <div class="text-sm text-white">{settingsState.formatDate(settingsState.selectedRelease.release_date)}</div>
+ </div>
+
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Size</div>
+ <div class="text-sm text-white">{settingsState.formatBytes(settingsState.selectedRelease.file_size)}</div>
+ </div>
+
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Type</div>
+ <div class="flex items-center gap-2">
+ <span class="text-sm text-white uppercase">{settingsState.selectedRelease.image_type}</span>
+ {#if settingsState.selectedRelease.is_lts}
+ <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded">LTS</span>
+ {/if}
+ </div>
+ </div>
+
+ <div>
+ <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Architecture</div>
+ <div class="text-sm text-white">{settingsState.selectedRelease.architecture}</div>
+ </div>
+
+ {#if !settingsState.selectedRelease.is_available}
+ <div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
+ <div class="text-xs text-red-400">Not available for your platform</div>
+ </div>
+ {/if}
+ </div>
+ {:else}
+ <div class="flex-1 flex items-center justify-center text-white/30 text-sm p-4 text-center">
+ Select a Java version to view details
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <!-- Download Progress (MC Style) -->
+ {#if settingsState.isDownloadingJava && settingsState.downloadProgress}
+ <div class="border-t border-white/10 p-4 bg-zinc-900/90">
+ <div class="flex items-center justify-between mb-2">
+ <h3 class="text-white font-bold text-sm">Downloading Java</h3>
+ <span class="text-xs text-zinc-400">{settingsState.downloadProgress.status}</span>
+ </div>
+
+ <!-- Progress Bar -->
+ <div class="mb-2">
+ <div class="flex justify-between text-[10px] text-zinc-400 mb-1">
+ <span>{settingsState.downloadProgress.file_name}</span>
+ <span>{Math.round(settingsState.downloadProgress.percentage)}%</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: {settingsState.downloadProgress.percentage}%"
+ ></div>
+ </div>
+ </div>
+
+ <!-- Speed & Stats -->
+ <div class="flex justify-between text-[10px] text-zinc-500 font-mono">
+ <span>
+ {settingsState.formatBytes(settingsState.downloadProgress.speed_bytes_per_sec)}/s ¡
+ ETA: {settingsState.formatTime(settingsState.downloadProgress.eta_seconds)}
+ </span>
+ <span>
+ {settingsState.formatBytes(settingsState.downloadProgress.downloaded_bytes)} /
+ {settingsState.formatBytes(settingsState.downloadProgress.total_bytes)}
+ </span>
+ </div>
+ </div>
+ {/if}
+
+ <!-- Pending Downloads Alert -->
+ {#if settingsState.pendingDownloads.length > 0 && !settingsState.isDownloadingJava}
+ <div class="border-t border-amber-500/30 p-4 bg-amber-500/10">
+ <div class="flex items-center justify-between">
+ <div class="flex items-center gap-3">
+ <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
+ </svg>
+ <span class="text-sm text-amber-200">
+ {settingsState.pendingDownloads.length} pending download(s) can be resumed
+ </span>
+ </div>
+ <button
+ onclick={() => settingsState.resumeDownloads()}
+ class="px-4 py-2 bg-amber-500/20 hover:bg-amber-500/30 text-amber-200 rounded-lg text-sm font-medium transition-colors"
+ >
+ Resume All
+ </button>
+ </div>
+ </div>
+ {/if}
+
+ <!-- Footer Actions -->
+ <div class="flex items-center justify-between p-4 border-t border-white/10 bg-black/20">
+ <button
+ onclick={() => settingsState.refreshCatalog()}
+ disabled={settingsState.isLoadingCatalog || settingsState.isDownloadingJava}
+ class="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 disabled:opacity-50 text-white/70 rounded-lg text-sm transition-colors"
+ >
+ <svg class="w-4 h-4 {settingsState.isLoadingCatalog ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
+ </svg>
+ Refresh
+ </button>
+
+ <div class="flex gap-3">
+ {#if settingsState.isDownloadingJava}
+ <button
+ onclick={() => settingsState.cancelDownload()}
+ class="px-5 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-colors"
+ >
+ Cancel Download
+ </button>
+ {:else}
+ {@const isInstalled = settingsState.selectedRelease ? settingsState.getInstallStatus(settingsState.selectedRelease) === 'installed' : false}
+ <button
+ onclick={() => settingsState.closeJavaDownloadModal()}
+ class="px-5 py-2.5 bg-white/10 hover:bg-white/20 text-white rounded-lg text-sm font-medium transition-colors"
+ >
+ Close
+ </button>
+ <button
+ onclick={() => settingsState.downloadJava()}
+ disabled={!settingsState.selectedRelease?.is_available || settingsState.isLoadingCatalog || isInstalled}
+ class="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
+ >
+ {isInstalled ? 'Already Installed' : 'Download & Install'}
+ </button>
+ {/if}
+ </div>
+ </div>
+ </div>
+ </div>
+{/if}
diff --git a/ui/src/components/Sidebar.svelte b/ui/src/components/Sidebar.svelte
index a4f4e35..1d7cc16 100644
--- a/ui/src/components/Sidebar.svelte
+++ b/ui/src/components/Sidebar.svelte
@@ -1,66 +1,89 @@
<script lang="ts">
import { uiState } from '../stores/ui.svelte';
+ import { Home, Package, Settings } from 'lucide-svelte';
</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-[#09090b] bg-white border-r dark:border-white/10 border-gray-200 flex flex-col items-center lg:items-start transition-all duration-300 shrink-0 py-6 z-20"
>
+ <!-- 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-6 mb-6"
>
- <!-- Icon Logo (Visible on small) -->
- <div
- class="lg:hidden text-2xl font-black bg-clip-text text-transparent bg-gradient-to-tr from-indigo-400 to-purple-400"
- >
- D
+ <!-- Icon Logo (Small) -->
+ <div class="lg:hidden text-black dark:text-white">
+ <svg width="32" height="32" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M25 25 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+ <path d="M25 75 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+ <path d="M50 50 L75 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+ <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
+ <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
+ <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
+ </svg>
</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:flex items-center gap-3 font-bold text-xl tracking-tighter dark:text-white text-black"
>
- DROP<span class="text-white">OUT</span>
+ <!-- Neural Network Dropout Logo -->
+ <svg width="42" height="42" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" class="shrink-0">
+ <!-- Lines -->
+ <path d="M25 25 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+ <path d="M25 75 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+ <path d="M50 50 L75 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
+
+ <!-- Input Layer (Left) -->
+ <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
+
+ <!-- Hidden Layer (Middle) - Dropout visualization -->
+ <!-- Dropped units (dashed) -->
+ <circle cx="50" cy="25" r="7" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" class="opacity-30" />
+ <circle cx="50" cy="75" r="7" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" class="opacity-30" />
+ <!-- Active unit -->
+ <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
+
+ <!-- Output Layer (Right) -->
+ <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
+ </svg>
+
+ <span>DROPOUT</span>
</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-1 px-3">
+ <!-- Nav Item Helper -->
+ {#snippet navItem(view, Icon, label)}
+ <button
+ class="group flex items-center lg:gap-3 justify-center lg:justify-start w-full px-0 lg:px-4 py-2.5 rounded-sm transition-all duration-200 relative
+ {uiState.currentView === view
+ ? 'bg-black/5 dark:bg-white/10 dark:text-white text-black font-medium'
+ : 'dark:text-zinc-400 text-zinc-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>
+ <Icon size={20} strokeWidth={uiState.currentView === view ? 2.5 : 2} />
+ <span class="hidden lg:block text-sm relative z-10">{label}</span>
+
+ <!-- Active Indicator -->
+ {#if uiState.currentView === view}
+ <div class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-black dark:bg-white rounded-r-full hidden lg:block"></div>
+ {/if}
+ </button>
+ {/snippet}
+
+ {@render navItem('home', Home, 'Overview')}
+ {@render navItem('versions', Package, 'Versions')}
+ {@render navItem('settings', 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-6 opacity-40 hover:opacity-100 transition-opacity"
>
- <div class="text-xs text-zinc-600 font-mono">v{uiState.appVersion}</div>
+ <div class="text-[10px] font-mono text-zinc-500 uppercase tracking-wider">v{uiState.appVersion}</div>
</div>
</aside>
diff --git a/ui/src/components/StatusToast.svelte b/ui/src/components/StatusToast.svelte
index 0d68778..4c981c7 100644
--- a/ui/src/components/StatusToast.svelte
+++ b/ui/src/components/StatusToast.svelte
@@ -5,19 +5,19 @@
{#if uiState.status !== "Ready"}
{#key uiState.status}
<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"
+ 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-400 uppercase font-bold">Status</div>
+ <div class="text-xs text-zinc-500 dark: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"
+ 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">{uiState.status}</div>
- <div class="w-full bg-zinc-700/50 h-1 rounded-full overflow-hidden">
+ <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>
diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte
index 98261b8..99cc296 100644
--- a/ui/src/components/VersionsView.svelte
+++ b/ui/src/components/VersionsView.svelte
@@ -1,55 +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, ".")
);
- let filteredVersions = $derived(
- gameState.versions.filter((v) =>
- v.id.toLowerCase().includes(normalizedQuery)
- )
- );
+ // 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>
-
- <input
- type="text"
- placeholder="Search versions..."
- class="w-full p-3 mb-4 bg-zinc-800 border border-zinc-700 rounded text-white focus:outline-none focus:border-green-500 transition-colors"
- bind:value={searchQuery}
- />
-
- <div class="grid gap-2">
- {#if gameState.versions.length === 0}
- <div class="text-zinc-500">Loading versions...</div>
- {:else if filteredVersions.length === 0 && normalizedQuery.length > 0}
- <div class="text-zinc-500">No versions found matching "{searchQuery}"</div>
- {:else}
- {#each filteredVersions 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 h-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 52c935c..860952c 100644
--- a/ui/src/lib/DownloadMonitor.svelte
+++ b/ui/src/lib/DownloadMonitor.svelte
@@ -156,7 +156,7 @@
{#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>
diff --git a/ui/src/lib/GameConsole.svelte b/ui/src/lib/GameConsole.svelte
index 281dc85..bc5edbc 100644
--- a/ui/src/lib/GameConsole.svelte
+++ b/ui/src/lib/GameConsole.svelte
@@ -1,107 +1,304 @@
<script lang="ts">
- import { listen } from "@tauri-apps/api/event";
- import { onMount, onDestroy } from "svelte";
+ import { logsState, type LogEntry } from "../stores/logs.svelte";
+ import { uiState } from "../stores/ui.svelte";
+ import { save } from "@tauri-apps/plugin-dialog";
+ import { writeTextFile } from "@tauri-apps/plugin-fs";
+ import { invoke } from "@tauri-apps/api/core";
+ import { open } from "@tauri-apps/plugin-shell";
+ import { onMount, tick } from "svelte";
+ import CustomSelect from "../components/CustomSelect.svelte";
+ import { ChevronDown, Check } from 'lucide-svelte';
- export let visible = false;
-
- let logs: { type: 'stdout' | 'stderr' | 'launcher', line: string, timestamp: string }[] = [];
let consoleElement: HTMLDivElement;
- let unlistenStdout: () => void;
- let unlistenStderr: () => void;
- let unlistenLauncher: () => void;
- let unlistenGameExited: () => void;
-
- function getTimestamp(): string {
- const now = new Date();
- return now.toTimeString().split(' ')[0]; // HH:MM:SS
- }
+ let autoScroll = $state(true);
+
+ // Search & Filter
+ let searchQuery = $state("");
+ let showInfo = $state(true);
+ let showWarn = $state(true);
+ let showError = $state(true);
+ let showDebug = $state(false);
+
+ // Source filter: "all" or specific source name
+ let selectedSource = $state("all");
- onMount(async () => {
- // Listen for launcher logs (preparation, downloads, launch status)
- unlistenLauncher = await listen<string>("launcher-log", (event) => {
- addLog('launcher', event.payload);
- });
-
- // Listen for game stdout
- unlistenStdout = await listen<string>("game-stdout", (event) => {
- addLog('stdout', event.payload);
- });
-
- // Listen for game stderr
- unlistenStderr = await listen<string>("game-stderr", (event) => {
- addLog('stderr', event.payload);
- });
-
- // Listen for game exit event
- unlistenGameExited = await listen<number>("game-exited", (event) => {
- addLog('launcher', `Game process exited with code: ${event.payload}`);
- });
- });
+ // Get sorted sources for dropdown
+ let sourceOptions = $derived([
+ { value: "all", label: "All Sources" },
+ ...[...logsState.sources].sort().map(s => ({ value: s, label: s }))
+ ]);
- onDestroy(() => {
- if (unlistenLauncher) unlistenLauncher();
- if (unlistenStdout) unlistenStdout();
- if (unlistenStderr) unlistenStderr();
- if (unlistenGameExited) unlistenGameExited();
- });
+ // Derived filtered logs
+ let filteredLogs = $derived(logsState.logs.filter((log) => {
+ // Source Filter
+ if (selectedSource !== "all" && log.source !== selectedSource) return false;
- function addLog(type: 'stdout' | 'stderr' | 'launcher', line: string) {
- logs = [...logs, { type, line, timestamp: getTimestamp() }];
- if (logs.length > 2000) {
- logs = logs.slice(logs.length - 2000);
+ // Level Filter
+ if (!showInfo && log.level === "info") return false;
+ if (!showWarn && log.level === "warn") return false;
+ if (!showError && (log.level === "error" || log.level === "fatal")) return false;
+ if (!showDebug && log.level === "debug") return false;
+
+ // Search Filter
+ if (searchQuery) {
+ const q = searchQuery.toLowerCase();
+ return (
+ log.message.toLowerCase().includes(q) ||
+ log.source.toLowerCase().includes(q)
+ );
}
- // Auto-scroll
- setTimeout(() => {
- if (consoleElement) {
+ return true;
+ }));
+
+ // Auto-scroll logic
+ $effect(() => {
+ // Depend on filteredLogs length to trigger scroll
+ if (filteredLogs.length && autoScroll && consoleElement) {
+ // Use tick to wait for DOM update
+ tick().then(() => {
consoleElement.scrollTop = consoleElement.scrollHeight;
- }
- }, 0);
+ });
+ }
+ });
+
+ function handleScroll() {
+ if (!consoleElement) return;
+ const { scrollTop, scrollHeight, clientHeight } = consoleElement;
+ // If user scrolls up (more than 50px from bottom), disable auto-scroll
+ if (scrollHeight - scrollTop - clientHeight > 50) {
+ autoScroll = false;
+ } else {
+ autoScroll = true;
+ }
+ }
+
+ // Export only currently filtered logs
+ async function exportLogs() {
+ try {
+ const content = logsState.exportLogs(filteredLogs);
+ const path = await save({
+ filters: [{ name: "Log File", extensions: ["txt", "log"] }],
+ defaultPath: `dropout-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.log`,
+ });
+
+ if (path) {
+ await writeTextFile(path, content);
+ logsState.addLog("info", "Console", `Exported ${filteredLogs.length} logs to ${path}`);
+ }
+ } catch (e) {
+ console.error("Export failed", e);
+ logsState.addLog("error", "Console", `Export failed: ${e}`);
+ }
}
- function clearLogs() {
- logs = [];
+ // Upload only currently filtered logs
+ async function uploadLogs() {
+ try {
+ const content = logsState.exportLogs(filteredLogs);
+ logsState.addLog("info", "Console", `Uploading ${filteredLogs.length} logs...`);
+
+ const response = await invoke<{ url: string }>("upload_to_pastebin", { content });
+
+ logsState.addLog("info", "Console", `Logs uploaded successfully: ${response.url}`);
+ await open(response.url);
+ } catch (e) {
+ console.error("Upload failed", e);
+ logsState.addLog("error", "Console", `Upload failed: ${e}`);
+ }
}
- function exportLogs() {
- const logText = logs.map(l => `[${l.timestamp}] [${l.type.toUpperCase()}] ${l.line}`).join('\n');
- const blob = new Blob([logText], { type: 'text/plain' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `dropout-logs-${new Date().toISOString().split('T')[0]}.txt`;
- a.click();
- URL.revokeObjectURL(url);
+ function highlightText(text: string, query: string) {
+ if (!query) return text;
+ // Escape regex special chars in query
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const parts = text.split(new RegExp(`(${escaped})`, "gi"));
+ return parts.map(part =>
+ part.toLowerCase() === query.toLowerCase()
+ ? `<span class="bg-yellow-500/30 text-yellow-200 font-bold">${part}</span>`
+ : part
+ ).join("");
+ }
+
+ function getLevelColor(level: LogEntry["level"]) {
+ switch (level) {
+ case "info": return "text-blue-400";
+ case "warn": return "text-yellow-400";
+ case "error":
+ case "fatal": return "text-red-400";
+ case "debug": return "text-purple-400";
+ default: return "text-zinc-400";
+ }
+ }
+
+ function getLevelLabel(level: LogEntry["level"]) {
+ switch (level) {
+ case "info": return "INFO";
+ case "warn": return "WARN";
+ case "error": return "ERR";
+ case "fatal": return "FATAL";
+ case "debug": return "DEBUG";
+ }
+ }
+
+ function getMessageColor(log: LogEntry) {
+ if (log.level === "error" || log.level === "fatal") return "text-red-300";
+ if (log.level === "warn") return "text-yellow-200";
+ if (log.level === "debug") return "text-purple-200/70";
+ if (log.source.startsWith("Game")) return "text-emerald-100/80";
+ return "";
}
</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="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>
- <div class="flex gap-1 text-[10px]">
- <span class="px-1.5 py-0.5 rounded bg-indigo-900/50 text-indigo-300">LAUNCHER</span>
- <span class="px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-300">GAME</span>
- <span class="px-1.5 py-0.5 rounded bg-red-900/50 text-red-300">ERROR</span>
- </div>
+<div class="absolute inset-0 flex flex-col bg-[#1e1e1e] text-zinc-300 font-mono text-xs overflow-hidden">
+ <!-- Toolbar -->
+ <div class="flex flex-wrap items-center justify-between p-2 bg-[#252526] border-b border-[#3e3e42] gap-2">
+ <div class="flex items-center gap-3">
+ <h3 class="font-bold text-zinc-100 uppercase tracking-wider px-2">Console</h3>
+
+ <!-- Source Dropdown -->
+ <CustomSelect
+ options={sourceOptions}
+ bind:value={selectedSource}
+ class="w-36"
+ />
+
+ <!-- Level Filters -->
+ <div class="flex items-center bg-[#1e1e1e] rounded border border-[#3e3e42] overflow-hidden">
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showInfo ? 'text-blue-400' : 'text-zinc-600'}"
+ onclick={() => showInfo = !showInfo}
+ title="Toggle Info"
+ >Info</button>
+ <div class="w-px h-3 bg-[#3e3e42]"></div>
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showWarn ? 'text-yellow-400' : 'text-zinc-600'}"
+ onclick={() => showWarn = !showWarn}
+ title="Toggle Warnings"
+ >Warn</button>
+ <div class="w-px h-3 bg-[#3e3e42]"></div>
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showError ? 'text-red-400' : 'text-zinc-600'}"
+ onclick={() => showError = !showError}
+ title="Toggle Errors"
+ >Error</button>
+ <div class="w-px h-3 bg-[#3e3e42]"></div>
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showDebug ? 'text-purple-400' : 'text-zinc-600'}"
+ onclick={() => showDebug = !showDebug}
+ title="Toggle Debug"
+ >Debug</button>
+ </div>
+
+ <!-- Search -->
+ <div class="relative group">
+ <input
+ type="text"
+ bind:value={searchQuery}
+ placeholder="Find..."
+ class="bg-[#1e1e1e] border border-[#3e3e42] rounded pl-8 pr-2 py-1 focus:border-indigo-500 focus:outline-none w-40 text-zinc-300 placeholder:text-zinc-600 transition-all focus:w-64"
+ />
+ <svg class="w-3.5 h-3.5 text-zinc-500 absolute left-2.5 top-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
+ {#if searchQuery}
+ <button class="absolute right-2 top-1.5 text-zinc-500 hover:text-white" onclick={() => searchQuery = ""}>✕</button>
+ {/if}
+ </div>
</div>
- <div class="flex gap-2">
- <button on:click={exportLogs} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Export</button>
- <button on:click={clearLogs} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Clear</button>
- <button on:click={() => visible = false} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Close</button>
+
+ <!-- Actions -->
+ <div class="flex items-center gap-2">
+ <!-- Log count indicator -->
+ <span class="text-zinc-500 text-[10px] px-2">{filteredLogs.length} / {logsState.logs.length}</span>
+
+ <button
+ onclick={() => logsState.clear()}
+ class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+ title="Clear Logs"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
+ </button>
+ <button
+ onclick={exportLogs}
+ class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+ title="Export Filtered Logs"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
+ </button>
+ <button
+ onclick={uploadLogs}
+ class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+ title="Upload Filtered Logs"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
+ </button>
+ <div class="w-px h-4 bg-[#3e3e42] mx-1"></div>
+ <button
+ onclick={() => uiState.toggleConsole()}
+ class="p-1.5 hover:bg-red-500/20 hover:text-red-400 rounded text-zinc-400 transition-colors"
+ title="Close"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
+ </button>
</div>
</div>
- <div bind:this={consoleElement} class="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5">
- {#each logs as log}
- <div class="flex whitespace-pre-wrap break-all {log.type === 'stderr' ? 'text-red-400' : log.type === 'launcher' ? 'text-indigo-300' : 'text-zinc-300'}">
- <span class="text-zinc-600 mr-2 shrink-0">{log.timestamp}</span>
- <span class="shrink-0 mr-2 {log.type === 'stderr' ? 'text-red-500' : log.type === 'launcher' ? 'text-indigo-500' : 'text-zinc-500'}">[{log.type === 'launcher' ? 'LAUNCHER' : log.type === 'stderr' ? 'ERROR' : 'GAME'}]</span>
- <span class="break-all">{log.line}</span>
- </div>
+
+ <!-- Log Area -->
+ <div
+ bind:this={consoleElement}
+ onscroll={handleScroll}
+ class="flex-1 overflow-y-auto overflow-x-hidden p-2 select-text custom-scrollbar"
+ >
+ {#each filteredLogs as log (log.id)}
+ <div class="flex gap-2 leading-relaxed hover:bg-[#2a2d2e] px-1 rounded-sm group">
+ <!-- Timestamp -->
+ <span class="text-zinc-500 shrink-0 select-none w-20 text-right opacity-50 group-hover:opacity-100">{log.timestamp.split('.')[0]}</span>
+
+ <!-- Source & Level -->
+ <div class="flex shrink-0 min-w-[140px] gap-1 justify-end font-bold select-none truncate">
+ <span class="text-zinc-500 truncate max-w-[90px]" title={log.source}>{log.source}</span>
+ <span class={getLevelColor(log.level)}>{getLevelLabel(log.level)}</span>
+ </div>
+
+ <!-- Message -->
+ <div class="flex-1 break-all whitespace-pre-wrap text-zinc-300 min-w-0 {getMessageColor(log)}">
+ {@html highlightText(log.message, searchQuery)}
+ </div>
+ </div>
{/each}
- {#if logs.length === 0}
- <div class="text-zinc-600 italic">Waiting for output... Click "Show Logs" and start a game to see logs here.</div>
+
+ {#if filteredLogs.length === 0}
+ <div class="text-center text-zinc-600 mt-10 italic select-none">
+ {#if logsState.logs.length === 0}
+ Waiting for logs...
+ {:else}
+ No logs match current filters.
+ {/if}
+ </div>
{/if}
</div>
+
+ <!-- Auto-scroll status -->
+ {#if !autoScroll}
+ <button
+ onclick={() => { autoScroll = true; consoleElement.scrollTop = consoleElement.scrollHeight; }}
+ class="absolute bottom-4 right-6 bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded shadow-lg text-xs font-bold transition-all animate-bounce"
+ >
+ Resume Auto-scroll âŦ‡
+ </button>
+ {/if}
</div>
-{/if}
+
+<style>
+ /* Custom Scrollbar for the log area */
+ .custom-scrollbar::-webkit-scrollbar {
+ width: 10px;
+ background-color: #1e1e1e;
+ }
+ .custom-scrollbar::-webkit-scrollbar-thumb {
+ background-color: #424242;
+ border: 2px solid #1e1e1e; /* padding around thumb */
+ border-radius: 0;
+ }
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background-color: #4f4f4f;
+ }
+</style>
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..a370936
--- /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: Warm 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/game.svelte.ts b/ui/src/stores/game.svelte.ts
index 0af3daf..28b2db5 100644
--- a/ui/src/stores/game.svelte.ts
+++ b/ui/src/stores/game.svelte.ts
@@ -7,6 +7,10 @@ export class GameState {
versions = $state<Version[]>([]);
selectedVersion = $state("");
+ get latestRelease() {
+ return this.versions.find((v) => v.type === "release");
+ }
+
async loadVersions() {
try {
this.versions = await invoke<Version[]>("get_versions");
diff --git a/ui/src/stores/logs.svelte.ts b/ui/src/stores/logs.svelte.ts
new file mode 100644
index 0000000..5491f70
--- /dev/null
+++ b/ui/src/stores/logs.svelte.ts
@@ -0,0 +1,139 @@
+import { listen } from "@tauri-apps/api/event";
+
+export interface LogEntry {
+ id: number;
+ timestamp: string;
+ level: "info" | "warn" | "error" | "debug" | "fatal";
+ source: string;
+ message: string;
+}
+
+// Parse Minecraft/Java log format: [HH:MM:SS] [Thread/LEVEL]: message
+// or: [HH:MM:SS] [Thread/LEVEL] [Source]: message
+const GAME_LOG_REGEX = /^\[[\d:]+\]\s*\[([^\]]+)\/(\w+)\](?:\s*\[([^\]]+)\])?:\s*(.*)$/;
+
+function parseGameLogLevel(levelStr: string): LogEntry["level"] {
+ const upper = levelStr.toUpperCase();
+ if (upper === "INFO") return "info";
+ if (upper === "WARN" || upper === "WARNING") return "warn";
+ if (upper === "ERROR" || upper === "SEVERE") return "error";
+ if (upper === "DEBUG" || upper === "TRACE" || upper === "FINE" || upper === "FINER" || upper === "FINEST") return "debug";
+ if (upper === "FATAL") return "fatal";
+ return "info";
+}
+
+export class LogsState {
+ logs = $state<LogEntry[]>([]);
+ private nextId = 0;
+ private maxLogs = 5000;
+
+ // Track all unique sources for filtering
+ sources = $state<Set<string>>(new Set(["Launcher"]));
+
+ constructor() {
+ this.addLog("info", "Launcher", "Logs initialized");
+ this.setupListeners();
+ }
+
+ addLog(level: LogEntry["level"], source: string, message: string) {
+ const now = new Date();
+ const timestamp = now.toLocaleTimeString() + "." + now.getMilliseconds().toString().padStart(3, "0");
+
+ this.logs.push({
+ id: this.nextId++,
+ timestamp,
+ level,
+ source,
+ message,
+ });
+
+ // Track source
+ if (!this.sources.has(source)) {
+ this.sources = new Set([...this.sources, source]);
+ }
+
+ if (this.logs.length > this.maxLogs) {
+ this.logs.shift();
+ }
+ }
+
+ // Parse game output and extract level/source
+ addGameLog(rawLine: string, isStderr: boolean) {
+ const match = rawLine.match(GAME_LOG_REGEX);
+
+ if (match) {
+ const [, thread, levelStr, extraSource, message] = match;
+ const level = parseGameLogLevel(levelStr);
+ // Use extraSource if available, otherwise use thread name as source hint
+ const source = extraSource || `Game/${thread.split("-")[0]}`;
+ this.addLog(level, source, message);
+ } else {
+ // Fallback: couldn't parse, use stderr as error indicator
+ const level = isStderr ? "error" : "info";
+ this.addLog(level, "Game", rawLine);
+ }
+ }
+
+ clear() {
+ this.logs = [];
+ this.sources = new Set(["Launcher"]);
+ this.addLog("info", "Launcher", "Logs cleared");
+ }
+
+ // Export with filter support
+ exportLogs(filteredLogs: LogEntry[]): string {
+ return filteredLogs
+ .map((l) => `[${l.timestamp}] [${l.source}/${l.level.toUpperCase()}] ${l.message}`)
+ .join("\n");
+ }
+
+ private async setupListeners() {
+ // General Launcher Logs
+ await listen<string>("launcher-log", (e) => {
+ this.addLog("info", "Launcher", e.payload);
+ });
+
+ // Game Stdout - parse log level
+ await listen<string>("game-stdout", (e) => {
+ this.addGameLog(e.payload, false);
+ });
+
+ // Game Stderr - parse log level, default to error
+ await listen<string>("game-stderr", (e) => {
+ this.addGameLog(e.payload, true);
+ });
+
+ // Download Events (Summarized)
+ await listen("download-start", (e) => {
+ this.addLog("info", "Downloader", `Starting batch download of ${e.payload} files...`);
+ });
+
+ await listen("download-complete", () => {
+ this.addLog("info", "Downloader", "All downloads completed.");
+ });
+
+ // Listen to file download progress to log finished files
+ await listen<any>("download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Finished") {
+ if (p.file.endsWith(".jar")) {
+ this.addLog("info", "Downloader", `Downloaded ${p.file}`);
+ }
+ }
+ });
+
+ // Java Download
+ await listen<any>("java-download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Downloading" && p.percentage === 0) {
+ this.addLog("info", "JavaInstaller", `Downloading Java: ${p.file_name}`);
+ } else if (p.status === "Completed") {
+ this.addLog("info", "JavaInstaller", `Java installed: ${p.file_name}`);
+ } else if (p.status === "Error") {
+ this.addLog("error", "JavaInstaller", `Java download error`);
+ }
+ });
+ }
+}
+
+export const logsState = new LogsState();
diff --git a/ui/src/stores/releases.svelte.ts b/ui/src/stores/releases.svelte.ts
new file mode 100644
index 0000000..c858abb
--- /dev/null
+++ b/ui/src/stores/releases.svelte.ts
@@ -0,0 +1,36 @@
+import { invoke } from "@tauri-apps/api/core";
+
+export interface GithubRelease {
+ tag_name: string;
+ name: string;
+ published_at: string;
+ body: string;
+ html_url: string;
+}
+
+export class ReleasesState {
+ releases = $state<GithubRelease[]>([]);
+ isLoading = $state(false);
+ isLoaded = $state(false);
+ error = $state<string | null>(null);
+
+ async loadReleases() {
+ // If already loaded or currently loading, skip to prevent duplicate requests
+ if (this.isLoaded || this.isLoading) return;
+
+ this.isLoading = true;
+ this.error = null;
+
+ try {
+ this.releases = await invoke<GithubRelease[]>("get_github_releases");
+ this.isLoaded = true;
+ } catch (e) {
+ console.error("Failed to load releases:", e);
+ this.error = String(e);
+ } finally {
+ this.isLoading = false;
+ }
+ }
+}
+
+export const releasesState = new ReleasesState();
diff --git a/ui/src/stores/settings.svelte.ts b/ui/src/stores/settings.svelte.ts
index 397b9a6..b85e5fb 100644
--- a/ui/src/stores/settings.svelte.ts
+++ b/ui/src/stores/settings.svelte.ts
@@ -1,5 +1,15 @@
import { invoke } from "@tauri-apps/api/core";
-import type { LauncherConfig, JavaInstallation } from "../types";
+import { convertFileSrc } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import type {
+ JavaCatalog,
+ JavaDownloadProgress,
+ JavaDownloadSource,
+ JavaInstallation,
+ JavaReleaseInfo,
+ LauncherConfig,
+ PendingJavaDownload,
+} from "../types";
import { uiState } from "./ui.svelte";
export class SettingsState {
@@ -10,14 +20,133 @@ export class SettingsState {
width: 854,
height: 480,
download_threads: 32,
+ enable_gpu_acceleration: false,
+ enable_visual_effects: true,
+ active_effect: "constellation",
+ theme: "dark",
+ custom_background_path: undefined,
+ log_upload_service: "paste.rs",
+ pastebin_api_key: undefined,
});
+
+ // Convert background path to proper asset URL
+ get backgroundUrl(): string | undefined {
+ if (this.settings.custom_background_path) {
+ return convertFileSrc(this.settings.custom_background_path);
+ }
+ return undefined;
+ }
javaInstallations = $state<JavaInstallation[]>([]);
isDetectingJava = $state(false);
+ // Java download modal state
+ showJavaDownloadModal = $state(false);
+ selectedDownloadSource = $state<JavaDownloadSource>("adoptium");
+
+ // Java catalog state
+ javaCatalog = $state<JavaCatalog | null>(null);
+ isLoadingCatalog = $state(false);
+ catalogError = $state("");
+
+ // Version selection state
+ selectedMajorVersion = $state<number | null>(null);
+ selectedImageType = $state<"jre" | "jdk">("jre");
+ showOnlyRecommended = $state(true);
+ searchQuery = $state("");
+
+ // Download progress state
+ isDownloadingJava = $state(false);
+ downloadProgress = $state<JavaDownloadProgress | null>(null);
+ javaDownloadStatus = $state("");
+
+ // Pending downloads
+ pendingDownloads = $state<PendingJavaDownload[]>([]);
+
+ // Event listener cleanup
+ private progressUnlisten: UnlistenFn | null = null;
+
+ // Computed: filtered releases based on selection
+ get filteredReleases(): JavaReleaseInfo[] {
+ if (!this.javaCatalog) return [];
+
+ let releases = this.javaCatalog.releases;
+
+ // Filter by major version if selected
+ if (this.selectedMajorVersion !== null) {
+ releases = releases.filter(r => r.major_version === this.selectedMajorVersion);
+ }
+
+ // Filter by image type
+ releases = releases.filter(r => r.image_type === this.selectedImageType);
+
+ // Filter by recommended (LTS) versions
+ if (this.showOnlyRecommended) {
+ releases = releases.filter(r => r.is_lts);
+ }
+
+ // Filter by search query
+ if (this.searchQuery.trim()) {
+ const query = this.searchQuery.toLowerCase();
+ releases = releases.filter(
+ r =>
+ r.release_name.toLowerCase().includes(query) ||
+ r.version.toLowerCase().includes(query) ||
+ r.major_version.toString().includes(query)
+ );
+ }
+
+ return releases;
+ }
+
+ // Computed: available major versions for display
+ get availableMajorVersions(): number[] {
+ if (!this.javaCatalog) return [];
+ let versions = [...this.javaCatalog.available_major_versions];
+
+ // Filter by LTS if showOnlyRecommended is enabled
+ if (this.showOnlyRecommended) {
+ versions = versions.filter(v => this.javaCatalog!.lts_versions.includes(v));
+ }
+
+ // Sort descending (newest first)
+ return versions.sort((a, b) => b - a);
+ }
+
+ // Get installation status for a release: 'installed' | 'download'
+ getInstallStatus(release: JavaReleaseInfo): 'installed' | 'download' {
+ // Find installed Java that matches the major version and image type (by path pattern)
+ const matchingInstallations = this.javaInstallations.filter(inst => {
+ // Check if this is a DropOut-managed Java (path contains temurin-XX-jre/jdk pattern)
+ const pathLower = inst.path.toLowerCase();
+ const pattern = `temurin-${release.major_version}-${release.image_type}`;
+ return pathLower.includes(pattern);
+ });
+
+ // If any matching installation exists, it's installed
+ return matchingInstallations.length > 0 ? 'installed' : 'download';
+ }
+
+ // Computed: selected release details
+ get selectedRelease(): JavaReleaseInfo | null {
+ if (!this.javaCatalog || this.selectedMajorVersion === null) return null;
+ return this.javaCatalog.releases.find(
+ r => r.major_version === this.selectedMajorVersion && r.image_type === this.selectedImageType
+ ) || null;
+ }
+
async loadSettings() {
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();
+ }
+ // Ensure custom_background_path is reactive
+ if (!this.settings.custom_background_path) {
+ this.settings.custom_background_path = undefined;
+ }
} catch (e) {
console.error("Failed to load settings:", e);
}
@@ -25,6 +154,11 @@ export class SettingsState {
async saveSettings() {
try {
+ // Ensure we clean up any invalid paths before saving
+ if (this.settings.custom_background_path === "") {
+ this.settings.custom_background_path = undefined;
+ }
+
await invoke("save_settings", { config: this.settings });
uiState.setStatus("Settings saved!");
} catch (e) {
@@ -53,6 +187,205 @@ export class SettingsState {
selectJava(path: string) {
this.settings.java_path = path;
}
+
+ async openJavaDownloadModal() {
+ this.showJavaDownloadModal = true;
+ this.javaDownloadStatus = "";
+ this.catalogError = "";
+ this.downloadProgress = null;
+
+ // Setup progress event listener
+ await this.setupProgressListener();
+
+ // Load catalog
+ await this.loadJavaCatalog(false);
+
+ // Check for pending downloads
+ await this.loadPendingDownloads();
+ }
+
+ async closeJavaDownloadModal() {
+ if (!this.isDownloadingJava) {
+ this.showJavaDownloadModal = false;
+ // Cleanup listener
+ if (this.progressUnlisten) {
+ this.progressUnlisten();
+ this.progressUnlisten = null;
+ }
+ }
+ }
+
+ private async setupProgressListener() {
+ if (this.progressUnlisten) {
+ this.progressUnlisten();
+ }
+
+ this.progressUnlisten = await listen<JavaDownloadProgress>(
+ "java-download-progress",
+ (event) => {
+ this.downloadProgress = event.payload;
+ this.javaDownloadStatus = event.payload.status;
+
+ if (event.payload.status === "Completed") {
+ this.isDownloadingJava = false;
+ setTimeout(async () => {
+ await this.detectJava();
+ uiState.setStatus(`Java installed successfully!`);
+ }, 500);
+ } else if (event.payload.status === "Error") {
+ this.isDownloadingJava = false;
+ }
+ }
+ );
+ }
+
+ async loadJavaCatalog(forceRefresh: boolean) {
+ this.isLoadingCatalog = true;
+ this.catalogError = "";
+
+ try {
+ const command = forceRefresh ? "refresh_java_catalog" : "fetch_java_catalog";
+ this.javaCatalog = await invoke<JavaCatalog>(command);
+
+ // Auto-select first LTS version
+ if (this.selectedMajorVersion === null && this.javaCatalog.lts_versions.length > 0) {
+ // Select most recent LTS (21 or highest)
+ const ltsVersions = [...this.javaCatalog.lts_versions].sort((a, b) => b - a);
+ this.selectedMajorVersion = ltsVersions[0];
+ }
+ } catch (e) {
+ console.error("Failed to load Java catalog:", e);
+ this.catalogError = `Failed to load Java catalog: ${e}`;
+ } finally {
+ this.isLoadingCatalog = false;
+ }
+ }
+
+ async refreshCatalog() {
+ await this.loadJavaCatalog(true);
+ uiState.setStatus("Java catalog refreshed");
+ }
+
+ async loadPendingDownloads() {
+ try {
+ this.pendingDownloads = await invoke<PendingJavaDownload[]>("get_pending_java_downloads");
+ } catch (e) {
+ console.error("Failed to load pending downloads:", e);
+ }
+ }
+
+ selectMajorVersion(version: number) {
+ this.selectedMajorVersion = version;
+ }
+
+ async downloadJava() {
+ if (!this.selectedRelease || !this.selectedRelease.is_available) {
+ uiState.setStatus("Selected Java version is not available for this platform");
+ return;
+ }
+
+ this.isDownloadingJava = true;
+ this.javaDownloadStatus = "Starting download...";
+ this.downloadProgress = null;
+
+ try {
+ const result: JavaInstallation = await invoke("download_adoptium_java", {
+ majorVersion: this.selectedMajorVersion,
+ imageType: this.selectedImageType,
+ customPath: null,
+ });
+
+ this.settings.java_path = result.path;
+ await this.detectJava();
+
+ setTimeout(() => {
+ this.showJavaDownloadModal = false;
+ uiState.setStatus(`Java ${this.selectedMajorVersion} is ready to use!`);
+ }, 1500);
+ } catch (e) {
+ console.error("Failed to download Java:", e);
+ this.javaDownloadStatus = `Download failed: ${e}`;
+ } finally {
+ this.isDownloadingJava = false;
+ }
+ }
+
+ async cancelDownload() {
+ try {
+ await invoke("cancel_java_download");
+ this.isDownloadingJava = false;
+ this.javaDownloadStatus = "Download cancelled";
+ this.downloadProgress = null;
+ await this.loadPendingDownloads();
+ } catch (e) {
+ console.error("Failed to cancel download:", e);
+ }
+ }
+
+ async resumeDownloads() {
+ if (this.pendingDownloads.length === 0) return;
+
+ this.isDownloadingJava = true;
+ this.javaDownloadStatus = "Resuming download...";
+
+ try {
+ const installed = await invoke<JavaInstallation[]>("resume_java_downloads");
+ if (installed.length > 0) {
+ this.settings.java_path = installed[0].path;
+ await this.detectJava();
+ uiState.setStatus(`Resumed and installed ${installed.length} Java version(s)`);
+ }
+ await this.loadPendingDownloads();
+ } catch (e) {
+ console.error("Failed to resume downloads:", e);
+ this.javaDownloadStatus = `Resume failed: ${e}`;
+ } finally {
+ this.isDownloadingJava = false;
+ }
+ }
+
+ // Format bytes to human readable
+ formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
+ }
+
+ // Format seconds to human readable
+ formatTime(seconds: number): string {
+ 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`;
+ }
+
+ // Format date string
+ formatDate(dateStr: string | null): string {
+ if (!dateStr) return "--";
+ try {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ year: "2-digit",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ } catch {
+ return "--";
+ }
+ }
+
+ // Legacy compatibility
+ get availableJavaVersions(): number[] {
+ return this.availableMajorVersions;
+ }
}
export const settingsState = new SettingsState();
diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts
index 1f83585..09a7d5e 100644
--- a/ui/src/types/index.ts
+++ b/ui/src/types/index.ts
@@ -30,6 +30,13 @@ export interface LauncherConfig {
width: number;
height: number;
download_threads: number;
+ custom_background_path?: string;
+ enable_gpu_acceleration: boolean;
+ enable_visual_effects: boolean;
+ active_effect: string;
+ theme: string;
+ log_upload_service: "paste.rs" | "pastebin.com";
+ pastebin_api_key?: string;
}
export interface JavaInstallation {
@@ -37,3 +44,116 @@ export interface JavaInstallation {
version: string;
is_64bit: boolean;
}
+
+export interface JavaDownloadInfo {
+ version: string;
+ release_name: string;
+ download_url: string;
+ file_name: string;
+ file_size: number;
+ checksum: string | null;
+ image_type: string;
+}
+
+export interface JavaReleaseInfo {
+ major_version: number;
+ image_type: string;
+ version: string;
+ release_name: string;
+ release_date: string | null;
+ file_size: number;
+ checksum: string | null;
+ download_url: string;
+ is_lts: boolean;
+ is_available: boolean;
+ architecture: string;
+}
+
+export interface JavaCatalog {
+ releases: JavaReleaseInfo[];
+ available_major_versions: number[];
+ lts_versions: number[];
+ cached_at: number;
+}
+
+export interface JavaDownloadProgress {
+ file_name: string;
+ downloaded_bytes: number;
+ total_bytes: number;
+ speed_bytes_per_sec: number;
+ eta_seconds: number;
+ status: string;
+ percentage: number;
+}
+
+export interface PendingJavaDownload {
+ major_version: number;
+ image_type: string;
+ download_url: string;
+ file_name: string;
+ file_size: number;
+ checksum: string | null;
+ install_path: string;
+ created_at: number;
+}
+
+export type JavaDownloadSource = "adoptium" | "mojang" | "azul";
+
+// ==================== 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";
+
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index d030e58..d5fcbc5 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -8,6 +8,7 @@ export default defineConfig({
// Fix for Tauri + Vite HMR
server: {
+ host: true,
strictPort: true,
hmr: {
protocol: 'ws',