diff options
| author | 2026-01-14 05:16:31 +0100 | |
|---|---|---|
| committer | 2026-01-14 05:16:31 +0100 | |
| commit | f8790b62643cba62b8f329e93e5e3566394441d7 (patch) | |
| tree | f3be16274ad1203e2f8ae4aeffeaf1102c580f4d | |
| parent | f093d2a310627aa3ee5a2820339f8a18bd251e81 (diff) | |
| parent | e8e139c07d05e2f29f04906019dff5f3c520f8cc (diff) | |
| download | DropOut-f8790b62643cba62b8f329e93e5e3566394441d7.tar.gz DropOut-f8790b62643cba62b8f329e93e5e3566394441d7.zip | |
Merge branch 'main' into feat/download-java-rt
| -rw-r--r-- | src-tauri/Cargo.toml | 2 | ||||
| -rw-r--r-- | src-tauri/tauri.conf.json | 2 | ||||
| -rw-r--r-- | ui/src/App.svelte | 191 | ||||
| -rw-r--r-- | ui/src/components/BottomBar.svelte | 88 | ||||
| -rw-r--r-- | ui/src/components/HomeView.svelte | 26 | ||||
| -rw-r--r-- | ui/src/components/LoginModal.svelte | 126 | ||||
| -rw-r--r-- | ui/src/components/SettingsView.svelte | 129 | ||||
| -rw-r--r-- | ui/src/components/Sidebar.svelte | 66 | ||||
| -rw-r--r-- | ui/src/components/StatusToast.svelte | 36 | ||||
| -rw-r--r-- | ui/src/components/VersionsView.svelte | 34 | ||||
| -rw-r--r-- | ui/src/stores/auth.svelte.ts | 141 | ||||
| -rw-r--r-- | ui/src/stores/game.svelte.ts | 48 | ||||
| -rw-r--r-- | ui/src/stores/settings.svelte.ts | 57 | ||||
| -rw-r--r-- | ui/src/stores/ui.svelte.ts | 32 | ||||
| -rw-r--r-- | ui/src/types/index.ts | 38 |
15 files changed, 853 insertions, 163 deletions
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 860a862..0387526 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dropout" -version = "0.1.12" +version = "0.1.13" edition = "2021" authors = ["HsiangNianian"] description = "The DropOut Minecraft Game Launcher" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c8703a4..7d2b0a3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "dropout", - "version": "0.1.12", + "version": "0.1.13", "identifier": "com.dropout.launcher", "build": { "beforeDevCommand": "cd ../ui && pnpm dev", diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 02cc173..b637512 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,7 +1,5 @@ <script lang="ts"> - import { invoke } from "@tauri-apps/api/core"; import { getVersion } from "@tauri-apps/api/app"; - import { open } from "@tauri-apps/plugin-shell"; import { onMount } from "svelte"; import DownloadMonitor from "./lib/DownloadMonitor.svelte"; import GameConsole from "./lib/GameConsole.svelte"; @@ -95,22 +93,27 @@ let msLoginLoading = false; let msLoginStatus = "Waiting for authorization..."; let isPollingRequestActive = false; + + // Components + import Sidebar from "./components/Sidebar.svelte"; + import HomeView from "./components/HomeView.svelte"; + import VersionsView from "./components/VersionsView.svelte"; + import SettingsView from "./components/SettingsView.svelte"; + import BottomBar from "./components/BottomBar.svelte"; + import LoginModal from "./components/LoginModal.svelte"; + import StatusToast from "./components/StatusToast.svelte"; + + // Stores + import { uiState } from "./stores/ui.svelte"; + import { authState } from "./stores/auth.svelte"; + import { settingsState } from "./stores/settings.svelte"; + import { gameState } from "./stores/game.svelte"; onMount(async () => { - checkAccount(); - loadSettings(); - getVersion().then((v) => (appVersion = v)); - try { - versions = await invoke("get_versions"); - if (versions.length > 0) { - // Find latest release or default to first - const latest = versions.find((v) => v.type === "release"); - selectedVersion = latest ? latest.id : versions[0].id; - } - } catch (e) { - console.error("Failed to fetch versions:", e); - status = "Error fetching versions: " + e; - } + authState.checkAccount(); + settingsState.loadSettings(); + gameState.loadVersions(); + getVersion().then((v) => (uiState.appVersion = v)); }); async function checkAccount() { @@ -374,69 +377,7 @@ <div class="flex h-screen bg-zinc-900 text-white font-sans overflow-hidden select-none" > - <!-- Sidebar --> - <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" - > - <div - class="h-20 w-full flex items-center justify-center lg:justify-start lg:px-6 border-b border-zinc-800/50" - > - <!-- Icon Logo (Visible on small) --> - <div - class="lg:hidden text-2xl font-black bg-clip-text text-transparent bg-gradient-to-tr from-indigo-400 to-purple-400" - > - D - </div> - <!-- Full Logo (Visible on large) --> - <div - class="hidden lg:block font-bold text-xl tracking-wider text-indigo-400" - > - DROP<span class="text-white">OUT</span> - </div> - </div> - - <nav class="flex-1 w-full flex flex-col gap-2 p-3"> - <button - class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {currentView === - 'home' - ? 'bg-zinc-800/80 text-white' - : 'text-zinc-400'} transition-all relative" - onclick={() => (currentView = "home")} - > - <span class="text-xl relative z-10">🏠</span> - <span - class="hidden lg:block font-medium relative z-10 transition-opacity" - >Home</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 {currentView === - 'versions' - ? 'bg-zinc-800/80 text-white' - : 'text-zinc-400'} transition-all" - onclick={() => (currentView = "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 {currentView === - 'settings' - ? 'bg-zinc-800/80 text-white' - : 'text-zinc-400'} transition-all" - onclick={() => (currentView = "settings")} - > - <span class="text-xl">⚙️</span> - <span class="hidden lg:block font-medium">Settings</span> - </button> - </nav> - - <div - class="p-4 w-full border-t border-zinc-800 flex justify-center lg:justify-start" - > - <div class="text-xs text-zinc-600 font-mono">v{appVersion}</div> - </div> - </aside> + <Sidebar /> <!-- Main Content --> <main class="flex-1 flex flex-col relative min-w-0"> @@ -633,90 +574,16 @@ </div> </div> </div> + {#if uiState.currentView === "home"} + <HomeView /> + {:else if uiState.currentView === "versions"} + <VersionsView /> + {:else if uiState.currentView === "settings"} + <SettingsView /> {/if} </div> - <!-- Bottom Bar --> - <div - class="h-24 bg-zinc-900 border-t border-zinc-800 flex items-center px-8 justify-between z-20 shadow-2xl" - > - <div class="flex items-center gap-4"> - <div - class="flex items-center gap-4 cursor-pointer hover:opacity-80 transition-opacity" - onclick={openLoginModal} - role="button" - tabindex="0" - onkeydown={(e) => e.key === "Enter" && 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" - > - {#if currentAccount} - <img - src={`https://minotar.net/avatar/${currentAccount.username}/48`} - alt={currentAccount.username} - class="w-full h-full" - /> - {:else} - ? - {/if} - </div> - <div> - <div class="font-bold text-white text-lg"> - {currentAccount ? currentAccount.username : "Click to Login"} - </div> - <div class="text-xs text-zinc-400 flex items-center gap-1"> - <span - class="w-1.5 h-1.5 rounded-full {currentAccount - ? 'bg-green-500' - : 'bg-zinc-500'}" - ></span> - {currentAccount ? "Ready" : "Guest"} - </div> - </div> - </div> - <!-- Console Toggle --> - <button - class="ml-4 text-xs text-zinc-500 hover:text-zinc-300 transition" - onclick={() => (showConsole = !showConsole)} - > - {showConsole ? "Hide Logs" : "Show Logs"} - </button> - </div> - - <div class="flex items-center gap-4"> - <div class="flex flex-col items-end mr-2"> - <label - class="text-xs text-zinc-500 mb-1 uppercase font-bold tracking-wider" - >Version</label - > - <select - bind:value={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 versions.length === 0} - <option>Loading...</option> - {:else} - {#each versions as version} - <option value={version.id}>{version.id} ({version.type})</option - > - {/each} - {/if} - </select> - </div> - - <button - onclick={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" - > - Play - <span - class="text-[10px] font-normal opacity-80 normal-case tracking-normal" - >Click to launch</span - > - </button> - </div> - </div> + <BottomBar /> </main> <!-- Login Modal --> @@ -960,6 +827,8 @@ } } </style> + <LoginModal /> + <StatusToast /> - <GameConsole visible={showConsole} /> + <GameConsole visible={uiState.showConsole} /> </div> diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte new file mode 100644 index 0000000..dcad9e8 --- /dev/null +++ b/ui/src/components/BottomBar.svelte @@ -0,0 +1,88 @@ +<script lang="ts"> + import { authState } from "../stores/auth.svelte"; + import { gameState } from "../stores/game.svelte"; + import { uiState } from "../stores/ui.svelte"; +</script> + +<div + class="h-24 bg-zinc-900 border-t border-zinc-800 flex items-center px-8 justify-between z-20 shadow-2xl" +> + <div class="flex items-center gap-4"> + <div + class="flex items-center gap-4 cursor-pointer hover:opacity-80 transition-opacity" + 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" + > + {#if authState.currentAccount} + <img + src={`https://minotar.net/avatar/${authState.currentAccount.username}/48`} + alt={authState.currentAccount.username} + class="w-full h-full" + /> + {:else} + ? + {/if} + </div> + <div> + <div class="font-bold text-white text-lg"> + {authState.currentAccount ? authState.currentAccount.username : "Click to Login"} + </div> + <div class="text-xs text-zinc-400 flex items-center gap-1"> + <span + class="w-1.5 h-1.5 rounded-full {authState.currentAccount + ? 'bg-green-500' + : 'bg-zinc-500'}" + ></span> + {authState.currentAccount ? "Ready" : "Guest"} + </div> + </div> + </div> + <!-- Console Toggle --> + <button + class="ml-4 text-xs text-zinc-500 hover:text-zinc-300 transition" + onclick={() => uiState.toggleConsole()} + > + {uiState.showConsole ? "Hide Logs" : "Show Logs"} + </button> + </div> + + <div class="flex items-center gap-4"> + <div class="flex flex-col items-end mr-2"> + <label + for="version-select" + class="text-xs text-zinc-500 mb-1 uppercase font-bold tracking-wider" + >Version</label + > + <select + id="version-select" + bind:value={gameState.selectedVersion} + class="bg-zinc-950 text-zinc-200 border border-zinc-700 rounded px-4 py-2 hover:border-zinc-500 transition-colors cursor-pointer outline-none focus:ring-1 focus:ring-indigo-500 w-48" + > + {#if gameState.versions.length === 0} + <option>Loading...</option> + {:else} + {#each gameState.versions as version} + <option value={version.id}>{version.id} ({version.type})</option + > + {/each} + {/if} + </select> + </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" + > + Play + <span + class="text-[10px] font-normal opacity-80 normal-case tracking-normal" + >Click to launch</span + > + </button> + </div> +</div> diff --git a/ui/src/components/HomeView.svelte b/ui/src/components/HomeView.svelte new file mode 100644 index 0000000..e876c14 --- /dev/null +++ b/ui/src/components/HomeView.svelte @@ -0,0 +1,26 @@ +<script lang="ts"> + // No script needed currently, just static markup mostly +</script> + +<!-- Background Image - Using gradient fallback --> +<div + class="absolute inset-0 z-0 opacity-60 bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 transition-transform duration-[10s] ease-linear group-hover:scale-105" +></div> +<div + class="absolute inset-0 z-0 bg-gradient-to-t from-zinc-900 via-transparent to-black/50" +></div> + +<div class="absolute bottom-24 left-8 z-10 p-4"> + <h1 + class="text-6xl font-black mb-2 tracking-tight text-white drop-shadow-lg" + > + MINECRAFT + </h1> + <div class="flex items-center gap-2 text-zinc-300"> + <span + class="bg-zinc-800 text-xs px-2 py-1 rounded border border-zinc-600" + >JAVA EDITION</span + > + <span class="text-lg">Release 1.20.4</span> + </div> +</div> diff --git a/ui/src/components/LoginModal.svelte b/ui/src/components/LoginModal.svelte new file mode 100644 index 0000000..f1ac0d5 --- /dev/null +++ b/ui/src/components/LoginModal.svelte @@ -0,0 +1,126 @@ +<script lang="ts"> + import { open } from "@tauri-apps/plugin-shell"; + import { authState } from "../stores/auth.svelte"; + + function openLink(url: string) { + open(url); + } +</script> + +{#if authState.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={() => authState.closeLoginModal()} + class="text-zinc-500 hover:text-white transition group" + > + ✕ + </button> + </div> + + {#if authState.loginMode === "select"} + <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" + > + <!-- 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={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" + 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" + > + Offline Login + </button> + </div> + </div> + {:else if authState.loginMode === "microsoft"} + <div class="text-center"> + {#if authState.msLoginLoading && !authState.deviceCodeData} + <div class="py-8 text-zinc-400 animate-pulse"> + Starting login flow... + </div> + {:else if authState.deviceCodeData} + <div class="space-y-4"> + <p class="text-sm 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" + > + {authState.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" + role="button" + tabindex="0" + onkeydown={(e) => e.key === 'Enter' && navigator.clipboard.writeText(authState.deviceCodeData?.user_code || "")} + onclick={() => + navigator.clipboard.writeText( + authState.deviceCodeData?.user_code || "" + )} + > + {authState.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">{authState.msLoginStatus}</span> + </div> + <p class="text-xs text-zinc-600">This window will update automatically.</p> + </div> + + <button + onclick={() => { authState.stopPolling(); authState.loginMode = "select"; }} + class="text-xs text-zinc-500 hover:text-zinc-300 mt-6 underline" + >Cancel</button + > + </div> + {/if} + </div> + {/if} + </div> + </div> +{/if} diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte new file mode 100644 index 0000000..9f260c1 --- /dev/null +++ b/ui/src/components/SettingsView.svelte @@ -0,0 +1,129 @@ +<script lang="ts"> + import { settingsState } from "../stores/settings.svelte"; +</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="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> + + {#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> + {#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'}" + > + <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 settingsState.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"> + <h3 + class="block text-sm font-bold text-zinc-400 mb-4 uppercase tracking-wide" + >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 + > + <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" + /> + </div> + <div> + <label for="max-memory" class="block text-xs text-zinc-500 mb-1" + >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" + /> + </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="grid grid-cols-2 gap-6"> + <div> + <label for="window-width" class="block text-xs text-zinc-500 mb-1">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" + /> + </div> + <div> + <label for="window-height" class="block text-xs text-zinc-500 mb-1">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" + /> + </div> + </div> + </div> + + <div class="pt-4"> + <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" + > + Save Settings + </button> + </div> + </div> +</div> diff --git a/ui/src/components/Sidebar.svelte b/ui/src/components/Sidebar.svelte new file mode 100644 index 0000000..a4f4e35 --- /dev/null +++ b/ui/src/components/Sidebar.svelte @@ -0,0 +1,66 @@ +<script lang="ts"> + import { uiState } from '../stores/ui.svelte'; +</script> + +<aside + class="w-20 lg:w-64 bg-zinc-950 flex flex-col items-center lg:items-start transition-all duration-300 border-r border-zinc-800 shrink-0" +> + <div + class="h-20 w-full flex items-center justify-center lg:justify-start lg:px-6 border-b border-zinc-800/50" + > + <!-- Icon Logo (Visible on small) --> + <div + class="lg:hidden text-2xl font-black bg-clip-text text-transparent bg-gradient-to-tr from-indigo-400 to-purple-400" + > + D + </div> + <!-- Full Logo (Visible on large) --> + <div + class="hidden lg:block font-bold text-xl tracking-wider text-indigo-400" + > + DROP<span class="text-white">OUT</span> + </div> + </div> + + <nav class="flex-1 w-full flex flex-col gap-2 p-3"> + <button + class="group flex items-center lg:gap-4 justify-center lg:justify-start w-full px-0 lg:px-4 py-3 rounded-lg hover:bg-zinc-800 {uiState.currentView === + 'home' + ? 'bg-zinc-800/80 text-white' + : 'text-zinc-400'} transition-all relative" + onclick={() => uiState.setView("home")} + > + <span class="text-xl relative z-10">🏠</span> + <span + class="hidden lg:block font-medium relative z-10 transition-opacity" + >Home</span + > + </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> + </nav> + + <div + class="p-4 w-full border-t border-zinc-800 flex justify-center lg:justify-start" + > + <div class="text-xs text-zinc-600 font-mono">v{uiState.appVersion}</div> + </div> +</aside> diff --git a/ui/src/components/StatusToast.svelte b/ui/src/components/StatusToast.svelte new file mode 100644 index 0000000..b1feffc --- /dev/null +++ b/ui/src/components/StatusToast.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + import { uiState } from "../stores/ui.svelte"; +</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> + </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> +{/if} + +<style> + @keyframes progress { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } + } +</style> diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte new file mode 100644 index 0000000..8c0ddfe --- /dev/null +++ b/ui/src/components/VersionsView.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import { gameState } from "../stores/game.svelte"; +</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> + {#if gameState.selectedVersion === version.id} + <div class="text-green-500 font-bold text-sm">SELECTED</div> + {/if} + </button> + {/each} + {/if} + </div> +</div> diff --git a/ui/src/stores/auth.svelte.ts b/ui/src/stores/auth.svelte.ts new file mode 100644 index 0000000..3d58245 --- /dev/null +++ b/ui/src/stores/auth.svelte.ts @@ -0,0 +1,141 @@ +import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-shell"; +import type { Account, DeviceCodeResponse } from "../types"; +import { uiState } from "./ui.svelte"; + +export class AuthState { + currentAccount = $state<Account | null>(null); + isLoginModalOpen = $state(false); + loginMode = $state<"select" | "offline" | "microsoft">("select"); + offlineUsername = $state(""); + deviceCodeData = $state<DeviceCodeResponse | null>(null); + msLoginLoading = $state(false); + msLoginStatus = $state("Waiting for authorization..."); + + private pollInterval: ReturnType<typeof setInterval> | null = null; + private isPollingRequestActive = false; + + async checkAccount() { + try { + const acc = await invoke("get_active_account"); + this.currentAccount = acc as Account | null; + } catch (e) { + console.error("Failed to check account:", e); + } + } + + openLoginModal() { + if (this.currentAccount) { + if (confirm("Logout " + this.currentAccount.username + "?")) { + invoke("logout").then(() => (this.currentAccount = null)); + } + return; + } + this.resetLoginState(); + this.isLoginModalOpen = true; + } + + closeLoginModal() { + this.stopPolling(); + this.isLoginModalOpen = false; + } + + resetLoginState() { + this.loginMode = "select"; + this.offlineUsername = ""; + this.deviceCodeData = null; + this.msLoginLoading = false; + } + + async performOfflineLogin() { + if (!this.offlineUsername) return; + try { + this.currentAccount = (await invoke("login_offline", { + username: this.offlineUsername, + })) as Account; + this.isLoginModalOpen = false; + } catch (e) { + alert("Login failed: " + e); + } + } + + async startMicrosoftLogin() { + this.loginMode = "microsoft"; + this.msLoginLoading = true; + this.msLoginStatus = "Waiting for authorization..."; + this.stopPolling(); + + try { + this.deviceCodeData = (await invoke( + "start_microsoft_login" + )) as DeviceCodeResponse; + + if (this.deviceCodeData) { + try { + await navigator.clipboard.writeText(this.deviceCodeData.user_code); + } catch (e) { + console.error("Clipboard failed", e); + } + + open(this.deviceCodeData.verification_uri); + + console.log("Starting polling for token..."); + const intervalMs = (this.deviceCodeData.interval || 5) * 1000; + this.pollInterval = setInterval( + () => this.checkLoginStatus(this.deviceCodeData!.device_code), + intervalMs + ); + } + } catch (e) { + alert("Failed to start Microsoft login: " + e); + this.loginMode = "select"; + } finally { + this.msLoginLoading = false; + } + } + + stopPolling() { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + } + + async checkLoginStatus(deviceCode: string) { + if (this.isPollingRequestActive) return; + this.isPollingRequestActive = true; + + console.log("Polling Microsoft API..."); + try { + this.currentAccount = (await invoke("complete_microsoft_login", { + deviceCode, + })) as Account; + + console.log("Login Successful!", this.currentAccount); + this.stopPolling(); + this.isLoginModalOpen = false; + uiState.setStatus("Welcome back, " + this.currentAccount.username); + } catch (e: any) { + const errStr = e.toString(); + if (errStr.includes("authorization_pending")) { + console.log("Status: Waiting for user to authorize..."); + } else { + console.error("Polling Error:", errStr); + this.msLoginStatus = "Error: " + errStr; + + if ( + errStr.includes("expired_token") || + errStr.includes("access_denied") + ) { + this.stopPolling(); + alert("Login failed: " + errStr); + this.loginMode = "select"; + } + } + } finally { + this.isPollingRequestActive = false; + } + } +} + +export const authState = new AuthState(); diff --git a/ui/src/stores/game.svelte.ts b/ui/src/stores/game.svelte.ts new file mode 100644 index 0000000..0af3daf --- /dev/null +++ b/ui/src/stores/game.svelte.ts @@ -0,0 +1,48 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { Version } from "../types"; +import { uiState } from "./ui.svelte"; +import { authState } from "./auth.svelte"; + +export class GameState { + versions = $state<Version[]>([]); + selectedVersion = $state(""); + + async loadVersions() { + try { + this.versions = await invoke<Version[]>("get_versions"); + if (this.versions.length > 0) { + const latest = this.versions.find((v) => v.type === "release"); + this.selectedVersion = latest ? latest.id : this.versions[0].id; + } + } catch (e) { + console.error("Failed to fetch versions:", e); + uiState.setStatus("Error fetching versions: " + e); + } + } + + async startGame() { + if (!authState.currentAccount) { + alert("Please login first!"); + authState.openLoginModal(); + return; + } + + if (!this.selectedVersion) { + alert("Please select a version!"); + return; + } + + uiState.setStatus("Preparing to launch " + this.selectedVersion + "..."); + console.log("Invoking start_game for version:", this.selectedVersion); + try { + const msg = await invoke<string>("start_game", { versionId: this.selectedVersion }); + console.log("Response:", msg); + uiState.setStatus(msg); + } catch (e) { + console.error(e); + uiState.setStatus("Error: " + e); + } + } +} + +export const gameState = new GameState(); diff --git a/ui/src/stores/settings.svelte.ts b/ui/src/stores/settings.svelte.ts new file mode 100644 index 0000000..989172c --- /dev/null +++ b/ui/src/stores/settings.svelte.ts @@ -0,0 +1,57 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { LauncherConfig, JavaInstallation } from "../types"; +import { uiState } from "./ui.svelte"; + +export class SettingsState { + settings = $state<LauncherConfig>({ + min_memory: 1024, + max_memory: 2048, + java_path: "java", + width: 854, + height: 480, + }); + javaInstallations = $state<JavaInstallation[]>([]); + isDetectingJava = $state(false); + + async loadSettings() { + try { + const result = await invoke<LauncherConfig>("get_settings"); + this.settings = result; + } catch (e) { + console.error("Failed to load settings:", e); + } + } + + async saveSettings() { + try { + await invoke("save_settings", { config: this.settings }); + uiState.setStatus("Settings saved!"); + } catch (e) { + console.error("Failed to save settings:", e); + uiState.setStatus("Error saving settings: " + e); + } + } + + async detectJava() { + this.isDetectingJava = true; + try { + this.javaInstallations = await invoke("detect_java"); + if (this.javaInstallations.length === 0) { + uiState.setStatus("No Java installations found"); + } else { + uiState.setStatus(`Found ${this.javaInstallations.length} Java installation(s)`); + } + } catch (e) { + console.error("Failed to detect Java:", e); + uiState.setStatus("Error detecting Java: " + e); + } finally { + this.isDetectingJava = false; + } + } + + selectJava(path: string) { + this.settings.java_path = path; + } +} + +export const settingsState = new SettingsState(); diff --git a/ui/src/stores/ui.svelte.ts b/ui/src/stores/ui.svelte.ts new file mode 100644 index 0000000..9c29c25 --- /dev/null +++ b/ui/src/stores/ui.svelte.ts @@ -0,0 +1,32 @@ +import { type ViewType } from "../types"; + +export class UIState { + currentView: ViewType = $state("home"); + status = $state("Ready"); + showConsole = $state(false); + appVersion = $state("..."); + + private statusTimeout: ReturnType<typeof setTimeout> | null = null; + + setStatus(msg: string) { + if (this.statusTimeout) clearTimeout(this.statusTimeout); + + this.status = msg; + + if (msg !== "Ready") { + this.statusTimeout = setTimeout(() => { + this.status = "Ready"; + }, 5000); + } + } + + toggleConsole() { + this.showConsole = !this.showConsole; + } + + setView(view: ViewType) { + this.currentView = view; + } +} + +export const uiState = new UIState(); diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts new file mode 100644 index 0000000..b7ff0a0 --- /dev/null +++ b/ui/src/types/index.ts @@ -0,0 +1,38 @@ +export type ViewType = "home" | "versions" | "settings"; + +export interface Version { + id: string; + type: string; + url: string; + time: string; + releaseTime: string; +} + +export interface Account { + type: "Offline" | "Microsoft"; + username: string; + uuid: string; +} + +export interface DeviceCodeResponse { + user_code: string; + device_code: string; + verification_uri: string; + expires_in: number; + interval: number; + message?: string; +} + +export interface LauncherConfig { + min_memory: number; + max_memory: number; + java_path: string; + width: number; + height: number; +} + +export interface JavaInstallation { + path: string; + version: string; + is_64bit: boolean; +} |