aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/client.ts35
-rw-r--r--packages/ui/src/models/instance.ts82
-rw-r--r--packages/ui/src/pages/instances-view.tsx164
-rw-r--r--packages/ui/src/stores/game-store.ts152
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;
}
},