aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/stores
diff options
context:
space:
mode:
authorNtskwK <natsukawa247@outlook.com>2026-02-28 09:03:19 +0800
committerNtskwK <natsukawa247@outlook.com>2026-02-28 09:03:19 +0800
commitcc53b1cf260e1c67939e50608ef18764da616d55 (patch)
tree119109c62331d4d26612e2df7726cee82d1871f5 /packages/ui/src/stores
parentee37d044e473217daadd9ce26c7e2e2ad39a0490 (diff)
parent81a62402ef6f8900ff092366121a9b7a4263ba52 (diff)
downloadDropOut-cc53b1cf260e1c67939e50608ef18764da616d55.tar.gz
DropOut-cc53b1cf260e1c67939e50608ef18764da616d55.zip
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'packages/ui/src/stores')
-rw-r--r--packages/ui/src/stores/assistant-store.ts201
-rw-r--r--packages/ui/src/stores/assistant.svelte.ts166
-rw-r--r--packages/ui/src/stores/auth-store.ts296
-rw-r--r--packages/ui/src/stores/auth.svelte.ts192
-rw-r--r--packages/ui/src/stores/game-store.ts101
-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-store.ts200
-rw-r--r--packages/ui/src/stores/logs.svelte.ts151
-rw-r--r--packages/ui/src/stores/releases-store.ts63
-rw-r--r--packages/ui/src/stores/releases.svelte.ts36
-rw-r--r--packages/ui/src/stores/settings-store.ts568
-rw-r--r--packages/ui/src/stores/settings.svelte.ts570
-rw-r--r--packages/ui/src/stores/ui-store.ts42
-rw-r--r--packages/ui/src/stores/ui.svelte.ts32
15 files changed, 1471 insertions, 1334 deletions
diff --git a/packages/ui/src/stores/assistant-store.ts b/packages/ui/src/stores/assistant-store.ts
new file mode 100644
index 0000000..180031b
--- /dev/null
+++ b/packages/ui/src/stores/assistant-store.ts
@@ -0,0 +1,201 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { create } from "zustand";
+import type { GenerationStats, StreamChunk } from "@/types/bindings/assistant";
+
+export interface Message {
+ role: "user" | "assistant" | "system";
+ content: string;
+ stats?: GenerationStats;
+}
+
+interface AssistantState {
+ // State
+ messages: Message[];
+ isProcessing: boolean;
+ isProviderHealthy: boolean | undefined;
+ streamingContent: string;
+ initialized: boolean;
+ streamUnlisten: UnlistenFn | null;
+
+ // Actions
+ init: () => Promise<void>;
+ checkHealth: () => Promise<void>;
+ sendMessage: (
+ content: string,
+ isEnabled: boolean,
+ provider: string,
+ endpoint: string,
+ ) => Promise<void>;
+ finishStreaming: () => void;
+ clearHistory: () => void;
+ setMessages: (messages: Message[]) => void;
+ setIsProcessing: (isProcessing: boolean) => void;
+ setIsProviderHealthy: (isProviderHealthy: boolean | undefined) => void;
+ setStreamingContent: (streamingContent: string) => void;
+}
+
+export const useAssistantStore = create<AssistantState>((set, get) => ({
+ // Initial state
+ messages: [],
+ isProcessing: false,
+ isProviderHealthy: false,
+ streamingContent: "",
+ initialized: false,
+ streamUnlisten: null,
+
+ // Actions
+ init: async () => {
+ const { initialized } = get();
+ if (initialized) return;
+ set({ initialized: true });
+ await get().checkHealth();
+ },
+
+ checkHealth: async () => {
+ try {
+ const isHealthy = await invoke<boolean>("assistant_check_health");
+ set({ isProviderHealthy: isHealthy });
+ } catch (e) {
+ console.error("Failed to check provider health:", e);
+ set({ isProviderHealthy: false });
+ }
+ },
+
+ finishStreaming: () => {
+ const { streamUnlisten } = get();
+ set({ isProcessing: false, streamingContent: "" });
+
+ if (streamUnlisten) {
+ streamUnlisten();
+ set({ streamUnlisten: null });
+ }
+ },
+
+ sendMessage: async (content, isEnabled, provider, endpoint) => {
+ if (!content.trim()) return;
+
+ const { messages } = get();
+
+ if (!isEnabled) {
+ const newMessage: Message = {
+ role: "assistant",
+ content: "Assistant is disabled. Enable it in Settings > AI Assistant.",
+ };
+ set({ messages: [...messages, { role: "user", content }, newMessage] });
+ return;
+ }
+
+ // Add user message
+ const userMessage: Message = { role: "user", content };
+ const updatedMessages = [...messages, userMessage];
+ set({
+ messages: updatedMessages,
+ isProcessing: true,
+ streamingContent: "",
+ });
+
+ // Add empty assistant message for streaming
+ const assistantMessage: Message = { role: "assistant", content: "" };
+ const withAssistantMessage = [...updatedMessages, assistantMessage];
+ set({ messages: withAssistantMessage });
+
+ try {
+ // Set up stream listener
+ const unlisten = await listen<StreamChunk>(
+ "assistant-stream",
+ (event) => {
+ const chunk = event.payload;
+ const currentState = get();
+
+ if (chunk.content) {
+ const newStreamingContent =
+ currentState.streamingContent + chunk.content;
+ const currentMessages = [...currentState.messages];
+ const lastIdx = currentMessages.length - 1;
+
+ if (lastIdx >= 0 && currentMessages[lastIdx].role === "assistant") {
+ currentMessages[lastIdx] = {
+ ...currentMessages[lastIdx],
+ content: newStreamingContent,
+ };
+ set({
+ streamingContent: newStreamingContent,
+ messages: currentMessages,
+ });
+ }
+ }
+
+ if (chunk.done) {
+ const finalMessages = [...currentState.messages];
+ const lastIdx = finalMessages.length - 1;
+
+ if (
+ chunk.stats &&
+ lastIdx >= 0 &&
+ finalMessages[lastIdx].role === "assistant"
+ ) {
+ finalMessages[lastIdx] = {
+ ...finalMessages[lastIdx],
+ stats: chunk.stats,
+ };
+ set({ messages: finalMessages });
+ }
+
+ get().finishStreaming();
+ }
+ },
+ );
+
+ set({ streamUnlisten: unlisten });
+
+ // Start streaming chat
+ await invoke<string>("assistant_chat_stream", {
+ messages: withAssistantMessage.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 currentMessages = [...get().messages];
+ const lastIdx = currentMessages.length - 1;
+ if (lastIdx >= 0 && currentMessages[lastIdx].role === "assistant") {
+ currentMessages[lastIdx] = {
+ role: "assistant",
+ content: `Error: ${errorMessage}${helpText}`,
+ };
+ set({ messages: currentMessages });
+ }
+
+ get().finishStreaming();
+ }
+ },
+
+ clearHistory: () => {
+ set({ messages: [], streamingContent: "" });
+ },
+
+ setMessages: (messages) => {
+ set({ messages });
+ },
+
+ setIsProcessing: (isProcessing) => {
+ set({ isProcessing });
+ },
+
+ setIsProviderHealthy: (isProviderHealthy) => {
+ set({ isProviderHealthy });
+ },
+
+ setStreamingContent: (streamingContent) => {
+ set({ streamingContent });
+ },
+}));
diff --git a/packages/ui/src/stores/assistant.svelte.ts b/packages/ui/src/stores/assistant.svelte.ts
deleted file mode 100644
index a3f47ea..0000000
--- a/packages/ui/src/stores/assistant.svelte.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-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-store.ts b/packages/ui/src/stores/auth-store.ts
new file mode 100644
index 0000000..bf7e3c5
--- /dev/null
+++ b/packages/ui/src/stores/auth-store.ts
@@ -0,0 +1,296 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { open } from "@tauri-apps/plugin-shell";
+import { toast } from "sonner";
+import { create } from "zustand";
+import type { Account, DeviceCodeResponse } from "../types/bindings/auth";
+
+interface AuthState {
+ // State
+ currentAccount: Account | null;
+ isLoginModalOpen: boolean;
+ isLogoutConfirmOpen: boolean;
+ loginMode: "select" | "offline" | "microsoft";
+ offlineUsername: string;
+ deviceCodeData: DeviceCodeResponse | null;
+ msLoginLoading: boolean;
+ msLoginStatus: string;
+
+ // Private state
+ pollInterval: ReturnType<typeof setInterval> | null;
+ isPollingRequestActive: boolean;
+ authProgressUnlisten: UnlistenFn | null;
+
+ // Actions
+ checkAccount: () => Promise<void>;
+ openLoginModal: () => void;
+ openLogoutConfirm: () => void;
+ cancelLogout: () => void;
+ confirmLogout: () => Promise<void>;
+ closeLoginModal: () => void;
+ resetLoginState: () => void;
+ performOfflineLogin: () => Promise<void>;
+ startMicrosoftLogin: () => Promise<void>;
+ checkLoginStatus: (deviceCode: string) => Promise<void>;
+ stopPolling: () => void;
+ cancelMicrosoftLogin: () => void;
+ setLoginMode: (mode: "select" | "offline" | "microsoft") => void;
+ setOfflineUsername: (username: string) => void;
+}
+
+export const useAuthStore = create<AuthState>((set, get) => ({
+ // Initial state
+ currentAccount: null,
+ isLoginModalOpen: false,
+ isLogoutConfirmOpen: false,
+ loginMode: "select",
+ offlineUsername: "",
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "Waiting for authorization...",
+
+ // Private state
+ pollInterval: null,
+ isPollingRequestActive: false,
+ authProgressUnlisten: null,
+
+ // Actions
+ checkAccount: async () => {
+ try {
+ const acc = await invoke<Account | null>("get_active_account");
+ set({ currentAccount: acc });
+ } catch (error) {
+ console.error("Failed to check account:", error);
+ }
+ },
+
+ openLoginModal: () => {
+ const { currentAccount } = get();
+ if (currentAccount) {
+ // Show custom logout confirmation dialog
+ set({ isLogoutConfirmOpen: true });
+ return;
+ }
+ get().resetLoginState();
+ set({ isLoginModalOpen: true });
+ },
+
+ openLogoutConfirm: () => {
+ set({ isLogoutConfirmOpen: true });
+ },
+
+ cancelLogout: () => {
+ set({ isLogoutConfirmOpen: false });
+ },
+
+ confirmLogout: async () => {
+ set({ isLogoutConfirmOpen: false });
+ try {
+ await invoke("logout");
+ set({ currentAccount: null });
+ } catch (error) {
+ console.error("Logout failed:", error);
+ }
+ },
+
+ closeLoginModal: () => {
+ get().stopPolling();
+ set({ isLoginModalOpen: false });
+ },
+
+ resetLoginState: () => {
+ set({
+ loginMode: "select",
+ offlineUsername: "",
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "Waiting for authorization...",
+ });
+ },
+
+ performOfflineLogin: async () => {
+ const { offlineUsername } = get();
+ if (!offlineUsername.trim()) return;
+
+ try {
+ const account = await invoke<Account>("login_offline", {
+ username: offlineUsername,
+ });
+ set({
+ currentAccount: account,
+ isLoginModalOpen: false,
+ offlineUsername: "",
+ });
+ } catch (error) {
+ // Keep UI-friendly behavior consistent with prior code
+ alert("Login failed: " + String(error));
+ }
+ },
+
+ startMicrosoftLogin: async () => {
+ // Prepare UI state
+ set({
+ msLoginLoading: true,
+ msLoginStatus: "Waiting for authorization...",
+ loginMode: "microsoft",
+ deviceCodeData: null,
+ });
+
+ // Listen to general launcher logs so we can display progress to the user.
+ // The backend emits logs via "launcher-log"; using that keeps this store decoupled
+ // from a dedicated auth event channel (backend may reuse launcher-log).
+ try {
+ const unlisten = await listen("launcher-log", (event) => {
+ const payload = event.payload;
+ // Normalize payload to string if possible
+ const message =
+ typeof payload === "string"
+ ? payload
+ : (payload?.toString?.() ?? JSON.stringify(payload));
+ set({ msLoginStatus: message });
+ });
+ set({ authProgressUnlisten: unlisten });
+ } catch (err) {
+ console.warn("Failed to attach launcher-log listener:", err);
+ }
+
+ try {
+ const deviceCodeData = await invoke<DeviceCodeResponse>(
+ "start_microsoft_login",
+ );
+ set({ deviceCodeData });
+
+ if (deviceCodeData) {
+ // Try to copy user code to clipboard for convenience (best-effort)
+ try {
+ await navigator.clipboard?.writeText(deviceCodeData.userCode ?? "");
+ } catch (err) {
+ // ignore clipboard errors
+ console.debug("Clipboard copy failed:", err);
+ }
+
+ // Open verification URI in default browser
+ try {
+ if (deviceCodeData.verificationUri) {
+ await open(deviceCodeData.verificationUri);
+ }
+ } catch (err) {
+ console.debug("Failed to open verification URI:", err);
+ }
+
+ // Start polling for completion
+ // `interval` from the bindings is a bigint (seconds). Convert safely to number.
+ const intervalSeconds =
+ deviceCodeData.interval !== undefined &&
+ deviceCodeData.interval !== null
+ ? Number(deviceCodeData.interval)
+ : 5;
+ const intervalMs = intervalSeconds * 1000;
+ const pollInterval = setInterval(
+ () => get().checkLoginStatus(deviceCodeData.deviceCode),
+ intervalMs,
+ );
+ set({ pollInterval });
+ }
+ } catch (error) {
+ toast.error(`Failed to start Microsoft login: ${error}`);
+ set({ loginMode: "select" });
+ // cleanup listener if present
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+ } finally {
+ set({ msLoginLoading: false });
+ }
+ },
+
+ checkLoginStatus: async (deviceCode: string) => {
+ const { isPollingRequestActive } = get();
+ if (isPollingRequestActive) return;
+
+ set({ isPollingRequestActive: true });
+
+ try {
+ const account = await invoke<Account>("complete_microsoft_login", {
+ deviceCode,
+ });
+
+ // On success, stop polling and cleanup listener
+ get().stopPolling();
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+
+ set({
+ currentAccount: account,
+ isLoginModalOpen: false,
+ });
+ } catch (error: unknown) {
+ const errStr = String(error);
+ if (errStr.includes("authorization_pending")) {
+ // Still waiting — keep polling
+ } else {
+ set({ msLoginStatus: "Error: " + errStr });
+
+ if (
+ errStr.includes("expired_token") ||
+ errStr.includes("access_denied")
+ ) {
+ // Terminal errors — stop polling and reset state
+ get().stopPolling();
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+ alert("Login failed: " + errStr);
+ set({ loginMode: "select" });
+ }
+ }
+ } finally {
+ set({ isPollingRequestActive: false });
+ }
+ },
+
+ stopPolling: () => {
+ const { pollInterval, authProgressUnlisten } = get();
+ if (pollInterval) {
+ try {
+ clearInterval(pollInterval);
+ } catch (err) {
+ console.debug("Failed to clear poll interval:", err);
+ }
+ set({ pollInterval: null });
+ }
+ if (authProgressUnlisten) {
+ try {
+ authProgressUnlisten();
+ } catch (err) {
+ console.debug("Failed to unlisten auth progress:", err);
+ }
+ set({ authProgressUnlisten: null });
+ }
+ },
+
+ cancelMicrosoftLogin: () => {
+ get().stopPolling();
+ set({
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "",
+ loginMode: "select",
+ });
+ },
+
+ setLoginMode: (mode: "select" | "offline" | "microsoft") => {
+ set({ loginMode: mode });
+ },
+
+ setOfflineUsername: (username: string) => {
+ set({ offlineUsername: username });
+ },
+}));
diff --git a/packages/ui/src/stores/auth.svelte.ts b/packages/ui/src/stores/auth.svelte.ts
deleted file mode 100644
index 1b613a7..0000000
--- a/packages/ui/src/stores/auth.svelte.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-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-store.ts b/packages/ui/src/stores/game-store.ts
new file mode 100644
index 0000000..fa0f9f8
--- /dev/null
+++ b/packages/ui/src/stores/game-store.ts
@@ -0,0 +1,101 @@
+import { toast } from "sonner";
+import { create } from "zustand";
+import { getVersions } from "@/client";
+import type { Version } from "@/types/bindings/manifest";
+
+interface GameState {
+ // State
+ versions: Version[];
+ selectedVersion: string;
+
+ // Computed property
+ latestRelease: Version | undefined;
+
+ // Actions
+ loadVersions: (instanceId?: string) => Promise<void>;
+ startGame: (
+ currentAccount: any,
+ openLoginModal: () => void,
+ activeInstanceId: string | null,
+ setView: (view: any) => void,
+ ) => Promise<void>;
+ setSelectedVersion: (version: string) => void;
+ setVersions: (versions: Version[]) => void;
+}
+
+export const useGameStore = create<GameState>((set, get) => ({
+ // Initial state
+ versions: [],
+ selectedVersion: "",
+
+ // Computed property
+ get latestRelease() {
+ return get().versions.find((v) => v.type === "release");
+ },
+
+ // Actions
+ loadVersions: async (instanceId?: string) => {
+ console.log("Loading versions for instance:", instanceId);
+ try {
+ // Ask the backend for known versions (optionally scoped to an instance).
+ // The Tauri command `get_versions` is expected to return an array of `Version`.
+ const versions = await getVersions();
+ set({ versions: versions ?? [] });
+ } catch (e) {
+ console.error("Failed to load versions:", e);
+ // Keep the store consistent on error by clearing versions.
+ set({ versions: [] });
+ }
+ },
+
+ startGame: async (
+ currentAccount,
+ openLoginModal,
+ activeInstanceId,
+ setView,
+ ) => {
+ const { selectedVersion } = get();
+
+ if (!currentAccount) {
+ alert("Please login first!");
+ openLoginModal();
+ return;
+ }
+
+ if (!selectedVersion) {
+ alert("Please select a version!");
+ return;
+ }
+
+ if (!activeInstanceId) {
+ alert("Please select an instance first!");
+ setView("instances");
+ return;
+ }
+
+ toast.info("Preparing to launch " + selectedVersion + "...");
+
+ try {
+ // Note: In production, this would call Tauri invoke
+ // const msg = await invoke<string>("start_game", {
+ // instanceId: activeInstanceId,
+ // versionId: selectedVersion,
+ // });
+
+ // Simulate success
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ toast.success("Game started successfully!");
+ } catch (e) {
+ console.error(e);
+ toast.error(`Error: ${e}`);
+ }
+ },
+
+ setSelectedVersion: (version: string) => {
+ set({ selectedVersion: version });
+ },
+
+ setVersions: (versions: Version[]) => {
+ set({ versions });
+ },
+}));
diff --git a/packages/ui/src/stores/game.svelte.ts b/packages/ui/src/stores/game.svelte.ts
deleted file mode 100644
index 504d108..0000000
--- a/packages/ui/src/stores/game.svelte.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-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
deleted file mode 100644
index f4ac4e9..0000000
--- a/packages/ui/src/stores/instances.svelte.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-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-store.ts b/packages/ui/src/stores/logs-store.ts
new file mode 100644
index 0000000..b19f206
--- /dev/null
+++ b/packages/ui/src/stores/logs-store.ts
@@ -0,0 +1,200 @@
+import { listen } from "@tauri-apps/api/event";
+import { create } from "zustand";
+
+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";
+}
+
+interface LogsState {
+ // State
+ logs: LogEntry[];
+ sources: Set<string>;
+ nextId: number;
+ maxLogs: number;
+ initialized: boolean;
+
+ // Actions
+ addLog: (level: LogEntry["level"], source: string, message: string) => void;
+ addGameLog: (rawLine: string, isStderr: boolean) => void;
+ clear: () => void;
+ exportLogs: (filteredLogs: LogEntry[]) => string;
+ init: () => Promise<void>;
+ setLogs: (logs: LogEntry[]) => void;
+ setSources: (sources: Set<string>) => void;
+}
+
+export const useLogsStore = create<LogsState>((set, get) => ({
+ // Initial state
+ logs: [],
+ sources: new Set(["Launcher"]),
+ nextId: 0,
+ maxLogs: 5000,
+ initialized: false,
+
+ // Actions
+ addLog: (level, source, message) => {
+ const { nextId, logs, maxLogs, sources } = get();
+ const now = new Date();
+ const timestamp =
+ now.toLocaleTimeString() +
+ "." +
+ now.getMilliseconds().toString().padStart(3, "0");
+
+ const newLog: LogEntry = {
+ id: nextId,
+ timestamp,
+ level,
+ source,
+ message,
+ };
+
+ const newLogs = [...logs, newLog];
+ const newSources = new Set(sources);
+
+ // Track source
+ if (!newSources.has(source)) {
+ newSources.add(source);
+ }
+
+ // Trim logs if exceeding max
+ const trimmedLogs =
+ newLogs.length > maxLogs ? newLogs.slice(-maxLogs) : newLogs;
+
+ set({
+ logs: trimmedLogs,
+ sources: newSources,
+ nextId: nextId + 1,
+ });
+ },
+
+ addGameLog: (rawLine, isStderr) => {
+ 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]}`;
+ get().addLog(level, source, message);
+ } else {
+ // Fallback: couldn't parse, use stderr as error indicator
+ const level = isStderr ? "error" : "info";
+ get().addLog(level, "Game", rawLine);
+ }
+ },
+
+ clear: () => {
+ set({
+ logs: [],
+ sources: new Set(["Launcher"]),
+ });
+ get().addLog("info", "Launcher", "Logs cleared");
+ },
+
+ exportLogs: (filteredLogs) => {
+ return filteredLogs
+ .map(
+ (l) =>
+ `[${l.timestamp}] [${l.source}/${l.level.toUpperCase()}] ${l.message}`,
+ )
+ .join("\n");
+ },
+
+ init: async () => {
+ const { initialized } = get();
+ if (initialized) return;
+
+ set({ initialized: true });
+
+ // Initial log
+ get().addLog("info", "Launcher", "Logs initialized");
+
+ // General Launcher Logs
+ await listen<string>("launcher-log", (e) => {
+ get().addLog("info", "Launcher", e.payload);
+ });
+
+ // Game Stdout - parse log level
+ await listen<string>("game-stdout", (e) => {
+ get().addGameLog(e.payload, false);
+ });
+
+ // Game Stderr - parse log level, default to error
+ await listen<string>("game-stderr", (e) => {
+ get().addGameLog(e.payload, true);
+ });
+
+ // Download Events (Summarized)
+ await listen("download-start", (e: any) => {
+ get().addLog(
+ "info",
+ "Downloader",
+ `Starting batch download of ${e.payload} files...`,
+ );
+ });
+
+ await listen("download-complete", () => {
+ get().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")) {
+ get().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) {
+ get().addLog(
+ "info",
+ "JavaInstaller",
+ `Downloading Java: ${p.file_name}`,
+ );
+ } else if (p.status === "Completed") {
+ get().addLog("info", "JavaInstaller", `Java installed: ${p.file_name}`);
+ } else if (p.status === "Error") {
+ get().addLog("error", "JavaInstaller", `Java download error`);
+ }
+ });
+ },
+
+ setLogs: (logs) => {
+ set({ logs });
+ },
+
+ setSources: (sources) => {
+ set({ sources });
+ },
+}));
diff --git a/packages/ui/src/stores/logs.svelte.ts b/packages/ui/src/stores/logs.svelte.ts
deleted file mode 100644
index c9d4acc..0000000
--- a/packages/ui/src/stores/logs.svelte.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-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-store.ts b/packages/ui/src/stores/releases-store.ts
new file mode 100644
index 0000000..56afa08
--- /dev/null
+++ b/packages/ui/src/stores/releases-store.ts
@@ -0,0 +1,63 @@
+import { invoke } from "@tauri-apps/api/core";
+import { create } from "zustand";
+import type { GithubRelease } from "@/types/bindings/core";
+
+interface ReleasesState {
+ // State
+ releases: GithubRelease[];
+ isLoading: boolean;
+ isLoaded: boolean;
+ error: string | null;
+
+ // Actions
+ loadReleases: () => Promise<void>;
+ setReleases: (releases: GithubRelease[]) => void;
+ setIsLoading: (isLoading: boolean) => void;
+ setIsLoaded: (isLoaded: boolean) => void;
+ setError: (error: string | null) => void;
+}
+
+export const useReleasesStore = create<ReleasesState>((set, get) => ({
+ // Initial state
+ releases: [],
+ isLoading: false,
+ isLoaded: false,
+ error: null,
+
+ // Actions
+ loadReleases: async () => {
+ const { isLoaded, isLoading } = get();
+
+ // If already loaded or currently loading, skip to prevent duplicate requests
+ if (isLoaded || isLoading) return;
+
+ set({ isLoading: true, error: null });
+
+ try {
+ const releases = await invoke<GithubRelease[]>("get_github_releases");
+ set({ releases, isLoaded: true });
+ } catch (e) {
+ const error = e instanceof Error ? e.message : String(e);
+ console.error("Failed to load releases:", e);
+ set({ error });
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ setReleases: (releases) => {
+ set({ releases });
+ },
+
+ setIsLoading: (isLoading) => {
+ set({ isLoading });
+ },
+
+ setIsLoaded: (isLoaded) => {
+ set({ isLoaded });
+ },
+
+ setError: (error) => {
+ set({ error });
+ },
+}));
diff --git a/packages/ui/src/stores/releases.svelte.ts b/packages/ui/src/stores/releases.svelte.ts
deleted file mode 100644
index c858abb..0000000
--- a/packages/ui/src/stores/releases.svelte.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-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-store.ts b/packages/ui/src/stores/settings-store.ts
new file mode 100644
index 0000000..0bfc1e1
--- /dev/null
+++ b/packages/ui/src/stores/settings-store.ts
@@ -0,0 +1,568 @@
+import { convertFileSrc, invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { toast } from "sonner";
+import { create } from "zustand";
+import { downloadAdoptiumJava } from "@/client";
+import type { ModelInfo } from "../types/bindings/assistant";
+import type { LauncherConfig } from "../types/bindings/config";
+import type {
+ JavaDownloadProgress,
+ PendingJavaDownload,
+} from "../types/bindings/downloader";
+import type {
+ JavaCatalog,
+ JavaInstallation,
+ JavaReleaseInfo,
+} from "../types/bindings/java";
+
+type JavaDownloadSource = "adoptium" | "mojang" | "azul";
+
+/**
+ * State shape for settings store.
+ *
+ * Note: Uses camelCase naming to match ts-rs generated bindings (which now use
+ * `serde(rename_all = "camelCase")`). When reading raw binding objects from
+ * invoke, convert/mapping should be applied where necessary.
+ */
+interface SettingsState {
+ // State
+ settings: LauncherConfig;
+ javaInstallations: JavaInstallation[];
+ isDetectingJava: boolean;
+ showJavaDownloadModal: boolean;
+ selectedDownloadSource: JavaDownloadSource;
+ javaCatalog: JavaCatalog | null;
+ isLoadingCatalog: boolean;
+ catalogError: string;
+ selectedMajorVersion: number | null;
+ selectedImageType: "jre" | "jdk";
+ showOnlyRecommended: boolean;
+ searchQuery: string;
+ isDownloadingJava: boolean;
+ downloadProgress: JavaDownloadProgress | null;
+ javaDownloadStatus: string;
+ pendingDownloads: PendingJavaDownload[];
+ ollamaModels: ModelInfo[];
+ openaiModels: ModelInfo[];
+ isLoadingOllamaModels: boolean;
+ isLoadingOpenaiModels: boolean;
+ ollamaModelsError: string;
+ openaiModelsError: string;
+ showConfigEditor: boolean;
+ rawConfigContent: string;
+ configFilePath: string;
+ configEditorError: string;
+
+ // Computed / derived
+ backgroundUrl: string | undefined;
+ filteredReleases: JavaReleaseInfo[];
+ availableMajorVersions: number[];
+ installStatus: (
+ version: number,
+ imageType: string,
+ ) => "installed" | "downloading" | "available";
+ selectedRelease: JavaReleaseInfo | null;
+ currentModelOptions: Array<{
+ value: string;
+ label: string;
+ details?: string;
+ }>;
+
+ // Actions
+ loadSettings: () => Promise<void>;
+ saveSettings: () => Promise<void>;
+ // compatibility helper to mirror the older set({ key: value }) usage
+ set: (patch: Partial<Record<string, unknown>>) => void;
+
+ detectJava: () => Promise<void>;
+ selectJava: (path: string) => void;
+
+ openJavaDownloadModal: () => Promise<void>;
+ closeJavaDownloadModal: () => void;
+ loadJavaCatalog: (forceRefresh: boolean) => Promise<void>;
+ refreshCatalog: () => Promise<void>;
+ loadPendingDownloads: () => Promise<void>;
+ selectMajorVersion: (version: number) => void;
+ downloadJava: () => Promise<void>;
+ cancelDownload: () => Promise<void>;
+ resumeDownloads: () => Promise<void>;
+
+ openConfigEditor: () => Promise<void>;
+ closeConfigEditor: () => void;
+ saveRawConfig: () => Promise<void>;
+
+ loadOllamaModels: () => Promise<void>;
+ loadOpenaiModels: () => Promise<void>;
+
+ setSetting: <K extends keyof LauncherConfig>(
+ key: K,
+ value: LauncherConfig[K],
+ ) => void;
+ setAssistantSetting: <K extends keyof LauncherConfig["assistant"]>(
+ key: K,
+ value: LauncherConfig["assistant"][K],
+ ) => void;
+ setFeatureFlag: <K extends keyof LauncherConfig["featureFlags"]>(
+ key: K,
+ value: LauncherConfig["featureFlags"][K],
+ ) => void;
+
+ // Private
+ progressUnlisten: UnlistenFn | null;
+}
+
+/**
+ * Default settings (camelCase) — lightweight defaults used until `get_settings`
+ * returns real values.
+ */
+const defaultSettings: LauncherConfig = {
+ minMemory: 1024,
+ maxMemory: 2048,
+ javaPath: "java",
+ width: 854,
+ height: 480,
+ downloadThreads: 32,
+ enableGpuAcceleration: false,
+ enableVisualEffects: true,
+ activeEffect: "constellation",
+ theme: "dark",
+ customBackgroundPath: null,
+ logUploadService: "paste.rs",
+ pastebinApiKey: null,
+ assistant: {
+ enabled: true,
+ llmProvider: "ollama",
+ ollamaEndpoint: "http://localhost:11434",
+ ollamaModel: "llama3",
+ openaiApiKey: null,
+ openaiEndpoint: "https://api.openai.com/v1",
+ openaiModel: "gpt-3.5-turbo",
+ systemPrompt:
+ "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.",
+ responseLanguage: "auto",
+ ttsEnabled: false,
+ ttsProvider: "disabled",
+ },
+ useSharedCaches: false,
+ keepLegacyPerInstanceStorage: true,
+ featureFlags: {
+ demoUser: false,
+ quickPlayEnabled: false,
+ quickPlayPath: null,
+ quickPlaySingleplayer: true,
+ quickPlayMultiplayerServer: null,
+ },
+};
+
+export const useSettingsStore = create<SettingsState>((set, get) => ({
+ // initial state
+ settings: defaultSettings,
+ javaInstallations: [],
+ isDetectingJava: false,
+ showJavaDownloadModal: false,
+ selectedDownloadSource: "adoptium",
+ javaCatalog: null,
+ isLoadingCatalog: false,
+ catalogError: "",
+ selectedMajorVersion: null,
+ selectedImageType: "jre",
+ showOnlyRecommended: true,
+ searchQuery: "",
+ isDownloadingJava: false,
+ downloadProgress: null,
+ javaDownloadStatus: "",
+ pendingDownloads: [],
+ ollamaModels: [],
+ openaiModels: [],
+ isLoadingOllamaModels: false,
+ isLoadingOpenaiModels: false,
+ ollamaModelsError: "",
+ openaiModelsError: "",
+ showConfigEditor: false,
+ rawConfigContent: "",
+ configFilePath: "",
+ configEditorError: "",
+ progressUnlisten: null,
+
+ // derived getters
+ get backgroundUrl() {
+ const { settings } = get();
+ if (settings.customBackgroundPath) {
+ return convertFileSrc(settings.customBackgroundPath);
+ }
+ return undefined;
+ },
+
+ get filteredReleases() {
+ const {
+ javaCatalog,
+ selectedMajorVersion,
+ selectedImageType,
+ showOnlyRecommended,
+ searchQuery,
+ } = get();
+
+ if (!javaCatalog) return [];
+
+ let releases = javaCatalog.releases;
+
+ if (selectedMajorVersion !== null) {
+ releases = releases.filter(
+ (r) => r.majorVersion === selectedMajorVersion,
+ );
+ }
+
+ releases = releases.filter((r) => r.imageType === selectedImageType);
+
+ if (showOnlyRecommended) {
+ releases = releases.filter((r) => r.isLts);
+ }
+
+ if (searchQuery.trim() !== "") {
+ const q = searchQuery.toLowerCase();
+ releases = releases.filter(
+ (r) =>
+ r.version.toLowerCase().includes(q) ||
+ (r.releaseName ?? "").toLowerCase().includes(q),
+ );
+ }
+
+ // sort newest-first by parsed version number
+ return releases.sort((a, b) => {
+ const aVer = parseFloat(a.version.split("-")[0]);
+ const bVer = parseFloat(b.version.split("-")[0]);
+ return bVer - aVer;
+ });
+ },
+
+ get availableMajorVersions() {
+ return get().javaCatalog?.availableMajorVersions || [];
+ },
+
+ installStatus: (version: number, imageType: string) => {
+ const {
+ javaInstallations,
+ pendingDownloads,
+ isDownloadingJava,
+ downloadProgress,
+ } = get();
+
+ const installed = javaInstallations.some(
+ (inst) => parseInt(inst.version.split(".")[0], 10) === version,
+ );
+ if (installed) return "installed";
+
+ if (
+ isDownloadingJava &&
+ downloadProgress?.fileName?.includes(`${version}`)
+ ) {
+ return "downloading";
+ }
+
+ const pending = pendingDownloads.some(
+ (d) => d.majorVersion === version && d.imageType === imageType,
+ );
+ if (pending) return "downloading";
+
+ return "available";
+ },
+
+ get selectedRelease() {
+ const { javaCatalog, selectedMajorVersion, selectedImageType } = get();
+ if (!javaCatalog || selectedMajorVersion === null) return null;
+ return (
+ javaCatalog.releases.find(
+ (r) =>
+ r.majorVersion === selectedMajorVersion &&
+ r.imageType === selectedImageType,
+ ) || null
+ );
+ },
+
+ get currentModelOptions() {
+ const { settings, ollamaModels, openaiModels } = get();
+ const provider = settings.assistant.llmProvider;
+ if (provider === "ollama") {
+ return ollamaModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details || m.size || "",
+ }));
+ } else {
+ return openaiModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details || "",
+ }));
+ }
+ },
+
+ // actions
+ loadSettings: async () => {
+ try {
+ const result = await invoke<LauncherConfig>("get_settings");
+ // result already uses camelCase fields from bindings
+ set({ settings: result });
+
+ // enforce dark theme at app-level if necessary
+ if (result.theme !== "dark") {
+ const updated = { ...result, theme: "dark" } as LauncherConfig;
+ set({ settings: updated });
+ await invoke("save_settings", { config: updated });
+ }
+
+ // ensure customBackgroundPath is undefined rather than null for reactiveness
+ if (!result.customBackgroundPath) {
+ set((s) => ({
+ settings: { ...s.settings, customBackgroundPath: null },
+ }));
+ }
+ } catch (e) {
+ console.error("Failed to load settings:", e);
+ }
+ },
+
+ saveSettings: async () => {
+ try {
+ const { settings } = get();
+
+ // Clean up empty strings to null where appropriate
+ if ((settings.customBackgroundPath ?? "") === "") {
+ set((state) => ({
+ settings: { ...state.settings, customBackgroundPath: null },
+ }));
+ }
+
+ await invoke("save_settings", { config: settings });
+ toast.success("Settings saved!");
+ } catch (e) {
+ console.error("Failed to save settings:", e);
+ toast.error(`Error saving settings: ${String(e)}`);
+ }
+ },
+
+ set: (patch: Partial<Record<string, unknown>>) => {
+ set(patch);
+ },
+
+ detectJava: async () => {
+ set({ isDetectingJava: true });
+ try {
+ const installs = await invoke<JavaInstallation[]>("detect_java");
+ set({ javaInstallations: installs });
+ if (installs.length === 0) toast.info("No Java installations found");
+ else toast.success(`Found ${installs.length} Java installation(s)`);
+ } catch (e) {
+ console.error("Failed to detect Java:", e);
+ toast.error(`Error detecting Java: ${String(e)}`);
+ } finally {
+ set({ isDetectingJava: false });
+ }
+ },
+
+ selectJava: (path: string) => {
+ set((s) => ({ settings: { ...s.settings, javaPath: path } }));
+ },
+
+ openJavaDownloadModal: async () => {
+ set({
+ showJavaDownloadModal: true,
+ javaDownloadStatus: "",
+ catalogError: "",
+ downloadProgress: null,
+ });
+
+ // attach event listener for download progress
+ const state = get();
+ if (state.progressUnlisten) {
+ state.progressUnlisten();
+ }
+
+ const unlisten = await listen<JavaDownloadProgress>(
+ "java-download-progress",
+ (event) => {
+ set({ downloadProgress: event.payload });
+ },
+ );
+
+ set({ progressUnlisten: unlisten });
+
+ // load catalog and pending downloads
+ await get().loadJavaCatalog(false);
+ await get().loadPendingDownloads();
+ },
+
+ closeJavaDownloadModal: () => {
+ const { isDownloadingJava, progressUnlisten } = get();
+
+ if (!isDownloadingJava) {
+ set({ showJavaDownloadModal: false });
+ if (progressUnlisten) {
+ try {
+ progressUnlisten();
+ } catch {
+ // ignore
+ }
+ set({ progressUnlisten: null });
+ }
+ }
+ },
+
+ loadJavaCatalog: async (forceRefresh: boolean) => {
+ set({ isLoadingCatalog: true, catalogError: "" });
+ try {
+ const cmd = forceRefresh ? "refresh_java_catalog" : "get_java_catalog";
+ const result = await invoke<JavaCatalog>(cmd);
+ set({ javaCatalog: result, isLoadingCatalog: false });
+ } catch (e) {
+ console.error("Failed to load Java catalog:", e);
+ set({ catalogError: String(e), isLoadingCatalog: false });
+ }
+ },
+
+ refreshCatalog: async () => {
+ await get().loadJavaCatalog(true);
+ },
+
+ loadPendingDownloads: async () => {
+ try {
+ const pending = await invoke<PendingJavaDownload[]>(
+ "get_pending_java_downloads",
+ );
+ set({ pendingDownloads: pending });
+ } catch (e) {
+ console.error("Failed to load pending downloads:", e);
+ }
+ },
+
+ selectMajorVersion: (version: number) => {
+ set({ selectedMajorVersion: version });
+ },
+
+ downloadJava: async () => {
+ const { selectedMajorVersion, selectedImageType, selectedDownloadSource } =
+ get();
+ if (!selectedMajorVersion) return;
+ set({ isDownloadingJava: true, javaDownloadStatus: "Starting..." });
+ try {
+ const result = await downloadAdoptiumJava(
+ selectedMajorVersion,
+ selectedImageType,
+ selectedDownloadSource,
+ );
+ set({
+ javaDownloadStatus: `Java ${selectedMajorVersion} download started: ${result.path}`,
+ });
+ toast.success("Download started");
+ } catch (e) {
+ console.error("Failed to download Java:", e);
+ toast.error(`Failed to start Java download: ${String(e)}`);
+ } finally {
+ set({ isDownloadingJava: false });
+ }
+ },
+
+ cancelDownload: async () => {
+ try {
+ await invoke("cancel_java_download");
+ toast.success("Cancelled Java download");
+ set({ isDownloadingJava: false, javaDownloadStatus: "" });
+ } catch (e) {
+ console.error("Failed to cancel download:", e);
+ toast.error(`Failed to cancel download: ${String(e)}`);
+ }
+ },
+
+ resumeDownloads: async () => {
+ try {
+ const installed = await invoke<boolean>("resume_java_downloads");
+ if (installed) toast.success("Resumed Java downloads");
+ else toast.info("No downloads to resume");
+ } catch (e) {
+ console.error("Failed to resume downloads:", e);
+ toast.error(`Failed to resume downloads: ${String(e)}`);
+ }
+ },
+
+ openConfigEditor: async () => {
+ try {
+ const path = await invoke<string>("get_config_path");
+ const content = await invoke<string>("read_config_raw");
+ set({
+ configFilePath: path,
+ rawConfigContent: content,
+ showConfigEditor: true,
+ });
+ } catch (e) {
+ console.error("Failed to open config editor:", e);
+ set({ configEditorError: String(e) });
+ }
+ },
+
+ closeConfigEditor: () => {
+ set({
+ showConfigEditor: false,
+ rawConfigContent: "",
+ configFilePath: "",
+ configEditorError: "",
+ });
+ },
+
+ saveRawConfig: async () => {
+ try {
+ await invoke("write_config_raw", { content: get().rawConfigContent });
+ toast.success("Config saved");
+ set({ showConfigEditor: false });
+ } catch (e) {
+ console.error("Failed to save config:", e);
+ set({ configEditorError: String(e) });
+ toast.error(`Failed to save config: ${String(e)}`);
+ }
+ },
+
+ loadOllamaModels: async () => {
+ set({ isLoadingOllamaModels: true, ollamaModelsError: "" });
+ try {
+ const models = await invoke<ModelInfo[]>("get_ollama_models");
+ set({ ollamaModels: models, isLoadingOllamaModels: false });
+ } catch (e) {
+ console.error("Failed to load Ollama models:", e);
+ set({ isLoadingOllamaModels: false, ollamaModelsError: String(e) });
+ }
+ },
+
+ loadOpenaiModels: async () => {
+ set({ isLoadingOpenaiModels: true, openaiModelsError: "" });
+ try {
+ const models = await invoke<ModelInfo[]>("get_openai_models");
+ set({ openaiModels: models, isLoadingOpenaiModels: false });
+ } catch (e) {
+ console.error("Failed to load OpenAI models:", e);
+ set({ isLoadingOpenaiModels: false, openaiModelsError: String(e) });
+ }
+ },
+
+ setSetting: (key, value) => {
+ set((s) => ({
+ settings: { ...s.settings, [key]: value } as unknown as LauncherConfig,
+ }));
+ },
+
+ setAssistantSetting: (key, value) => {
+ set((s) => ({
+ settings: {
+ ...s.settings,
+ assistant: { ...s.settings.assistant, [key]: value },
+ } as LauncherConfig,
+ }));
+ },
+
+ setFeatureFlag: (key, value) => {
+ set((s) => ({
+ settings: {
+ ...s.settings,
+ featureFlags: { ...s.settings.featureFlags, [key]: value },
+ } as LauncherConfig,
+ }));
+ },
+}));
diff --git a/packages/ui/src/stores/settings.svelte.ts b/packages/ui/src/stores/settings.svelte.ts
deleted file mode 100644
index 5d20050..0000000
--- a/packages/ui/src/stores/settings.svelte.ts
+++ /dev/null
@@ -1,570 +0,0 @@
-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-store.ts b/packages/ui/src/stores/ui-store.ts
new file mode 100644
index 0000000..89b9191
--- /dev/null
+++ b/packages/ui/src/stores/ui-store.ts
@@ -0,0 +1,42 @@
+import { create } from "zustand";
+
+export type ViewType = "home" | "versions" | "settings" | "guide" | "instances";
+
+interface UIState {
+ // State
+ currentView: ViewType;
+ showConsole: boolean;
+ appVersion: string;
+
+ // Actions
+ toggleConsole: () => void;
+ setView: (view: ViewType) => void;
+ setAppVersion: (version: string) => void;
+}
+
+export const useUIStore = create<UIState>((set) => ({
+ // Initial state
+ currentView: "home",
+ showConsole: false,
+ appVersion: "...",
+
+ // Actions
+ toggleConsole: () => {
+ set((state) => ({ showConsole: !state.showConsole }));
+ },
+
+ setView: (view: ViewType) => {
+ set({ currentView: view });
+ },
+
+ setAppVersion: (version: string) => {
+ set({ appVersion: version });
+ },
+}));
+
+// Provide lowercase alias for compatibility with existing imports.
+// Use a function wrapper to ensure the named export exists as a callable value
+// at runtime (some bundlers/tree-shakers may remove simple aliases).
+export function useUiStore() {
+ return useUIStore();
+}
diff --git a/packages/ui/src/stores/ui.svelte.ts b/packages/ui/src/stores/ui.svelte.ts
deleted file mode 100644
index e88f6b4..0000000
--- a/packages/ui/src/stores/ui.svelte.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-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();