diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6b577d43..99d576dd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,14 +3,15 @@ ## Architecture Overview **DropOut** is a Tauri v2 desktop application combining: + - **Backend (Rust)**: Game launching, asset management, authentication, mod loader installation -- **Frontend (Svelte 5)**: Reactive UI with Tailwind CSS 4 and particle effects +- **Frontend (React + Svelte)**: Active React UI in `packages/ui-new`, legacy Svelte 5 UI in `packages/ui` - **Communication**: Tauri commands (invoke) and events (emit/listen) - **Pre-commit Hooks**: Python-based tooling for JSON/TOML validation (managed via `pyproject.toml`) **Key Data Flow**: Frontend invokes Rust commands → Rust processes/downloads → Rust emits progress events → Frontend updates UI via listeners -**Version Management**: Uses `_version.py` for single source of truth, synced to `Cargo.toml` (0.1.24) and `tauri.conf.json` +**Version Management**: `Cargo.toml` is the source of truth; `scripts/bump-tauri.ts` syncs `src-tauri/tauri.conf.json` ## Project Structure @@ -33,59 +34,80 @@ src-tauri/ # Rust backend version_merge.rs # Parent version inheritance merging utils/ zip.rs # Native library extraction -ui/ # Svelte 5 frontend - src/ - App.svelte # Main app component, enforces dark mode - stores/ # Svelte 5 runes state management ($state, $effect) - auth.svelte.ts # Authentication state with device code polling - game.svelte.ts # Game state (running, logs) - settings.svelte.ts # Settings + Java detection - ui.svelte.ts # UI state (toasts, modals, active view) - components/ # UI components (HomeView, VersionsView, SettingsView, etc.) - lib/ # Reusable components (DownloadMonitor, GameConsole) +packages/ + ui-new/ # React frontend used by Tauri + src/ + main.tsx # React Router setup (hash routing) + pages/ # Route views (Home, Versions, Settings, ...) + stores/ # Zustand stores + components/ # UI components + ui/ # Legacy Svelte 5 frontend + src/ + App.svelte # Main app component, enforces dark mode + stores/ # Svelte 5 runes state management ($state, $effect) + auth.svelte.ts # Authentication state with device code polling + game.svelte.ts # Game state (running, logs) + settings.svelte.ts # Settings + Java detection + ui.svelte.ts # UI state (toasts, modals, active view) + components/ # UI components (HomeView, VersionsView, SettingsView, etc.) + lib/ # Reusable components (DownloadMonitor, GameConsole) ``` ## Critical Development Workflows ### Development Mode + ```bash -cargo tauri dev # Starts frontend dev server (Vite on :5173) + Tauri window +cargo tauri dev # Runs ui-new dev server (Vite on :5173) + Tauri window ``` -- Frontend uses **Rolldown-based Vite fork** (`npm:rolldown-vite@7.2.5`) with hot reload + +- `src-tauri/tauri.conf.json` runs `pnpm --filter @dropout/ui-new dev` +- Frontend uses **Rolldown-based Vite fork** (`npm:rolldown-vite@^7`) with hot reload - Backend recompiles on Rust file changes - Console shows both Rust stdout and frontend Vite logs -- **Vite Config**: Uses `usePolling: true` for watch compatibility with Tauri - **HMR**: WebSocket on `ws://localhost:5173` ### Pre-commit Checks + - Uses **pre-commit** with Python (configured in `pyproject.toml`) - Hooks: JSON/TOML/YAML validation, Ruff for Python files - Run manually: `pre-commit run --all-files` - **IMPORTANT**: All Python tooling for CI/validation lives here, NOT for app logic ### Building + ```bash -cd ui && pnpm install # Install frontend dependencies (requires pnpm 9, Node 22) -cargo tauri build # Produces platform bundles in src-tauri/target/release/bundle/ +pnpm install # Install workspace deps (requires pnpm 10, Node 22) +cargo tauri build # Produces bundles in src-tauri/target/release/bundle/ +pnpm --filter @dropout/ui-new build # Frontend-only build ``` ### Frontend Workflows + ```bash -cd ui -pnpm check # Svelte type checking + TypeScript validation -pnpm lint # OxLint for code quality -pnpm format # OxFmt for formatting (--check for CI) +# React UI (active) +pnpm --filter @dropout/ui-new lint # Biome check +pnpm --filter @dropout/ui-new build + +# Svelte UI (legacy) +pnpm --filter @dropout/ui check # Svelte + TS checks +pnpm --filter @dropout/ui lint # OxLint +pnpm --filter @dropout/ui format # OxFmt (--check for CI) ``` ### Testing + - CI workflow: [`.github/workflows/test.yml`](.github/workflows/test.yml) tests on Ubuntu, Arch (Wayland), Windows, macOS -- Local: `cargo test` (no comprehensive test suite exists yet) +- Local: `cargo test` (run from `src-tauri/`) +- Single test: `cargo test ` (unit) or `cargo test --test ` - **Test workflow behavior**: Push/PR = Linux build only, `workflow_dispatch` = full multi-platform builds ## Project-Specific Patterns & Conventions ### Tauri Command Pattern + Commands in [`main.rs`](../src-tauri/src/main.rs) follow this structure: + ```rust #[tauri::command] async fn command_name( @@ -98,35 +120,43 @@ async fn command_name( Ok(result) } ``` + **Register in `main()`:** + ```rust tauri::Builder::default() .invoke_handler(tauri::generate_handler![command_name, ...]) ``` ### Event Communication + **Rust → Frontend (Progress/Logs):** + ```rust // In Rust window.emit("launcher-log", "Downloading assets...")?; window.emit("download-progress", progress_struct)?; ``` + ```typescript -// In Frontend (Svelte) +// In Frontend (React/Svelte) import { listen } from "@tauri-apps/api/event"; const unlisten = await listen("launcher-log", (event) => { - console.log(event.payload); + console.log(event.payload); }); ``` **Frontend → Rust (Commands):** + ```typescript import { invoke } from "@tauri-apps/api/core"; const result = await invoke("start_game", { versionId: "1.20.4" }); ``` ### State Management (Rust) + Global state via Tauri's managed state: + ```rust pub struct ConfigState { pub config: Mutex, @@ -138,14 +168,16 @@ pub struct ConfigState { config_state: State<'_, ConfigState> ``` -### State Management (Svelte 5) -Uses **Svelte 5 runes** (not stores): +### State Management (Svelte 5, legacy UI) + +Uses **Svelte 5 runes** (not stores) in `packages/ui`: + ```typescript // stores/auth.svelte.ts export class AuthState { currentAccount = $state(null); // Reactive state isLoginModalOpen = $state(false); - + $effect(() => { // Side effects // Runs when dependencies change }); @@ -153,64 +185,83 @@ export class AuthState { // Export singleton export const authState = new AuthState(); ``` + **CRITICAL**: Stores are TypeScript classes with `$state` runes, not Svelte 4's `writable()`. Each store file exports a singleton instance. -**Store Pattern**: -- File: `stores/*.svelte.ts` (note `.svelte.ts` extension) +**Store Pattern**: + +- File: `packages/ui/src/stores/*.svelte.ts` (note `.svelte.ts` extension) - Class-based with reactive `$state` properties - Methods for actions (async operations with `invoke()`) - Derived values with `get` accessors - Side effects with `$effect()` (auto-tracks dependencies) +### State Management (React UI) + +`packages/ui-new` uses Zustand stores in `src/stores` with `create(...)` and hook exports (e.g., `useUIStore`). + ### Version Inheritance System + Modded versions (Fabric/Forge) use `inheritsFrom` field: + - [`version_merge.rs`](../src-tauri/src/core/version_merge.rs): Merges parent vanilla JSON with mod loader JSON - [`manifest.rs`](../src-tauri/src/core/manifest.rs): `load_version()` recursively resolves inheritance - Libraries, assets, arguments are merged from parent + modded version ### Microsoft Authentication Flow + Uses **Device Code Flow** (no redirect needed): + 1. Frontend calls `start_microsoft_login()` → gets device code + URL 2. User visits URL in browser, enters code -3. Frontend polls `complete_microsoft_login()` with device code -4. Rust exchanges code → MS token → Xbox Live → XSTS → Minecraft token -5. Stores MS refresh token for auto-refresh (see [`auth.rs`](../src-tauri/src/core/auth.rs)) +3. Frontend calls `complete_microsoft_login()` with device code +4. Rust exchanges code → MS token → Xbox Live → XSTS → Minecraft token → profile +5. Emits `auth-progress` during the flow and stores MS refresh token -**Client ID**: Uses ATLauncher's public client ID (`c36a9fb6-4f2a-41ff-90bd-ae7cc92031eb`) +**Client ID**: `CLIENT_ID` in [`auth.rs`](../src-tauri/src/core/auth.rs) is `fe165602-5410-4441-92f7-326e10a7cb82`. ### Download System + [`downloader.rs`](../src-tauri/src/core/downloader.rs) features: + - **Concurrent downloads** with semaphore (configurable threads) - **Resumable downloads**: `.part` + `.part.meta` files track progress - **Multi-segment downloads**: Large files split into segments downloaded in parallel - **Checksum verification**: SHA1/SHA256 validation -- **Progress events**: Emits `download-progress` with file name, bytes, ETA +- **Progress events**: Emits `download-progress` with file/status, bytes, and totals (plus `download-start`) - **Queue persistence**: Java downloads saved to `download_queue.json` for resumption ### Java Management -[`java.rs`](../src-tauri/src/core/java.rs): -- **Auto-detection**: Scans `/usr/lib/jvm`, `/Library/Java`, `JAVA_HOME`, `PATH` -- **Adoptium API**: Fetches available JDK/JRE versions for current OS/arch -- **Catalog caching**: `java_catalog.json` cached for 24 hours -- **Installation**: Downloads, extracts to `app_data_dir/java/` -- **Cancellation**: Global `AtomicBool` flag for download cancellation + +`src-tauri/src/core/java/` module: + +- **Auto-detection**: PATH/JAVA_HOME plus OS-specific directories (e.g., `/usr/lib/jvm`, `/Library/Java/JavaVirtualMachines`, `Program Files\\Java`) +- **Catalog caching**: `java_catalog_cache.json` cached for 24 hours +- **Installation**: Downloads with queue persistence, extracts to `app_data_dir/java/--` +- **Download progress event**: `java-download-progress` ### Error Handling + - Commands return `Result` (String for JS-friendly errors) - Use `.map_err(|e| e.to_string())` to convert errors - Emit detailed error logs: `emit_log!(window, format!("Error: {}", e))` ### File Paths -- **Game directory**: `app_handle.path().app_data_dir()` (~/.local/share/com.dropout.launcher on Linux) -- **Versions**: `game_dir/versions//.json` -- **Libraries**: `game_dir/libraries/` -- **Assets**: `game_dir/assets/objects//` -- **Config**: `game_dir/config.json` -- **Accounts**: `game_dir/accounts.json` + +- **App data root**: `app_handle.path().app_data_dir()` (platform-specific) +- **Instance metadata**: `app_data_dir/instances.json` +- **Instance game dir**: `app_data_dir/instances//` + - Versions: `versions//.json` + - Libraries: `libraries/` + - Assets: `assets/objects//` +- **Shared caches** (when `use_shared_caches`): `app_data_dir/{versions,libraries,assets}` +- **Config**: `app_data_dir/config.json` +- **Accounts**: `app_data_dir/accounts.json` ## Integration Points ### External APIs + - **Mojang**: `https://piston-meta.mojang.com/mc/game/version_manifest_v2.json` - **Fabric Meta**: `https://meta.fabricmc.net/v2/` - **Forge Maven**: `https://maven.minecraftforge.net/` @@ -218,6 +269,7 @@ Uses **Device Code Flow** (no redirect needed): - **GitHub Releases**: `https://api.github.com/repos/HsiangNianian/DropOut/releases` ### Native Dependencies + - **Linux**: `libwebkit2gtk-4.1-dev`, `libgtk-3-dev` (see [test.yml](../.github/workflows/test.yml)) - **macOS**: System WebKit via Tauri - **Windows**: WebView2 runtime (bundled) @@ -225,40 +277,45 @@ Uses **Device Code Flow** (no redirect needed): ## Common Tasks ### Adding a New Tauri Command + 1. Define function in [`main.rs`](../src-tauri/src/main.rs) with `#[tauri::command]` 2. Add to `.invoke_handler(tauri::generate_handler![..., new_command])` 3. Call from frontend: `invoke("new_command", { args })` ### Adding a New UI View -1. Create component in `ui/src/components/NewView.svelte` -2. Import in [`App.svelte`](../ui/src/App.svelte) -3. Add navigation in [`Sidebar.svelte`](../ui/src/components/Sidebar.svelte) -4. Update `uiState.activeView` in [`ui.svelte.ts`](../ui/src/stores/ui.svelte.ts) + +- **React UI (active)**: add a page in `packages/ui-new/src/pages` and register the route in [`main.tsx`](../packages/ui-new/src/main.tsx). +- **Svelte UI (legacy)**: create component in `packages/ui/src/components`, import in [`App.svelte`](../packages/ui/src/App.svelte), update `uiState.activeView` in [`ui.svelte.ts`](../packages/ui/src/stores/ui.svelte.ts). ### Emitting Progress Events + Use `emit_log!` macro for launcher logs: + ```rust emit_log!(window, format!("Downloading {}", filename)); ``` + For custom events: + ```rust window.emit("custom-event", payload)?; ``` ### Handling Placeholders in Arguments + Game arguments may contain `${variable}` placeholders. Use the `has_unresolved_placeholder()` helper to skip malformed arguments (see [`main.rs:57-67`](../src-tauri/src/main.rs#L57-L67)). ## Important Notes -- **Dark mode enforced**: [`App.svelte`](../ui/src/App.svelte) force-adds `dark` class regardless of system preference +- **Dark mode enforced (legacy UI)**: [`App.svelte`](../packages/ui/src/App.svelte) force-adds `dark` class regardless of system preference - **Svelte 5 syntax**: Use `$state`, `$derived`, `$effect` (not `writable` stores) -- **No CREATE_NO_WINDOW on non-Windows**: Use `#[cfg(target_os = "windows")]` for Windows-specific code +- **Java launch on Windows**: Uses `CREATE_NO_WINDOW` behind `#[cfg(target_os = "windows")]` - **Version IDs**: Fabric uses `fabric-loader--`, Forge uses `-forge-` -- **Mod loader libraries**: Don't have `downloads.artifact`, use Maven resolution via [`maven.rs`](../src-tauri/src/core/maven.rs) +- **Library resolution**: When `downloads.artifact` is missing, resolve via Maven coordinates in [`maven.rs`](../src-tauri/src/core/maven.rs) - **Native extraction**: Extract to `versions//natives/`, exclude META-INF - **Classpath order**: Libraries → Client JAR (see [`main.rs:437-453`](../src-tauri/src/main.rs#L437-L453)) -- **Version management**: Single source in `_version.py`, synced to Cargo.toml and tauri.conf.json -- **Frontend dependencies**: Must use pnpm 9 + Node 22 (uses Rolldown-based Vite fork) +- **Version management**: `Cargo.toml` version is synced to `tauri.conf.json` via `pnpm bump-tauri` +- **Frontend dependencies**: Use Node 22 + pnpm 10 (Rolldown-based Vite fork) - **Store files**: Must have `.svelte.ts` extension, not `.ts` ## Debugging Tips @@ -272,15 +329,16 @@ Game arguments may contain `${variable}` placeholders. Use the `has_unresolved_p ## Version Compatibility - **Rust**: Edition 2021, requires Tauri v2 dependencies -- **Node.js**: 22+ with pnpm 9+ for frontend (uses Rolldown-based Vite fork `npm:rolldown-vite@7.2.5`) +- **Node.js**: 22+ with pnpm 10+ for frontend (uses Rolldown-based Vite fork `npm:rolldown-vite@^7`) - **Tauri**: v2.9+ - **Svelte**: v5.46+ (runes mode) -- **Java**: Supports detection of Java 8-23+, recommends Java 17+ for modern Minecraft +- **Java**: Required versions come from `javaVersion` in version JSON; Java 8 is enforced as a max for old versions - **Python**: 3.10+ for pre-commit hooks (validation only, not app logic) ## Commit Conventions Follow instructions in [`.github/instructions/commit.instructions.md`](.github/instructions/commit.instructions.md): + - **Format**: `[scope]: ` (lowercase, imperative, no period) - **AI commits**: MUST include `Reviewed-by: [MODEL_NAME]` - **Common types**: `feat`, `fix`, `docs`, `refactor`, `perf`, `test`, `chore` diff --git a/.gitignore b/.gitignore index 1419805a..22fcb7f0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ release/ # claude code .claude/ + +# Vscode +.vscode/ diff --git a/packages/ui/src/client.ts b/packages/ui/src/client.ts index 18d2377f..07398616 100644 --- a/packages/ui/src/client.ts +++ b/packages/ui/src/client.ts @@ -25,6 +25,13 @@ import type { VersionMetadata, } from "@/types"; +export interface InstanceRepairResult { + restoredInstances: number; + removedStaleEntries: number; + createdDefaultActive: boolean; + activeInstanceId: string | null; +} + export function assistantChat(messages: Message[]): Promise { return invoke("assistant_chat", { messages, @@ -119,6 +126,16 @@ export function duplicateInstance( }); } +export function exportInstance( + instanceId: string, + archivePath: string, +): Promise { + return invoke("export_instance", { + instanceId, + archivePath, + }); +} + export function fetchAdoptiumJava( majorVersion: number, imageType: string, @@ -267,6 +284,16 @@ export function installVersion( }); } +export function importInstance( + archivePath: string, + newName?: string, +): Promise { + return invoke("import_instance", { + archivePath, + newName, + }); +} + export function isFabricInstalled( instanceId: string, gameVersion: string, @@ -351,6 +378,10 @@ export function refreshJavaCatalog(): Promise { return invoke("refresh_java_catalog"); } +export function repairInstances(): Promise { + return invoke("repair_instances"); +} + export function resumeJavaDownloads(): Promise { return invoke("resume_java_downloads"); } @@ -383,6 +414,10 @@ export function startGame( }); } +export function stopGame(): Promise { + return invoke("stop_game"); +} + export function startMicrosoftLogin(): Promise { return invoke("start_microsoft_login"); } diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx index 0710c3ae..8f709852 100644 --- a/packages/ui/src/components/bottom-bar.tsx +++ b/packages/ui/src/components/bottom-bar.tsx @@ -1,11 +1,10 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { Play, User, XIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import { listInstalledVersions, startGame } from "@/client"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/models/auth"; import { useInstanceStore } from "@/models/instance"; +import { useGameStore } from "@/stores/game-store"; import { LoginModal } from "./login-modal"; import { Button } from "./ui/button"; import { @@ -18,150 +17,74 @@ import { } from "./ui/select"; import { Spinner } from "./ui/spinner"; -interface InstalledVersion { - id: string; - type: string; -} - export function BottomBar() { - const authStore = useAuthStore(); - const instancesStore = useInstanceStore(); + const account = useAuthStore((state) => state.account); + const instances = useInstanceStore((state) => state.instances); + const activeInstance = useInstanceStore((state) => state.activeInstance); + const setActiveInstance = useInstanceStore((state) => state.setActiveInstance); + const selectedVersion = useGameStore((state) => state.selectedVersion); + const setSelectedVersion = useGameStore((state) => state.setSelectedVersion); + const startGame = useGameStore((state) => state.startGame); + const stopGame = useGameStore((state) => state.stopGame); + const runningInstanceId = useGameStore((state) => state.runningInstanceId); + const launchingInstanceId = useGameStore((state) => state.launchingInstanceId); + const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId); - const [isLaunched, setIsLaunched] = useState(false); - const gameUnlisten = useRef(null); - const [isLaunching, setIsLaunching] = useState(false); - const [selectedVersion, setSelectedVersion] = useState(null); - const [installedVersions, setInstalledVersions] = useState< - InstalledVersion[] - >([]); - const [isLoadingVersions, setIsLoadingVersions] = useState(true); const [showLoginModal, setShowLoginModal] = useState(false); - const loadInstalledVersions = useCallback(async () => { - if (!instancesStore.activeInstance) { - setInstalledVersions([]); - setIsLoadingVersions(false); + useEffect(() => { + const nextVersion = activeInstance?.versionId ?? ""; + if (selectedVersion === nextVersion) { return; } - setIsLoadingVersions(true); - try { - const versions = await listInstalledVersions( - instancesStore.activeInstance.id, - ); - setInstalledVersions(versions); + setSelectedVersion(nextVersion); + }, [activeInstance?.id, activeInstance?.versionId, selectedVersion, setSelectedVersion]); - // If no version is selected but we have installed versions, select the first one - if (!selectedVersion && versions.length > 0) { - setSelectedVersion(versions[0].id); + const handleInstanceChange = useCallback( + async (instanceId: string) => { + if (activeInstance?.id === instanceId) { + return; } - } catch (error) { - console.error("Failed to load installed versions:", error); - } finally { - setIsLoadingVersions(false); - } - }, [instancesStore.activeInstance, selectedVersion]); - useEffect(() => { - loadInstalledVersions(); - - // Listen for backend events that should refresh installed versions. - let unlistenDownload: UnlistenFn | null = null; - let unlistenVersionDeleted: UnlistenFn | null = null; - - (async () => { - try { - unlistenDownload = await listen("download-complete", () => { - loadInstalledVersions(); - }); - } catch (err) { - // best-effort: do not break UI if listening fails - // eslint-disable-next-line no-console - console.warn("Failed to attach download-complete listener:", err); + const nextInstance = instances.find((instance) => instance.id === instanceId); + if (!nextInstance) { + return; } try { - unlistenVersionDeleted = await listen("version-deleted", () => { - loadInstalledVersions(); - }); - } catch (err) { - // eslint-disable-next-line no-console - console.warn("Failed to attach version-deleted listener:", err); + await setActiveInstance(nextInstance); + } catch (error) { + console.error("Failed to activate instance:", error); + toast.error(`Failed to activate instance: ${String(error)}`); } - })(); - - return () => { - try { - if (unlistenDownload) unlistenDownload(); - } catch { - // ignore - } - try { - if (unlistenVersionDeleted) unlistenVersionDeleted(); - } catch { - // ignore - } - }; - }, [loadInstalledVersions]); + }, + [activeInstance?.id, instances, setActiveInstance], + ); const handleStartGame = async () => { - if (!selectedVersion) { - toast.info("Please select a version!"); - return; - } - - if (!instancesStore.activeInstance) { + if (!activeInstance) { toast.info("Please select an instance first!"); return; } - try { - gameUnlisten.current = await listen("game-exited", () => { - setIsLaunched(false); - }); - } catch (error) { - toast.warning(`Failed to listen to game-exited event: ${error}`); - } - - setIsLaunching(true); - try { - await startGame(instancesStore.activeInstance?.id, selectedVersion); - setIsLaunched(true); - } catch (error) { - console.error(`Failed to start game: ${error}`); - toast.error(`Failed to start game: ${error}`); - } finally { - setIsLaunching(false); - } + await startGame( + account, + () => setShowLoginModal(true), + activeInstance.id, + selectedVersion || activeInstance.versionId, + () => undefined, + ); }; - const getVersionTypeColor = (type: string) => { - switch (type) { - case "release": - return "bg-emerald-500"; - case "snapshot": - return "bg-amber-500"; - case "old_beta": - return "bg-rose-500"; - case "old_alpha": - return "bg-violet-500"; - default: - return "bg-gray-500"; - } + const handleStopGame = async () => { + await stopGame(runningInstanceId); }; - const versionOptions = useMemo( - () => - installedVersions.map((v) => ({ - label: `${v.id}${v.type !== "release" ? ` (${v.type})` : ""}`, - value: v.id, - type: v.type, - })), - [installedVersions], - ); - const renderButton = () => { - if (!authStore.account) { + const isGameRunning = runningInstanceId !== null; + + if (!account) { return ( - ) : ( + if (isGameRunning) { + return ( + + ); + } + + return ( ); @@ -206,40 +129,39 @@ export function BottomBar() {
-
-
- - Active Instance - - - {instancesStore.activeInstance?.name || "No instance selected"} - -
- +