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