From 4a504c7e3d0c50cb90907d7903bc325d7daaf369 Mon Sep 17 00:00:00 2001 From: HsiangNianian Date: Thu, 12 Mar 2026 15:40:18 +0800 Subject: feat(instance): finish multi instances system --- packages/ui/src/client.ts | 35 + packages/ui/src/models/instance.ts | 82 ++- packages/ui/src/pages/instances-view.tsx | 164 ++++- packages/ui/src/stores/game-store.ts | 152 +++- src-tauri/src/core/instance.rs | 502 ++++++++++++- src-tauri/src/main.rs | 1128 ++++++++++++++++++------------ 6 files changed, 1508 insertions(+), 555 deletions(-) diff --git a/packages/ui/src/client.ts b/packages/ui/src/client.ts index 18d2377..0739861 100644 --- a/packages/ui/src/client.ts +++ b/packages/ui/src/client.ts @@ -25,6 +25,13 @@ import type { VersionMetadata, } from "@/types"; +export interface InstanceRepairResult { + restoredInstances: number; + removedStaleEntries: number; + createdDefaultActive: boolean; + activeInstanceId: string | null; +} + export function assistantChat(messages: Message[]): Promise { return invoke("assistant_chat", { messages, @@ -119,6 +126,16 @@ export function duplicateInstance( }); } +export function exportInstance( + instanceId: string, + archivePath: string, +): Promise { + return invoke("export_instance", { + instanceId, + archivePath, + }); +} + export function fetchAdoptiumJava( majorVersion: number, imageType: string, @@ -267,6 +284,16 @@ export function installVersion( }); } +export function importInstance( + archivePath: string, + newName?: string, +): Promise { + return invoke("import_instance", { + archivePath, + newName, + }); +} + export function isFabricInstalled( instanceId: string, gameVersion: string, @@ -351,6 +378,10 @@ export function refreshJavaCatalog(): Promise { return invoke("refresh_java_catalog"); } +export function repairInstances(): Promise { + return invoke("repair_instances"); +} + export function resumeJavaDownloads(): Promise { return invoke("resume_java_downloads"); } @@ -383,6 +414,10 @@ export function startGame( }); } +export function stopGame(): Promise { + return invoke("stop_game"); +} + export function startMicrosoftLogin(): Promise { return invoke("start_microsoft_login"); } diff --git a/packages/ui/src/models/instance.ts b/packages/ui/src/models/instance.ts index b1b463e..e1eb7c1 100644 --- a/packages/ui/src/models/instance.ts +++ b/packages/ui/src/models/instance.ts @@ -4,10 +4,13 @@ import { createInstance, deleteInstance, duplicateInstance, + exportInstance, getActiveInstance, getInstance, + importInstance, listInstances, - setActiveInstance, + repairInstances, + setActiveInstance as setActiveInstanceCommand, updateInstance, } from "@/client"; import type { Instance } from "@/types"; @@ -22,6 +25,9 @@ interface InstanceState { update: (instance: Instance) => Promise; setActiveInstance: (instance: Instance) => Promise; duplicate: (id: string, newName: string) => Promise; + exportArchive: (id: string, archivePath: string) => Promise; + importArchive: (archivePath: string, newName?: string) => Promise; + repair: () => Promise; get: (id: string) => Promise; } @@ -30,14 +36,20 @@ export const useInstanceStore = create((set, get) => ({ activeInstance: null, refresh: async () => { - const { setActiveInstance } = get(); try { const instances = await listInstances(); - const activeInstance = await getActiveInstance(); + let activeInstance = await getActiveInstance(); + + if ( + activeInstance && + !instances.some((instance) => instance.id === activeInstance?.id) + ) { + activeInstance = null; + } if (!activeInstance && instances.length > 0) { - // If no active instance but instances exist, set the first one as active - await setActiveInstance(instances[0]); + await setActiveInstanceCommand(instances[0].id); + activeInstance = instances[0]; } set({ instances, activeInstance }); @@ -51,35 +63,27 @@ export const useInstanceStore = create((set, get) => ({ 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("Error creating instance"); + toast.error(String(e)); return null; } }, delete: async (id) => { - const { refresh, instances, activeInstance, setActiveInstance } = get(); + const { refresh } = get(); try { await deleteInstance(id); await refresh(); - // If deleted instance was active, set another as active - if (activeInstance?.id === id) { - if (instances.length > 0) { - await setActiveInstance(instances[0]); - } else { - set({ activeInstance: null }); - } - } - toast.success("Instance deleted successfully"); } catch (e) { console.error("Failed to delete instance:", e); - toast.error("Error deleting instance"); + toast.error(String(e)); } }, @@ -96,7 +100,7 @@ export const useInstanceStore = create((set, get) => ({ }, setActiveInstance: async (instance) => { - await setActiveInstance(instance.id); + await setActiveInstanceCommand(instance.id); set({ activeInstance: instance }); }, @@ -104,16 +108,56 @@ export const useInstanceStore = create((set, get) => ({ const { refresh } = get(); try { const instance = await duplicateInstance(id, newName); + await setActiveInstanceCommand(instance.id); await refresh(); toast.success(`Instance duplicated as "${newName}"`); return instance; } catch (e) { console.error("Failed to duplicate instance:", e); - toast.error("Error duplicating instance"); + toast.error(String(e)); + return null; + } + }, + + exportArchive: async (id, archivePath) => { + try { + await exportInstance(id, archivePath); + toast.success("Instance exported successfully"); + } catch (e) { + console.error("Failed to export instance:", e); + toast.error(String(e)); + } + }, + + importArchive: async (archivePath, newName) => { + const { refresh } = get(); + try { + const instance = await importInstance(archivePath, newName); + await setActiveInstanceCommand(instance.id); + await refresh(); + toast.success(`Instance "${instance.name}" imported successfully`); + return instance; + } catch (e) { + console.error("Failed to import instance:", e); + toast.error(String(e)); return null; } }, + repair: async () => { + const { refresh } = get(); + try { + const result = await repairInstances(); + await refresh(); + toast.success( + `Repair completed: restored ${result.restoredInstances}, removed ${result.removedStaleEntries}`, + ); + } catch (e) { + console.error("Failed to repair instances:", e); + toast.error(String(e)); + } + }, + get: async (id) => { try { return await getInstance(id); diff --git a/packages/ui/src/pages/instances-view.tsx b/packages/ui/src/pages/instances-view.tsx index e99004c..4d36201 100644 --- a/packages/ui/src/pages/instances-view.tsx +++ b/packages/ui/src/pages/instances-view.tsx @@ -1,7 +1,16 @@ -import { CopyIcon, EditIcon, Plus, RocketIcon, Trash2Icon } from "lucide-react"; +import { open, save } from "@tauri-apps/plugin-dialog"; +import { + CopyIcon, + EditIcon, + FolderOpenIcon, + Plus, + RocketIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { startGame } from "@/client"; +import { openFileExplorer } from "@/client"; import InstanceCreationModal from "@/components/instance-creation-modal"; import InstanceEditorModal from "@/components/instance-editor-modal"; import { Button } from "@/components/ui/button"; @@ -15,11 +24,22 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { useAuthStore } from "@/models/auth"; import { useInstanceStore } from "@/models/instance"; +import { useGameStore } from "@/stores/game-store"; import type { Instance } from "@/types"; export function InstancesView() { + const account = useAuthStore((state) => state.account); const instancesStore = useInstanceStore(); + const startGame = useGameStore((state) => state.startGame); + const stopGame = useGameStore((state) => state.stopGame); + const runningInstanceId = useGameStore((state) => state.runningInstanceId); + const launchingInstanceId = useGameStore((state) => state.launchingInstanceId); + const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId); + const [isImporting, setIsImporting] = useState(false); + const [repairing, setRepairing] = useState(false); + const [exportingId, setExportingId] = useState(null); // Modal / UI state const [showCreateModal, setShowCreateModal] = useState(false); @@ -78,20 +98,73 @@ export function InstancesView() { setShowDuplicateModal(false); }; + const handleImport = async () => { + setIsImporting(true); + try { + const selected = await open({ + multiple: false, + filters: [{ name: "Zip Archive", extensions: ["zip"] }], + }); + + if (typeof selected !== "string") { + return; + } + + await instancesStore.importArchive(selected); + } finally { + setIsImporting(false); + } + }; + + const handleRepair = async () => { + setRepairing(true); + try { + await instancesStore.repair(); + } finally { + setRepairing(false); + } + }; + + const handleExport = async (instance: Instance) => { + setExportingId(instance.id); + try { + const filePath = await save({ + defaultPath: `${instance.name.replace(/[\\/:*?"<>|]/g, "_")}.zip`, + filters: [{ name: "Zip Archive", extensions: ["zip"] }], + }); + + if (!filePath) { + return; + } + + await instancesStore.exportArchive(instance.id, filePath); + } finally { + setExportingId(null); + } + }; + return (

Instances

- +
+ + + +
{instancesStore.instances.length === 0 ? ( @@ -105,6 +178,10 @@ export function InstancesView() {
    {instancesStore.instances.map((instance) => { const isActive = instancesStore.activeInstance?.id === instance.id; + const isRunning = runningInstanceId === instance.id; + const isLaunching = launchingInstanceId === instance.id; + const isStopping = stoppingInstanceId === instance.id; + const otherInstanceRunning = runningInstanceId !== null && !isRunning; return (
  • + +