aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui-new/src/stores
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui-new/src/stores')
-rw-r--r--packages/ui-new/src/stores/assistant-store.ts201
-rw-r--r--packages/ui-new/src/stores/auth-store.ts296
-rw-r--r--packages/ui-new/src/stores/game-store.ts101
-rw-r--r--packages/ui-new/src/stores/instances-store.ts149
-rw-r--r--packages/ui-new/src/stores/logs-store.ts200
-rw-r--r--packages/ui-new/src/stores/releases-store.ts63
-rw-r--r--packages/ui-new/src/stores/settings-store.ts568
-rw-r--r--packages/ui-new/src/stores/ui-store.ts42
8 files changed, 1620 insertions, 0 deletions
diff --git a/packages/ui-new/src/stores/assistant-store.ts b/packages/ui-new/src/stores/assistant-store.ts
new file mode 100644
index 0000000..180031b
--- /dev/null
+++ b/packages/ui-new/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-new/src/stores/auth-store.ts b/packages/ui-new/src/stores/auth-store.ts
new file mode 100644
index 0000000..bf7e3c5
--- /dev/null
+++ b/packages/ui-new/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-new/src/stores/game-store.ts b/packages/ui-new/src/stores/game-store.ts
new file mode 100644
index 0000000..541b386
--- /dev/null
+++ b/packages/ui-new/src/stores/game-store.ts
@@ -0,0 +1,101 @@
+import { invoke } from "@tauri-apps/api/core";
+import { toast } from "sonner";
+import { create } from "zustand";
+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 invoke<Version[]>("get_versions", { instanceId });
+ 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-new/src/stores/instances-store.ts b/packages/ui-new/src/stores/instances-store.ts
new file mode 100644
index 0000000..4636b79
--- /dev/null
+++ b/packages/ui-new/src/stores/instances-store.ts
@@ -0,0 +1,149 @@
+import { invoke } from "@tauri-apps/api/core";
+import { toast } from "sonner";
+import { create } from "zustand";
+import type { Instance } from "../types/bindings/instance";
+
+interface InstancesState {
+ // State
+ instances: Instance[];
+ activeInstanceId: string | null;
+
+ // Computed property
+ activeInstance: Instance | null;
+
+ // Actions
+ loadInstances: () => Promise<void>;
+ createInstance: (name: string) => Promise<Instance | null>;
+ deleteInstance: (id: string) => Promise<void>;
+ updateInstance: (instance: Instance) => Promise<void>;
+ setActiveInstance: (id: string) => Promise<void>;
+ duplicateInstance: (id: string, newName: string) => Promise<Instance | null>;
+ getInstance: (id: string) => Promise<Instance | null>;
+ setInstances: (instances: Instance[]) => void;
+ setActiveInstanceId: (id: string | null) => void;
+}
+
+export const useInstancesStore = create<InstancesState>((set, get) => ({
+ // Initial state
+ instances: [],
+ activeInstanceId: null,
+
+ // Computed property
+ get activeInstance() {
+ const { instances, activeInstanceId } = get();
+ if (!activeInstanceId) return null;
+ return instances.find((i) => i.id === activeInstanceId) || null;
+ },
+
+ // Actions
+ loadInstances: async () => {
+ try {
+ const instances = await invoke<Instance[]>("list_instances");
+ const active = await invoke<Instance | null>("get_active_instance");
+
+ let newActiveInstanceId = null;
+ if (active) {
+ newActiveInstanceId = active.id;
+ } else if (instances.length > 0) {
+ // If no active instance but instances exist, set the first one as active
+ await get().setActiveInstance(instances[0].id);
+ newActiveInstanceId = instances[0].id;
+ }
+
+ set({ instances, activeInstanceId: newActiveInstanceId });
+ } catch (e) {
+ console.error("Failed to load instances:", e);
+ toast.error("Error loading instances: " + String(e));
+ }
+ },
+
+ createInstance: async (name) => {
+ try {
+ const instance = await invoke<Instance>("create_instance", { name });
+ await get().loadInstances();
+ toast.success(`Instance "${name}" created successfully`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ toast.error("Error creating instance: " + String(e));
+ return null;
+ }
+ },
+
+ deleteInstance: async (id) => {
+ try {
+ await invoke("delete_instance", { instanceId: id });
+ await get().loadInstances();
+
+ // If deleted instance was active, set another as active
+ const { instances, activeInstanceId } = get();
+ if (activeInstanceId === id) {
+ if (instances.length > 0) {
+ await get().setActiveInstance(instances[0].id);
+ } else {
+ set({ activeInstanceId: null });
+ }
+ }
+
+ toast.success("Instance deleted successfully");
+ } catch (e) {
+ console.error("Failed to delete instance:", e);
+ toast.error("Error deleting instance: " + String(e));
+ }
+ },
+
+ updateInstance: async (instance) => {
+ try {
+ await invoke("update_instance", { instance });
+ await get().loadInstances();
+ toast.success("Instance updated successfully");
+ } catch (e) {
+ console.error("Failed to update instance:", e);
+ toast.error("Error updating instance: " + String(e));
+ }
+ },
+
+ setActiveInstance: async (id) => {
+ try {
+ await invoke("set_active_instance", { instanceId: id });
+ set({ activeInstanceId: id });
+ toast.success("Active instance changed");
+ } catch (e) {
+ console.error("Failed to set active instance:", e);
+ toast.error("Error setting active instance: " + String(e));
+ }
+ },
+
+ duplicateInstance: async (id, newName) => {
+ try {
+ const instance = await invoke<Instance>("duplicate_instance", {
+ instanceId: id,
+ newName,
+ });
+ await get().loadInstances();
+ toast.success(`Instance duplicated as "${newName}"`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to duplicate instance:", e);
+ toast.error("Error duplicating instance: " + String(e));
+ return null;
+ }
+ },
+
+ getInstance: async (id) => {
+ try {
+ return await invoke<Instance>("get_instance", { instanceId: id });
+ } catch (e) {
+ console.error("Failed to get instance:", e);
+ return null;
+ }
+ },
+
+ setInstances: (instances) => {
+ set({ instances });
+ },
+
+ setActiveInstanceId: (id) => {
+ set({ activeInstanceId: id });
+ },
+}));
diff --git a/packages/ui-new/src/stores/logs-store.ts b/packages/ui-new/src/stores/logs-store.ts
new file mode 100644
index 0000000..b19f206
--- /dev/null
+++ b/packages/ui-new/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-new/src/stores/releases-store.ts b/packages/ui-new/src/stores/releases-store.ts
new file mode 100644
index 0000000..56afa08
--- /dev/null
+++ b/packages/ui-new/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-new/src/stores/settings-store.ts b/packages/ui-new/src/stores/settings-store.ts
new file mode 100644
index 0000000..52da7fd
--- /dev/null
+++ b/packages/ui-new/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 type { ModelInfo } from "../types/bindings/assistant";
+import type { LauncherConfig } from "../types/bindings/config";
+import type {
+ JavaDownloadProgress,
+ PendingJavaDownload,
+} from "../types/bindings/downloader";
+import type {
+ JavaCatalog,
+ JavaDownloadInfo,
+ 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 invoke<JavaDownloadInfo>("download_java", {
+ majorVersion: selectedMajorVersion,
+ imageType: selectedImageType,
+ source: selectedDownloadSource,
+ });
+ set({
+ javaDownloadStatus: `Java ${selectedMajorVersion} download started: ${result.fileName}`,
+ });
+ 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-new/src/stores/ui-store.ts b/packages/ui-new/src/stores/ui-store.ts
new file mode 100644
index 0000000..89b9191
--- /dev/null
+++ b/packages/ui-new/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();
+}