aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/models
diff options
context:
space:
mode:
authorNtskwK <natsukawa247@outlook.com>2026-03-30 16:49:08 +0800
committerNtskwK <natsukawa247@outlook.com>2026-03-30 16:49:08 +0800
commit878d66f9add4e4026a26ae2fa2a1226b5259154d (patch)
treeaf78680c8d4f357843ab336bdac6e56a622de3c7 /packages/ui/src/models
parentf8b4bcb3bdc8f11323103081ef8c05b06159d684 (diff)
parentc4dc0676d794bca2613be282867d369328ebf073 (diff)
downloadDropOut-878d66f9add4e4026a26ae2fa2a1226b5259154d.tar.gz
DropOut-878d66f9add4e4026a26ae2fa2a1226b5259154d.zip
Merge branch 'main' of https://github.com/HydroRoll-Team/DropOut
Diffstat (limited to 'packages/ui/src/models')
-rw-r--r--packages/ui/src/models/assistant-store.ts.bk201
-rw-r--r--packages/ui/src/models/auth.ts5
-rw-r--r--packages/ui/src/models/game.ts113
-rw-r--r--packages/ui/src/models/instance.ts23
-rw-r--r--packages/ui/src/models/logs-store.ts.bk200
-rw-r--r--packages/ui/src/models/settings-store.ts.bk568
6 files changed, 1096 insertions, 14 deletions
diff --git a/packages/ui/src/models/assistant-store.ts.bk b/packages/ui/src/models/assistant-store.ts.bk
new file mode 100644
index 0000000..180031b
--- /dev/null
+++ b/packages/ui/src/models/assistant-store.ts.bk
@@ -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/models/auth.ts b/packages/ui/src/models/auth.ts
index 9c814d2..d64b67a 100644
--- a/packages/ui/src/models/auth.ts
+++ b/packages/ui/src/models/auth.ts
@@ -95,7 +95,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} catch (error) {
const message = getAuthErrorMessage(error);
console.error("Failed to start Microsoft login:", error);
- set({ loginMode: null, statusMessage: `Failed to start login: ${message}` });
+ set({
+ loginMode: null,
+ statusMessage: `Failed to start login: ${message}`,
+ });
toast.error(`Failed to start Microsoft login: ${message}`);
}
},
diff --git a/packages/ui/src/models/game.ts b/packages/ui/src/models/game.ts
new file mode 100644
index 0000000..5342078
--- /dev/null
+++ b/packages/ui/src/models/game.ts
@@ -0,0 +1,113 @@
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { toast } from "sonner";
+import { create } from "zustand";
+import {
+ startGame as startGameCommand,
+ stopGame as stopGameCommand,
+} from "@/client";
+import type { GameExitedEvent } from "@/types/bindings/core";
+
+interface GameState {
+ runningInstanceId: string | null;
+ runningVersionId: string | null;
+ launchingInstanceId: string | null;
+ stoppingInstanceId: string | null;
+ lifecycleUnlisten: UnlistenFn | null;
+
+ isGameRunning: boolean;
+ startGame: (instanceId: string, versionId: string) => Promise<string | null>;
+ stopGame: (instanceId?: string | null) => Promise<string | null>;
+}
+
+export const useGameStore = create<GameState>((set, get) => ({
+ runningInstanceId: null,
+ runningVersionId: null,
+ launchingInstanceId: null,
+ stoppingInstanceId: null,
+ lifecycleUnlisten: null,
+
+ get isGameRunning() {
+ return get().runningInstanceId !== null;
+ },
+
+ startGame: async (instanceId, versionId) => {
+ const { isGameRunning, lifecycleUnlisten } = get();
+
+ if (isGameRunning) {
+ toast.info("A game is already running");
+ return null;
+ } else {
+ lifecycleUnlisten?.();
+ }
+
+ set({
+ launchingInstanceId: instanceId,
+ });
+ toast.info(`Preparing to launch ${versionId}...`);
+
+ const unlisten = await listen<GameExitedEvent>("game-exited", (event) => {
+ const { instanceId, versionId, wasStopped, exitCode } = event.payload;
+
+ set({
+ runningInstanceId: null,
+ runningVersionId: null,
+ launchingInstanceId: null,
+ stoppingInstanceId: null,
+ });
+
+ if (wasStopped) {
+ toast.success(
+ `Stopped Minecraft ${versionId} for instance ${instanceId}`,
+ );
+ } else {
+ toast.info(
+ `Minecraft ${versionId} exited with code ${exitCode} for instance ${instanceId}`,
+ );
+ }
+ });
+
+ set({ lifecycleUnlisten: unlisten });
+
+ try {
+ const message = await startGameCommand(instanceId, versionId);
+ set({
+ launchingInstanceId: null,
+ runningInstanceId: instanceId,
+ runningVersionId: versionId,
+ });
+ toast.success(message);
+ return message;
+ } catch (e) {
+ console.error(e);
+ set({ launchingInstanceId: null });
+ toast.error(`Error: ${e}`);
+ return null;
+ }
+ },
+
+ stopGame: async (instanceId) => {
+ const { runningInstanceId } = get();
+
+ if (!runningInstanceId) {
+ toast.info("No running game found");
+ return null;
+ }
+
+ if (instanceId !== runningInstanceId) {
+ toast.info("That instance is not the one currently running");
+ return null;
+ }
+
+ set({ stoppingInstanceId: runningInstanceId });
+
+ try {
+ return await stopGameCommand();
+ } catch (e) {
+ console.error("Failed to stop game:", e);
+ toast.error(`Failed to stop game: ${e}`);
+ return null;
+ } finally {
+ set({ stoppingInstanceId: null });
+ }
+ },
+}));
diff --git a/packages/ui/src/models/instance.ts b/packages/ui/src/models/instance.ts
index e1eb7c1..8c108c1 100644
--- a/packages/ui/src/models/instance.ts
+++ b/packages/ui/src/models/instance.ts
@@ -20,13 +20,16 @@ interface InstanceState {
activeInstance: Instance | null;
refresh: () => Promise<void>;
- create: (name: string) => Promise<Instance | null>;
+ create: (name: string) => Promise<Instance>;
delete: (id: string) => Promise<void>;
update: (instance: Instance) => Promise<void>;
setActiveInstance: (instance: Instance) => Promise<void>;
duplicate: (id: string, newName: string) => Promise<Instance | null>;
exportArchive: (id: string, archivePath: string) => Promise<void>;
- importArchive: (archivePath: string, newName?: string) => Promise<Instance | null>;
+ importArchive: (
+ archivePath: string,
+ newName?: string,
+ ) => Promise<Instance | null>;
repair: () => Promise<void>;
get: (id: string) => Promise<Instance | null>;
}
@@ -61,17 +64,11 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
create: async (name) => {
const { refresh } = get();
- try {
- const instance = await createInstance(name);
- await setActiveInstanceCommand(instance.id);
- await refresh();
- toast.success(`Instance "${name}" created successfully`);
- return instance;
- } catch (e) {
- console.error("Failed to create instance:", e);
- toast.error(String(e));
- return null;
- }
+ const instance = await createInstance(name);
+ await setActiveInstanceCommand(instance.id);
+ await refresh();
+ toast.success(`Instance "${name}" created successfully`);
+ return instance;
},
delete: async (id) => {
diff --git a/packages/ui/src/models/logs-store.ts.bk b/packages/ui/src/models/logs-store.ts.bk
new file mode 100644
index 0000000..b19f206
--- /dev/null
+++ b/packages/ui/src/models/logs-store.ts.bk
@@ -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/models/settings-store.ts.bk b/packages/ui/src/models/settings-store.ts.bk
new file mode 100644
index 0000000..0bfc1e1
--- /dev/null
+++ b/packages/ui/src/models/settings-store.ts.bk
@@ -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,
+ }));
+ },
+}));