diff options
| author | 2026-03-12 15:40:18 +0800 | |
|---|---|---|
| committer | 2026-03-12 15:40:18 +0800 | |
| commit | 4a504c7e3d0c50cb90907d7903bc325d7daaf369 (patch) | |
| tree | 3c8033f253c724a2480a769c293bd033f0ff2da3 /packages/ui/src | |
| parent | b63cf2e9cdba4dd4960aba61756bc2dca5666fa9 (diff) | |
| download | DropOut-4a504c7e3d0c50cb90907d7903bc325d7daaf369.tar.gz DropOut-4a504c7e3d0c50cb90907d7903bc325d7daaf369.zip | |
feat(instance): finish multi instances system
Diffstat (limited to 'packages/ui/src')
| -rw-r--r-- | packages/ui/src/client.ts | 35 | ||||
| -rw-r--r-- | packages/ui/src/models/instance.ts | 82 | ||||
| -rw-r--r-- | packages/ui/src/pages/instances-view.tsx | 164 | ||||
| -rw-r--r-- | packages/ui/src/stores/game-store.ts | 152 |
4 files changed, 362 insertions, 71 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<Message> { return invoke<Message>("assistant_chat", { messages, @@ -119,6 +126,16 @@ export function duplicateInstance( }); } +export function exportInstance( + instanceId: string, + archivePath: string, +): Promise<string> { + return invoke<string>("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<Instance> { + return invoke<Instance>("import_instance", { + archivePath, + newName, + }); +} + export function isFabricInstalled( instanceId: string, gameVersion: string, @@ -351,6 +378,10 @@ export function refreshJavaCatalog(): Promise<JavaCatalog> { return invoke<JavaCatalog>("refresh_java_catalog"); } +export function repairInstances(): Promise<InstanceRepairResult> { + return invoke<InstanceRepairResult>("repair_instances"); +} + export function resumeJavaDownloads(): Promise<JavaInstallation[]> { return invoke<JavaInstallation[]>("resume_java_downloads"); } @@ -383,6 +414,10 @@ export function startGame( }); } +export function stopGame(): Promise<string> { + return invoke<string>("stop_game"); +} + export function startMicrosoftLogin(): Promise<DeviceCodeResponse> { return invoke<DeviceCodeResponse>("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<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>; + repair: () => Promise<void>; get: (id: string) => Promise<Instance | null>; } @@ -30,14 +36,20 @@ export const useInstanceStore = create<InstanceState>((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<InstanceState>((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<InstanceState>((set, get) => ({ }, setActiveInstance: async (instance) => { - await setActiveInstance(instance.id); + await setActiveInstanceCommand(instance.id); set({ activeInstance: instance }); }, @@ -104,16 +108,56 @@ export const useInstanceStore = create<InstanceState>((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<string | null>(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 ( <div className="h-full flex flex-col gap-4 p-6 overflow-y-auto"> <div className="flex items-center justify-between"> <h1 className="text-2xl font-bold text-gray-900 dark:text-white"> Instances </h1> - <Button - type="button" - onClick={openCreate} - className="px-4 py-2 transition-colors" - > - <Plus size={18} /> - Create Instance - </Button> + <div className="flex items-center gap-2"> + <Button type="button" variant="outline" onClick={handleImport}> + {isImporting ? "Importing..." : "Import"} + </Button> + <Button type="button" variant="outline" onClick={handleRepair}> + {repairing ? "Repairing..." : "Repair Index"} + </Button> + <Button + type="button" + onClick={openCreate} + className="px-4 py-2 transition-colors" + > + <Plus size={18} /> + Create Instance + </Button> + </div> </div> {instancesStore.instances.length === 0 ? ( @@ -105,6 +178,10 @@ export function InstancesView() { <ul className="flex flex-col space-y-3"> {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 ( <li @@ -164,22 +241,71 @@ export function InstancesView() { <div className="flex items-center"> <div className="flex flex-row space-x-2"> <Button - variant="ghost" + variant={isRunning ? "destructive" : "ghost"} size="icon" - onClick={async () => { + onClick={async (e) => { + e.stopPropagation(); + + try { + await instancesStore.setActiveInstance(instance); + } catch (error) { + console.error("Failed to set active instance:", error); + toast.error("Error setting active instance"); + return; + } + + if (isRunning) { + await stopGame(instance.id); + return; + } + if (!instance.versionId) { toast.error("No version selected or installed"); return; } - try { - await startGame(instance.id, instance.versionId); - } catch (e) { - console.error("Failed to start game:", e); - toast.error("Error starting game"); - } + + await startGame( + account, + () => { + toast.info("Please login first"); + }, + instance.id, + instance.versionId, + () => undefined, + ); }} + disabled={otherInstanceRunning || isLaunching || isStopping} > - <RocketIcon /> + {isLaunching || isStopping ? ( + <span className="text-xs">...</span> + ) : isRunning ? ( + <XIcon /> + ) : ( + <RocketIcon /> + )} + </Button> + <Button + variant="ghost" + size="icon" + onClick={(e) => { + e.stopPropagation(); + void openFileExplorer(instance.gameDir); + }} + > + <FolderOpenIcon /> + </Button> + <Button + variant="ghost" + size="icon" + onClick={(e) => { + e.stopPropagation(); + void handleExport(instance); + }} + disabled={exportingId === instance.id} + > + <span className="text-xs"> + {exportingId === instance.id ? "..." : "ZIP"} + </span> </Button> <Button variant="ghost" diff --git a/packages/ui/src/stores/game-store.ts b/packages/ui/src/stores/game-store.ts index fa0f9f8..7e407de 100644 --- a/packages/ui/src/stores/game-store.ts +++ b/packages/ui/src/stores/game-store.ts @@ -1,49 +1,98 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { toast } from "sonner"; import { create } from "zustand"; -import { getVersions } from "@/client"; +import { + getVersions, + getVersionsOfInstance, + startGame as startGameCommand, + stopGame as stopGameCommand, +} from "@/client"; +import type { Account } from "@/types/bindings/auth"; import type { Version } from "@/types/bindings/manifest"; +interface GameExitedEvent { + instanceId: string; + versionId: string; + exitCode: number | null; + wasStopped: boolean; +} + interface GameState { - // State versions: Version[]; selectedVersion: string; + runningInstanceId: string | null; + runningVersionId: string | null; + launchingInstanceId: string | null; + stoppingInstanceId: string | null; + lifecycleUnlisten: UnlistenFn | null; - // Computed property latestRelease: Version | undefined; + isGameRunning: boolean; - // Actions + initLifecycle: () => Promise<void>; loadVersions: (instanceId?: string) => Promise<void>; startGame: ( - currentAccount: any, + currentAccount: Account | null, openLoginModal: () => void, activeInstanceId: string | null, - setView: (view: any) => void, - ) => Promise<void>; + versionId: string | null, + setView: (view: string) => void, + ) => Promise<string | null>; + stopGame: (instanceId?: string | null) => Promise<string | null>; setSelectedVersion: (version: string) => void; setVersions: (versions: Version[]) => void; } export const useGameStore = create<GameState>((set, get) => ({ - // Initial state versions: [], selectedVersion: "", + runningInstanceId: null, + runningVersionId: null, + launchingInstanceId: null, + stoppingInstanceId: null, + lifecycleUnlisten: null, - // Computed property get latestRelease() { return get().versions.find((v) => v.type === "release"); }, - // Actions + get isGameRunning() { + return get().runningInstanceId !== null; + }, + + initLifecycle: async () => { + if (get().lifecycleUnlisten) { + return; + } + + const unlisten = await listen<GameExitedEvent>("game-exited", (event) => { + const { instanceId, versionId, wasStopped } = 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 for instance ${instanceId}`); + } + }); + + set({ lifecycleUnlisten: unlisten }); + }, + 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(); + const versions = instanceId + ? await getVersionsOfInstance(instanceId) + : 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: [] }); } }, @@ -52,42 +101,79 @@ export const useGameStore = create<GameState>((set, get) => ({ currentAccount, openLoginModal, activeInstanceId, + versionId, setView, ) => { - const { selectedVersion } = get(); + const { isGameRunning } = get(); + const targetVersion = versionId ?? get().selectedVersion; if (!currentAccount) { - alert("Please login first!"); + toast.info("Please login first"); openLoginModal(); - return; + return null; } - if (!selectedVersion) { - alert("Please select a version!"); - return; + if (!targetVersion) { + toast.info("Please select a version first"); + return null; } if (!activeInstanceId) { - alert("Please select an instance first!"); + toast.info("Please select an instance first"); setView("instances"); - return; + return null; + } + + if (isGameRunning) { + toast.info("A game is already running"); + return null; } - toast.info("Preparing to launch " + selectedVersion + "..."); + set({ + launchingInstanceId: activeInstanceId, + selectedVersion: targetVersion, + }); + toast.info(`Preparing to launch ${targetVersion}...`); 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!"); + const message = await startGameCommand(activeInstanceId, targetVersion); + set({ + launchingInstanceId: null, + runningInstanceId: activeInstanceId, + runningVersionId: targetVersion, + }); + 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 && 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); + set({ stoppingInstanceId: null }); + toast.error(`Failed to stop game: ${e}`); + return null; } }, |