aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/stores
diff options
context:
space:
mode:
author苏向夜 <fu050409@163.com>2026-01-19 14:17:32 +0800
committer苏向夜 <fu050409@163.com>2026-01-19 14:17:32 +0800
commitda0d79f0db873c08fab3bc85023167e174d18b0e (patch)
tree4a1934780d0d723ec8b834088188d4714f2cf3e7 /packages/ui/src/stores
parent887e415314014c3da7db3048fa0e724f3078c5cb (diff)
downloadDropOut-da0d79f0db873c08fab3bc85023167e174d18b0e.tar.gz
DropOut-da0d79f0db873c08fab3bc85023167e174d18b0e.zip
chore(ui): refactor workspace to monorepo
Diffstat (limited to 'packages/ui/src/stores')
-rw-r--r--packages/ui/src/stores/assistant.svelte.ts166
-rw-r--r--packages/ui/src/stores/auth.svelte.ts192
-rw-r--r--packages/ui/src/stores/game.svelte.ts78
-rw-r--r--packages/ui/src/stores/instances.svelte.ts109
-rw-r--r--packages/ui/src/stores/logs.svelte.ts151
-rw-r--r--packages/ui/src/stores/releases.svelte.ts36
-rw-r--r--packages/ui/src/stores/settings.svelte.ts570
-rw-r--r--packages/ui/src/stores/ui.svelte.ts32
8 files changed, 1334 insertions, 0 deletions
diff --git a/packages/ui/src/stores/assistant.svelte.ts b/packages/ui/src/stores/assistant.svelte.ts
new file mode 100644
index 0000000..a3f47ea
--- /dev/null
+++ b/packages/ui/src/stores/assistant.svelte.ts
@@ -0,0 +1,166 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+
+export interface GenerationStats {
+ total_duration: number;
+ load_duration: number;
+ prompt_eval_count: number;
+ prompt_eval_duration: number;
+ eval_count: number;
+ eval_duration: number;
+}
+
+export interface Message {
+ role: "user" | "assistant" | "system";
+ content: string;
+ stats?: GenerationStats;
+}
+
+interface StreamChunk {
+ content: string;
+ done: boolean;
+ stats?: GenerationStats;
+}
+
+// Module-level state using $state
+let messages = $state<Message[]>([]);
+let isProcessing = $state(false);
+let isProviderHealthy = $state(false);
+let streamingContent = "";
+let initialized = false;
+let streamUnlisten: UnlistenFn | null = null;
+
+async function init() {
+ if (initialized) return;
+ initialized = true;
+ await checkHealth();
+}
+
+async function checkHealth() {
+ try {
+ isProviderHealthy = await invoke("assistant_check_health");
+ } catch (e) {
+ console.error("Failed to check provider health:", e);
+ isProviderHealthy = false;
+ }
+}
+
+function finishStreaming() {
+ isProcessing = false;
+ streamingContent = "";
+ if (streamUnlisten) {
+ streamUnlisten();
+ streamUnlisten = null;
+ }
+}
+
+async function sendMessage(
+ content: string,
+ isEnabled: boolean,
+ provider: string,
+ endpoint: string,
+) {
+ if (!content.trim()) return;
+ if (!isEnabled) {
+ messages = [
+ ...messages,
+ {
+ role: "assistant",
+ content: "Assistant is disabled. Enable it in Settings > AI Assistant.",
+ },
+ ];
+ return;
+ }
+
+ // Add user message
+ messages = [...messages, { role: "user", content }];
+ isProcessing = true;
+ streamingContent = "";
+
+ // Add empty assistant message for streaming
+ messages = [...messages, { role: "assistant", content: "" }];
+
+ try {
+ // Set up stream listener
+ streamUnlisten = await listen<StreamChunk>("assistant-stream", (event) => {
+ const chunk = event.payload;
+
+ if (chunk.content) {
+ streamingContent += chunk.content;
+ // Update the last message (assistant's response)
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ ...messages[lastIdx],
+ content: streamingContent,
+ };
+ // Trigger reactivity
+ messages = [...messages];
+ }
+ }
+
+ if (chunk.done) {
+ if (chunk.stats) {
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ ...messages[lastIdx],
+ stats: chunk.stats,
+ };
+ messages = [...messages];
+ }
+ }
+ finishStreaming();
+ }
+ });
+
+ // Start streaming chat
+ await invoke<string>("assistant_chat_stream", {
+ messages: messages.slice(0, -1), // Exclude the empty assistant message
+ });
+ } catch (e) {
+ console.error("Failed to send message:", e);
+ const errorMessage = e instanceof Error ? e.message : String(e);
+
+ let helpText = "";
+ if (provider === "ollama") {
+ helpText = `\n\nPlease ensure Ollama is running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ helpText = "\n\nPlease check your OpenAI API key in Settings.";
+ }
+
+ // Update the last message with error
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ role: "assistant",
+ content: `Error: ${errorMessage}${helpText}`,
+ };
+ messages = [...messages];
+ }
+
+ finishStreaming();
+ }
+}
+
+function clearHistory() {
+ messages = [];
+ streamingContent = "";
+}
+
+// Export as an object with getters for reactive access
+export const assistantState = {
+ get messages() {
+ return messages;
+ },
+ get isProcessing() {
+ return isProcessing;
+ },
+ get isProviderHealthy() {
+ return isProviderHealthy;
+ },
+ init,
+ checkHealth,
+ sendMessage,
+ clearHistory,
+};
diff --git a/packages/ui/src/stores/auth.svelte.ts b/packages/ui/src/stores/auth.svelte.ts
new file mode 100644
index 0000000..1b613a7
--- /dev/null
+++ b/packages/ui/src/stores/auth.svelte.ts
@@ -0,0 +1,192 @@
+import { invoke } from "@tauri-apps/api/core";
+import { open } from "@tauri-apps/plugin-shell";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import type { Account, DeviceCodeResponse } from "../types";
+import { uiState } from "./ui.svelte";
+import { logsState } from "./logs.svelte";
+
+export class AuthState {
+ currentAccount = $state<Account | null>(null);
+ isLoginModalOpen = $state(false);
+ isLogoutConfirmOpen = $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;
+ private authProgressUnlisten: UnlistenFn | null = null;
+
+ 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) {
+ // Show custom logout confirmation dialog
+ this.isLogoutConfirmOpen = true;
+ return;
+ }
+ this.resetLoginState();
+ this.isLoginModalOpen = true;
+ }
+
+ cancelLogout() {
+ this.isLogoutConfirmOpen = false;
+ }
+
+ async confirmLogout() {
+ this.isLogoutConfirmOpen = false;
+ try {
+ await invoke("logout");
+ this.currentAccount = null;
+ uiState.setStatus("Logged out successfully");
+ } catch (e) {
+ console.error("Logout failed:", e);
+ }
+ }
+
+ 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();
+
+ // Setup auth progress listener
+ this.setupAuthProgressListener();
+
+ 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);
+ logsState.addLog(
+ "info",
+ "Auth",
+ "Microsoft login started, waiting for browser authorization...",
+ );
+
+ 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) {
+ logsState.addLog("error", "Auth", `Failed to start Microsoft login: ${e}`);
+ alert("Failed to start Microsoft login: " + e);
+ this.loginMode = "select";
+ } finally {
+ this.msLoginLoading = false;
+ }
+ }
+
+ private async setupAuthProgressListener() {
+ // Clean up previous listener if exists
+ if (this.authProgressUnlisten) {
+ this.authProgressUnlisten();
+ this.authProgressUnlisten = null;
+ }
+
+ this.authProgressUnlisten = await listen<string>("auth-progress", (event) => {
+ const message = event.payload;
+ this.msLoginStatus = message;
+ logsState.addLog("info", "Auth", message);
+ });
+ }
+
+ private cleanupAuthListener() {
+ if (this.authProgressUnlisten) {
+ this.authProgressUnlisten();
+ this.authProgressUnlisten = null;
+ }
+ }
+
+ 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.cleanupAuthListener();
+ this.isLoginModalOpen = false;
+ logsState.addLog(
+ "info",
+ "Auth",
+ `Login successful! Welcome, ${this.currentAccount.username}`,
+ );
+ 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;
+ logsState.addLog("error", "Auth", `Login error: ${errStr}`);
+
+ if (errStr.includes("expired_token") || errStr.includes("access_denied")) {
+ this.stopPolling();
+ this.cleanupAuthListener();
+ alert("Login failed: " + errStr);
+ this.loginMode = "select";
+ }
+ }
+ } finally {
+ this.isPollingRequestActive = false;
+ }
+ }
+}
+
+export const authState = new AuthState();
diff --git a/packages/ui/src/stores/game.svelte.ts b/packages/ui/src/stores/game.svelte.ts
new file mode 100644
index 0000000..504d108
--- /dev/null
+++ b/packages/ui/src/stores/game.svelte.ts
@@ -0,0 +1,78 @@
+import { invoke } from "@tauri-apps/api/core";
+import type { Version } from "../types";
+import { uiState } from "./ui.svelte";
+import { authState } from "./auth.svelte";
+import { instancesState } from "./instances.svelte";
+
+export class GameState {
+ versions = $state<Version[]>([]);
+ selectedVersion = $state("");
+
+ constructor() {
+ // Constructor intentionally empty
+ // Instance switching handled in App.svelte with $effect
+ }
+
+ get latestRelease() {
+ return this.versions.find((v) => v.type === "release");
+ }
+
+ async loadVersions(instanceId?: string) {
+ const id = instanceId || instancesState.activeInstanceId;
+ if (!id) {
+ this.versions = [];
+ return;
+ }
+
+ try {
+ this.versions = await invoke<Version[]>("get_versions", {
+ instanceId: id,
+ });
+ // Don't auto-select version here - let BottomBar handle version selection
+ // based on installed versions only
+ } 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;
+ }
+
+ if (!instancesState.activeInstanceId) {
+ alert("Please select an instance first!");
+ uiState.setView("instances");
+ return;
+ }
+
+ uiState.setStatus("Preparing to launch " + this.selectedVersion + "...");
+ console.log(
+ "Invoking start_game for version:",
+ this.selectedVersion,
+ "instance:",
+ instancesState.activeInstanceId,
+ );
+ try {
+ const msg = await invoke<string>("start_game", {
+ instanceId: instancesState.activeInstanceId,
+ 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/packages/ui/src/stores/instances.svelte.ts b/packages/ui/src/stores/instances.svelte.ts
new file mode 100644
index 0000000..f4ac4e9
--- /dev/null
+++ b/packages/ui/src/stores/instances.svelte.ts
@@ -0,0 +1,109 @@
+import { invoke } from "@tauri-apps/api/core";
+import type { Instance } from "../types";
+import { uiState } from "./ui.svelte";
+
+export class InstancesState {
+ instances = $state<Instance[]>([]);
+ activeInstanceId = $state<string | null>(null);
+ get activeInstance(): Instance | null {
+ if (!this.activeInstanceId) return null;
+ return this.instances.find((i) => i.id === this.activeInstanceId) || null;
+ }
+
+ async loadInstances() {
+ try {
+ this.instances = await invoke<Instance[]>("list_instances");
+ const active = await invoke<Instance | null>("get_active_instance");
+ if (active) {
+ this.activeInstanceId = active.id;
+ } else if (this.instances.length > 0) {
+ // If no active instance but instances exist, set the first one as active
+ await this.setActiveInstance(this.instances[0].id);
+ }
+ } catch (e) {
+ console.error("Failed to load instances:", e);
+ uiState.setStatus("Error loading instances: " + e);
+ }
+ }
+
+ async createInstance(name: string): Promise<Instance | null> {
+ try {
+ const instance = await invoke<Instance>("create_instance", { name });
+ await this.loadInstances();
+ uiState.setStatus(`Instance "${name}" created successfully`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ uiState.setStatus("Error creating instance: " + e);
+ return null;
+ }
+ }
+
+ async deleteInstance(id: string) {
+ try {
+ await invoke("delete_instance", { instanceId: id });
+ await this.loadInstances();
+ // If deleted instance was active, set another as active
+ if (this.activeInstanceId === id) {
+ if (this.instances.length > 0) {
+ await this.setActiveInstance(this.instances[0].id);
+ } else {
+ this.activeInstanceId = null;
+ }
+ }
+ uiState.setStatus("Instance deleted successfully");
+ } catch (e) {
+ console.error("Failed to delete instance:", e);
+ uiState.setStatus("Error deleting instance: " + e);
+ }
+ }
+
+ async updateInstance(instance: Instance) {
+ try {
+ await invoke("update_instance", { instance });
+ await this.loadInstances();
+ uiState.setStatus("Instance updated successfully");
+ } catch (e) {
+ console.error("Failed to update instance:", e);
+ uiState.setStatus("Error updating instance: " + e);
+ }
+ }
+
+ async setActiveInstance(id: string) {
+ try {
+ await invoke("set_active_instance", { instanceId: id });
+ this.activeInstanceId = id;
+ uiState.setStatus("Active instance changed");
+ } catch (e) {
+ console.error("Failed to set active instance:", e);
+ uiState.setStatus("Error setting active instance: " + e);
+ }
+ }
+
+ async duplicateInstance(id: string, newName: string): Promise<Instance | null> {
+ try {
+ const instance = await invoke<Instance>("duplicate_instance", {
+ instanceId: id,
+ newName,
+ });
+ await this.loadInstances();
+ uiState.setStatus(`Instance duplicated as "${newName}"`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to duplicate instance:", e);
+ uiState.setStatus("Error duplicating instance: " + e);
+ return null;
+ }
+ }
+
+ async getInstance(id: string): Promise<Instance | null> {
+ try {
+ return await invoke<Instance>("get_instance", { instanceId: id });
+ } catch (e) {
+ console.error("Failed to get instance:", e);
+ return null;
+ }
+ }
+}
+
+export const instancesState = new InstancesState();
diff --git a/packages/ui/src/stores/logs.svelte.ts b/packages/ui/src/stores/logs.svelte.ts
new file mode 100644
index 0000000..c9d4acc
--- /dev/null
+++ b/packages/ui/src/stores/logs.svelte.ts
@@ -0,0 +1,151 @@
+import { listen } from "@tauri-apps/api/event";
+
+export interface LogEntry {
+ id: number;
+ timestamp: string;
+ level: "info" | "warn" | "error" | "debug" | "fatal";
+ source: string;
+ message: string;
+}
+
+// Parse Minecraft/Java log format: [HH:MM:SS] [Thread/LEVEL]: message
+// or: [HH:MM:SS] [Thread/LEVEL] [Source]: message
+const GAME_LOG_REGEX = /^\[[\d:]+\]\s*\[([^\]]+)\/(\w+)\](?:\s*\[([^\]]+)\])?:\s*(.*)$/;
+
+function parseGameLogLevel(levelStr: string): LogEntry["level"] {
+ const upper = levelStr.toUpperCase();
+ if (upper === "INFO") return "info";
+ if (upper === "WARN" || upper === "WARNING") return "warn";
+ if (upper === "ERROR" || upper === "SEVERE") return "error";
+ if (
+ upper === "DEBUG" ||
+ upper === "TRACE" ||
+ upper === "FINE" ||
+ upper === "FINER" ||
+ upper === "FINEST"
+ )
+ return "debug";
+ if (upper === "FATAL") return "fatal";
+ return "info";
+}
+
+export class LogsState {
+ logs = $state<LogEntry[]>([]);
+ private nextId = 0;
+ private maxLogs = 5000;
+
+ // Track all unique sources for filtering
+ sources = $state<Set<string>>(new Set(["Launcher"]));
+
+ constructor() {
+ this.addLog("info", "Launcher", "Logs initialized");
+ }
+
+ addLog(level: LogEntry["level"], source: string, message: string) {
+ const now = new Date();
+ const timestamp =
+ now.toLocaleTimeString() + "." + now.getMilliseconds().toString().padStart(3, "0");
+
+ this.logs.push({
+ id: this.nextId++,
+ timestamp,
+ level,
+ source,
+ message,
+ });
+
+ // Track source
+ if (!this.sources.has(source)) {
+ this.sources = new Set([...this.sources, source]);
+ }
+
+ if (this.logs.length > this.maxLogs) {
+ this.logs.shift();
+ }
+ }
+
+ // Parse game output and extract level/source
+ addGameLog(rawLine: string, isStderr: boolean) {
+ const match = rawLine.match(GAME_LOG_REGEX);
+
+ if (match) {
+ const [, thread, levelStr, extraSource, message] = match;
+ const level = parseGameLogLevel(levelStr);
+ // Use extraSource if available, otherwise use thread name as source hint
+ const source = extraSource || `Game/${thread.split("-")[0]}`;
+ this.addLog(level, source, message);
+ } else {
+ // Fallback: couldn't parse, use stderr as error indicator
+ const level = isStderr ? "error" : "info";
+ this.addLog(level, "Game", rawLine);
+ }
+ }
+
+ clear() {
+ this.logs = [];
+ this.sources = new Set(["Launcher"]);
+ this.addLog("info", "Launcher", "Logs cleared");
+ }
+
+ // Export with filter support
+ exportLogs(filteredLogs: LogEntry[]): string {
+ return filteredLogs
+ .map((l) => `[${l.timestamp}] [${l.source}/${l.level.toUpperCase()}] ${l.message}`)
+ .join("\n");
+ }
+
+ private initialized = false;
+
+ async init() {
+ if (this.initialized) return;
+ this.initialized = true;
+
+ // General Launcher Logs
+ await listen<string>("launcher-log", (e) => {
+ this.addLog("info", "Launcher", e.payload);
+ });
+
+ // Game Stdout - parse log level
+ await listen<string>("game-stdout", (e) => {
+ this.addGameLog(e.payload, false);
+ });
+
+ // Game Stderr - parse log level, default to error
+ await listen<string>("game-stderr", (e) => {
+ this.addGameLog(e.payload, true);
+ });
+
+ // Download Events (Summarized)
+ await listen("download-start", (e) => {
+ this.addLog("info", "Downloader", `Starting batch download of ${e.payload} files...`);
+ });
+
+ await listen("download-complete", () => {
+ this.addLog("info", "Downloader", "All downloads completed.");
+ });
+
+ // Listen to file download progress to log finished files
+ await listen<any>("download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Finished") {
+ if (p.file.endsWith(".jar")) {
+ this.addLog("info", "Downloader", `Downloaded ${p.file}`);
+ }
+ }
+ });
+
+ // Java Download
+ await listen<any>("java-download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Downloading" && p.percentage === 0) {
+ this.addLog("info", "JavaInstaller", `Downloading Java: ${p.file_name}`);
+ } else if (p.status === "Completed") {
+ this.addLog("info", "JavaInstaller", `Java installed: ${p.file_name}`);
+ } else if (p.status === "Error") {
+ this.addLog("error", "JavaInstaller", `Java download error`);
+ }
+ });
+ }
+}
+
+export const logsState = new LogsState();
diff --git a/packages/ui/src/stores/releases.svelte.ts b/packages/ui/src/stores/releases.svelte.ts
new file mode 100644
index 0000000..c858abb
--- /dev/null
+++ b/packages/ui/src/stores/releases.svelte.ts
@@ -0,0 +1,36 @@
+import { invoke } from "@tauri-apps/api/core";
+
+export interface GithubRelease {
+ tag_name: string;
+ name: string;
+ published_at: string;
+ body: string;
+ html_url: string;
+}
+
+export class ReleasesState {
+ releases = $state<GithubRelease[]>([]);
+ isLoading = $state(false);
+ isLoaded = $state(false);
+ error = $state<string | null>(null);
+
+ async loadReleases() {
+ // If already loaded or currently loading, skip to prevent duplicate requests
+ if (this.isLoaded || this.isLoading) return;
+
+ this.isLoading = true;
+ this.error = null;
+
+ try {
+ this.releases = await invoke<GithubRelease[]>("get_github_releases");
+ this.isLoaded = true;
+ } catch (e) {
+ console.error("Failed to load releases:", e);
+ this.error = String(e);
+ } finally {
+ this.isLoading = false;
+ }
+ }
+}
+
+export const releasesState = new ReleasesState();
diff --git a/packages/ui/src/stores/settings.svelte.ts b/packages/ui/src/stores/settings.svelte.ts
new file mode 100644
index 0000000..5d20050
--- /dev/null
+++ b/packages/ui/src/stores/settings.svelte.ts
@@ -0,0 +1,570 @@
+import { invoke } from "@tauri-apps/api/core";
+import { convertFileSrc } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import type {
+ JavaCatalog,
+ JavaDownloadProgress,
+ JavaDownloadSource,
+ JavaInstallation,
+ JavaReleaseInfo,
+ LauncherConfig,
+ ModelInfo,
+ PendingJavaDownload,
+} 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,
+ download_threads: 32,
+ enable_gpu_acceleration: false,
+ enable_visual_effects: true,
+ active_effect: "constellation",
+ theme: "dark",
+ custom_background_path: undefined,
+ log_upload_service: "paste.rs",
+ pastebin_api_key: undefined,
+ assistant: {
+ enabled: true,
+ llm_provider: "ollama",
+ ollama_endpoint: "http://localhost:11434",
+ ollama_model: "llama3",
+ openai_api_key: undefined,
+ openai_endpoint: "https://api.openai.com/v1",
+ openai_model: "gpt-3.5-turbo",
+ system_prompt:
+ "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
+ response_language: "auto",
+ tts_enabled: false,
+ tts_provider: "disabled",
+ },
+ use_shared_caches: false,
+ keep_legacy_per_instance_storage: true,
+ feature_flags: {
+ demo_user: false,
+ quick_play_enabled: false,
+ quick_play_path: undefined,
+ quick_play_singleplayer: true,
+ quick_play_multiplayer_server: undefined,
+ },
+ });
+
+ // Convert background path to proper asset URL
+ get backgroundUrl(): string | undefined {
+ if (this.settings.custom_background_path) {
+ return convertFileSrc(this.settings.custom_background_path);
+ }
+ return undefined;
+ }
+ javaInstallations = $state<JavaInstallation[]>([]);
+ isDetectingJava = $state(false);
+
+ // Java download modal state
+ showJavaDownloadModal = $state(false);
+ selectedDownloadSource = $state<JavaDownloadSource>("adoptium");
+
+ // Java catalog state
+ javaCatalog = $state<JavaCatalog | null>(null);
+ isLoadingCatalog = $state(false);
+ catalogError = $state("");
+
+ // Version selection state
+ selectedMajorVersion = $state<number | null>(null);
+ selectedImageType = $state<"jre" | "jdk">("jre");
+ showOnlyRecommended = $state(true);
+ searchQuery = $state("");
+
+ // Download progress state
+ isDownloadingJava = $state(false);
+ downloadProgress = $state<JavaDownloadProgress | null>(null);
+ javaDownloadStatus = $state("");
+
+ // Pending downloads
+ pendingDownloads = $state<PendingJavaDownload[]>([]);
+
+ // AI Model lists
+ ollamaModels = $state<ModelInfo[]>([]);
+ openaiModels = $state<ModelInfo[]>([]);
+ isLoadingOllamaModels = $state(false);
+ isLoadingOpenaiModels = $state(false);
+ ollamaModelsError = $state("");
+ openaiModelsError = $state("");
+
+ // Config Editor state
+ showConfigEditor = $state(false);
+ rawConfigContent = $state("");
+ configFilePath = $state("");
+ configEditorError = $state("");
+
+ // Event listener cleanup
+ private progressUnlisten: UnlistenFn | null = null;
+
+ async openConfigEditor() {
+ this.configEditorError = "";
+ try {
+ const path = await invoke<string>("get_config_path");
+ const content = await invoke<string>("read_raw_config");
+ this.configFilePath = path;
+ this.rawConfigContent = content;
+ this.showConfigEditor = true;
+ } catch (e) {
+ console.error("Failed to open config editor:", e);
+ uiState.setStatus(`Failed to open config: ${e}`);
+ }
+ }
+
+ async saveRawConfig(content: string, closeAfterSave = true) {
+ try {
+ await invoke("save_raw_config", { content });
+ // Reload settings to ensure UI is in sync
+ await this.loadSettings();
+ if (closeAfterSave) {
+ this.showConfigEditor = false;
+ }
+ uiState.setStatus("Configuration saved successfully!");
+ } catch (e) {
+ console.error("Failed to save config:", e);
+ this.configEditorError = String(e);
+ }
+ }
+
+ closeConfigEditor() {
+ this.showConfigEditor = false;
+ this.rawConfigContent = "";
+ this.configEditorError = "";
+ }
+
+ // Computed: filtered releases based on selection
+ get filteredReleases(): JavaReleaseInfo[] {
+ if (!this.javaCatalog) return [];
+
+ let releases = this.javaCatalog.releases;
+
+ // Filter by major version if selected
+ if (this.selectedMajorVersion !== null) {
+ releases = releases.filter((r) => r.major_version === this.selectedMajorVersion);
+ }
+
+ // Filter by image type
+ releases = releases.filter((r) => r.image_type === this.selectedImageType);
+
+ // Filter by recommended (LTS) versions
+ if (this.showOnlyRecommended) {
+ releases = releases.filter((r) => r.is_lts);
+ }
+
+ // Filter by search query
+ if (this.searchQuery.trim()) {
+ const query = this.searchQuery.toLowerCase();
+ releases = releases.filter(
+ (r) =>
+ r.release_name.toLowerCase().includes(query) ||
+ r.version.toLowerCase().includes(query) ||
+ r.major_version.toString().includes(query),
+ );
+ }
+
+ return releases;
+ }
+
+ // Computed: available major versions for display
+ get availableMajorVersions(): number[] {
+ if (!this.javaCatalog) return [];
+ let versions = [...this.javaCatalog.available_major_versions];
+
+ // Filter by LTS if showOnlyRecommended is enabled
+ if (this.showOnlyRecommended) {
+ versions = versions.filter((v) => this.javaCatalog!.lts_versions.includes(v));
+ }
+
+ // Sort descending (newest first)
+ return versions.sort((a, b) => b - a);
+ }
+
+ // Get installation status for a release: 'installed' | 'download'
+ getInstallStatus(release: JavaReleaseInfo): "installed" | "download" {
+ // Find installed Java that matches the major version and image type (by path pattern)
+ const matchingInstallations = this.javaInstallations.filter((inst) => {
+ // Check if this is a DropOut-managed Java (path contains temurin-XX-jre/jdk pattern)
+ const pathLower = inst.path.toLowerCase();
+ const pattern = `temurin-${release.major_version}-${release.image_type}`;
+ return pathLower.includes(pattern);
+ });
+
+ // If any matching installation exists, it's installed
+ return matchingInstallations.length > 0 ? "installed" : "download";
+ }
+
+ // Computed: selected release details
+ get selectedRelease(): JavaReleaseInfo | null {
+ if (!this.javaCatalog || this.selectedMajorVersion === null) return null;
+ return (
+ this.javaCatalog.releases.find(
+ (r) =>
+ r.major_version === this.selectedMajorVersion && r.image_type === this.selectedImageType,
+ ) || null
+ );
+ }
+
+ async loadSettings() {
+ try {
+ const result = await invoke<LauncherConfig>("get_settings");
+ this.settings = result;
+ // Force dark mode
+ if (this.settings.theme !== "dark") {
+ this.settings.theme = "dark";
+ this.saveSettings();
+ }
+ // Ensure custom_background_path is reactive
+ if (!this.settings.custom_background_path) {
+ this.settings.custom_background_path = undefined;
+ }
+ } catch (e) {
+ console.error("Failed to load settings:", e);
+ }
+ }
+
+ async saveSettings() {
+ try {
+ // Ensure we clean up any invalid paths before saving
+ if (this.settings.custom_background_path === "") {
+ this.settings.custom_background_path = undefined;
+ }
+
+ await invoke("save_settings", { config: this.settings });
+ uiState.setStatus("Settings saved!");
+ } catch (e) {
+ 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;
+ }
+
+ async openJavaDownloadModal() {
+ this.showJavaDownloadModal = true;
+ this.javaDownloadStatus = "";
+ this.catalogError = "";
+ this.downloadProgress = null;
+
+ // Setup progress event listener
+ await this.setupProgressListener();
+
+ // Load catalog
+ await this.loadJavaCatalog(false);
+
+ // Check for pending downloads
+ await this.loadPendingDownloads();
+ }
+
+ async closeJavaDownloadModal() {
+ if (!this.isDownloadingJava) {
+ this.showJavaDownloadModal = false;
+ // Cleanup listener
+ if (this.progressUnlisten) {
+ this.progressUnlisten();
+ this.progressUnlisten = null;
+ }
+ }
+ }
+
+ private async setupProgressListener() {
+ if (this.progressUnlisten) {
+ this.progressUnlisten();
+ }
+
+ this.progressUnlisten = await listen<JavaDownloadProgress>(
+ "java-download-progress",
+ (event) => {
+ this.downloadProgress = event.payload;
+ this.javaDownloadStatus = event.payload.status;
+
+ if (event.payload.status === "Completed") {
+ this.isDownloadingJava = false;
+ setTimeout(async () => {
+ await this.detectJava();
+ uiState.setStatus(`Java installed successfully!`);
+ }, 500);
+ } else if (event.payload.status === "Error") {
+ this.isDownloadingJava = false;
+ }
+ },
+ );
+ }
+
+ async loadJavaCatalog(forceRefresh: boolean) {
+ this.isLoadingCatalog = true;
+ this.catalogError = "";
+
+ try {
+ const command = forceRefresh ? "refresh_java_catalog" : "fetch_java_catalog";
+ this.javaCatalog = await invoke<JavaCatalog>(command);
+
+ // Auto-select first LTS version
+ if (this.selectedMajorVersion === null && this.javaCatalog.lts_versions.length > 0) {
+ // Select most recent LTS (21 or highest)
+ const ltsVersions = [...this.javaCatalog.lts_versions].sort((a, b) => b - a);
+ this.selectedMajorVersion = ltsVersions[0];
+ }
+ } catch (e) {
+ console.error("Failed to load Java catalog:", e);
+ this.catalogError = `Failed to load Java catalog: ${e}`;
+ } finally {
+ this.isLoadingCatalog = false;
+ }
+ }
+
+ async refreshCatalog() {
+ await this.loadJavaCatalog(true);
+ uiState.setStatus("Java catalog refreshed");
+ }
+
+ async loadPendingDownloads() {
+ try {
+ this.pendingDownloads = await invoke<PendingJavaDownload[]>("get_pending_java_downloads");
+ } catch (e) {
+ console.error("Failed to load pending downloads:", e);
+ }
+ }
+
+ selectMajorVersion(version: number) {
+ this.selectedMajorVersion = version;
+ }
+
+ async downloadJava() {
+ if (!this.selectedRelease || !this.selectedRelease.is_available) {
+ uiState.setStatus("Selected Java version is not available for this platform");
+ return;
+ }
+
+ this.isDownloadingJava = true;
+ this.javaDownloadStatus = "Starting download...";
+ this.downloadProgress = null;
+
+ try {
+ const result: JavaInstallation = await invoke("download_adoptium_java", {
+ majorVersion: this.selectedMajorVersion,
+ imageType: this.selectedImageType,
+ customPath: null,
+ });
+
+ this.settings.java_path = result.path;
+ await this.detectJava();
+
+ setTimeout(() => {
+ this.showJavaDownloadModal = false;
+ uiState.setStatus(`Java ${this.selectedMajorVersion} is ready to use!`);
+ }, 1500);
+ } catch (e) {
+ console.error("Failed to download Java:", e);
+ this.javaDownloadStatus = `Download failed: ${e}`;
+ } finally {
+ this.isDownloadingJava = false;
+ }
+ }
+
+ async cancelDownload() {
+ try {
+ await invoke("cancel_java_download");
+ this.isDownloadingJava = false;
+ this.javaDownloadStatus = "Download cancelled";
+ this.downloadProgress = null;
+ await this.loadPendingDownloads();
+ } catch (e) {
+ console.error("Failed to cancel download:", e);
+ }
+ }
+
+ async resumeDownloads() {
+ if (this.pendingDownloads.length === 0) return;
+
+ this.isDownloadingJava = true;
+ this.javaDownloadStatus = "Resuming download...";
+
+ try {
+ const installed = await invoke<JavaInstallation[]>("resume_java_downloads");
+ if (installed.length > 0) {
+ this.settings.java_path = installed[0].path;
+ await this.detectJava();
+ uiState.setStatus(`Resumed and installed ${installed.length} Java version(s)`);
+ }
+ await this.loadPendingDownloads();
+ } catch (e) {
+ console.error("Failed to resume downloads:", e);
+ this.javaDownloadStatus = `Resume failed: ${e}`;
+ } finally {
+ this.isDownloadingJava = false;
+ }
+ }
+
+ // Format bytes to human readable
+ formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
+ }
+
+ // Format seconds to human readable
+ formatTime(seconds: number): string {
+ if (seconds === 0 || !isFinite(seconds)) return "--";
+ if (seconds < 60) return `${Math.round(seconds)}s`;
+ if (seconds < 3600) {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.round(seconds % 60);
+ return `${mins}m ${secs}s`;
+ }
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ return `${hours}h ${mins}m`;
+ }
+
+ // Format date string
+ formatDate(dateStr: string | null): string {
+ if (!dateStr) return "--";
+ try {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ year: "2-digit",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ } catch {
+ return "--";
+ }
+ }
+
+ // Legacy compatibility
+ get availableJavaVersions(): number[] {
+ return this.availableMajorVersions;
+ }
+
+ // AI Model loading methods
+ async loadOllamaModels() {
+ this.isLoadingOllamaModels = true;
+ this.ollamaModelsError = "";
+
+ try {
+ const models = await invoke<ModelInfo[]>("list_ollama_models", {
+ endpoint: this.settings.assistant.ollama_endpoint,
+ });
+ this.ollamaModels = models;
+
+ // If no model is selected or selected model isn't available, select the first one
+ if (models.length > 0) {
+ const currentModel = this.settings.assistant.ollama_model;
+ const modelExists = models.some((m) => m.id === currentModel);
+ if (!modelExists) {
+ this.settings.assistant.ollama_model = models[0].id;
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load Ollama models:", e);
+ this.ollamaModelsError = String(e);
+ this.ollamaModels = [];
+ } finally {
+ this.isLoadingOllamaModels = false;
+ }
+ }
+
+ async loadOpenaiModels() {
+ if (!this.settings.assistant.openai_api_key) {
+ this.openaiModelsError = "API key required";
+ this.openaiModels = [];
+ return;
+ }
+
+ this.isLoadingOpenaiModels = true;
+ this.openaiModelsError = "";
+
+ try {
+ const models = await invoke<ModelInfo[]>("list_openai_models");
+ this.openaiModels = models;
+
+ // If no model is selected or selected model isn't available, select the first one
+ if (models.length > 0) {
+ const currentModel = this.settings.assistant.openai_model;
+ const modelExists = models.some((m) => m.id === currentModel);
+ if (!modelExists) {
+ this.settings.assistant.openai_model = models[0].id;
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load OpenAI models:", e);
+ this.openaiModelsError = String(e);
+ this.openaiModels = [];
+ } finally {
+ this.isLoadingOpenaiModels = false;
+ }
+ }
+
+ // Computed: get model options for current provider
+ get currentModelOptions(): { value: string; label: string; details?: string }[] {
+ const provider = this.settings.assistant.llm_provider;
+
+ if (provider === "ollama") {
+ if (this.ollamaModels.length === 0) {
+ // Return fallback options if no models loaded
+ return [
+ { value: "llama3", label: "Llama 3" },
+ { value: "llama3.1", label: "Llama 3.1" },
+ { value: "llama3.2", label: "Llama 3.2" },
+ { value: "mistral", label: "Mistral" },
+ { value: "gemma2", label: "Gemma 2" },
+ { value: "qwen2.5", label: "Qwen 2.5" },
+ { value: "phi3", label: "Phi-3" },
+ { value: "codellama", label: "Code Llama" },
+ ];
+ }
+ return this.ollamaModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.size ? `${m.size}${m.details ? ` - ${m.details}` : ""}` : m.details,
+ }));
+ } else if (provider === "openai") {
+ if (this.openaiModels.length === 0) {
+ // Return fallback options if no models loaded
+ return [
+ { value: "gpt-4o", label: "GPT-4o" },
+ { value: "gpt-4o-mini", label: "GPT-4o Mini" },
+ { value: "gpt-4-turbo", label: "GPT-4 Turbo" },
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
+ ];
+ }
+ return this.openaiModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details,
+ }));
+ }
+
+ return [];
+ }
+}
+
+export const settingsState = new SettingsState();
diff --git a/packages/ui/src/stores/ui.svelte.ts b/packages/ui/src/stores/ui.svelte.ts
new file mode 100644
index 0000000..e88f6b4
--- /dev/null
+++ b/packages/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();