diff options
| -rw-r--r-- | .env.example | 7 | ||||
| -rw-r--r-- | .gitignore | 7 | ||||
| -rw-r--r-- | packages/ui/src/client.ts | 29 | ||||
| -rw-r--r-- | packages/ui/src/components/bottom-bar.tsx | 248 | ||||
| -rw-r--r-- | packages/ui/src/models/auth.ts | 87 | ||||
| -rw-r--r-- | packages/ui/src/models/instance.ts | 82 | ||||
| -rw-r--r-- | packages/ui/src/pages/index.tsx | 7 | ||||
| -rw-r--r-- | packages/ui/src/pages/instances-view.tsx | 174 | ||||
| -rw-r--r-- | packages/ui/src/stores/game-store.ts | 147 | ||||
| -rw-r--r-- | packages/ui/src/types/bindings/core.ts | 7 | ||||
| -rw-r--r-- | packages/ui/src/types/bindings/instance.ts | 7 | ||||
| -rw-r--r-- | packages/ui/vite.config.ts | 4 | ||||
| -rw-r--r-- | src-tauri/Cargo.toml | 1 | ||||
| -rw-r--r-- | src-tauri/build.rs | 7 | ||||
| -rw-r--r-- | src-tauri/capabilities/default.json | 8 | ||||
| -rw-r--r-- | src-tauri/src/core/instance.rs | 516 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack.rs | 7 | ||||
| -rw-r--r-- | src-tauri/src/main.rs | 1124 | ||||
| -rw-r--r-- | src-tauri/tauri.conf.json | 3 |
19 files changed, 1735 insertions, 737 deletions
diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e7e55a --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy this file to .env and fill in values to enable optional features at build time. +# .env is gitignored and will never be committed. + +# CurseForge API key — required only to build with CurseForge modpack support. +# Obtain one at https://console.curseforge.com/ +# If absent, CurseForge modpack import is disabled at runtime (all other features work normally). +# CURSEFORGE_API_KEY=your_key_here @@ -29,8 +29,15 @@ __pycache__/ # Tauri artifacts artifacts/ +# Local secrets (do not commit) +.env +.env.local + # AUR Release release/ # claude code .claude/ + +# Vscode +.vscode/ diff --git a/packages/ui/src/client.ts b/packages/ui/src/client.ts index 18d2377..6f354d2 100644 --- a/packages/ui/src/client.ts +++ b/packages/ui/src/client.ts @@ -12,6 +12,7 @@ import type { InstalledForgeVersion, InstalledVersion, Instance, + InstanceRepairResult, JavaCatalog, JavaDownloadInfo, JavaInstallation, @@ -119,6 +120,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 +278,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 +372,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 +408,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/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx index 0710c3a..8f70985 100644 --- a/packages/ui/src/components/bottom-bar.tsx +++ b/packages/ui/src/components/bottom-bar.tsx @@ -1,11 +1,10 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { Play, User, XIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import { listInstalledVersions, startGame } from "@/client"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/models/auth"; import { useInstanceStore } from "@/models/instance"; +import { useGameStore } from "@/stores/game-store"; import { LoginModal } from "./login-modal"; import { Button } from "./ui/button"; import { @@ -18,150 +17,74 @@ import { } from "./ui/select"; import { Spinner } from "./ui/spinner"; -interface InstalledVersion { - id: string; - type: string; -} - export function BottomBar() { - const authStore = useAuthStore(); - const instancesStore = useInstanceStore(); + const account = useAuthStore((state) => state.account); + const instances = useInstanceStore((state) => state.instances); + const activeInstance = useInstanceStore((state) => state.activeInstance); + const setActiveInstance = useInstanceStore((state) => state.setActiveInstance); + const selectedVersion = useGameStore((state) => state.selectedVersion); + const setSelectedVersion = useGameStore((state) => state.setSelectedVersion); + 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 [isLaunched, setIsLaunched] = useState<boolean>(false); - const gameUnlisten = useRef<UnlistenFn | null>(null); - const [isLaunching, setIsLaunching] = useState<boolean>(false); - const [selectedVersion, setSelectedVersion] = useState<string | null>(null); - const [installedVersions, setInstalledVersions] = useState< - InstalledVersion[] - >([]); - const [isLoadingVersions, setIsLoadingVersions] = useState(true); const [showLoginModal, setShowLoginModal] = useState(false); - const loadInstalledVersions = useCallback(async () => { - if (!instancesStore.activeInstance) { - setInstalledVersions([]); - setIsLoadingVersions(false); + useEffect(() => { + const nextVersion = activeInstance?.versionId ?? ""; + if (selectedVersion === nextVersion) { return; } - setIsLoadingVersions(true); - try { - const versions = await listInstalledVersions( - instancesStore.activeInstance.id, - ); - setInstalledVersions(versions); + setSelectedVersion(nextVersion); + }, [activeInstance?.id, activeInstance?.versionId, selectedVersion, setSelectedVersion]); - // If no version is selected but we have installed versions, select the first one - if (!selectedVersion && versions.length > 0) { - setSelectedVersion(versions[0].id); + const handleInstanceChange = useCallback( + async (instanceId: string) => { + if (activeInstance?.id === instanceId) { + return; } - } catch (error) { - console.error("Failed to load installed versions:", error); - } finally { - setIsLoadingVersions(false); - } - }, [instancesStore.activeInstance, selectedVersion]); - useEffect(() => { - loadInstalledVersions(); - - // Listen for backend events that should refresh installed versions. - let unlistenDownload: UnlistenFn | null = null; - let unlistenVersionDeleted: UnlistenFn | null = null; - - (async () => { - try { - unlistenDownload = await listen("download-complete", () => { - loadInstalledVersions(); - }); - } catch (err) { - // best-effort: do not break UI if listening fails - // eslint-disable-next-line no-console - console.warn("Failed to attach download-complete listener:", err); + const nextInstance = instances.find((instance) => instance.id === instanceId); + if (!nextInstance) { + return; } try { - unlistenVersionDeleted = await listen("version-deleted", () => { - loadInstalledVersions(); - }); - } catch (err) { - // eslint-disable-next-line no-console - console.warn("Failed to attach version-deleted listener:", err); + await setActiveInstance(nextInstance); + } catch (error) { + console.error("Failed to activate instance:", error); + toast.error(`Failed to activate instance: ${String(error)}`); } - })(); - - return () => { - try { - if (unlistenDownload) unlistenDownload(); - } catch { - // ignore - } - try { - if (unlistenVersionDeleted) unlistenVersionDeleted(); - } catch { - // ignore - } - }; - }, [loadInstalledVersions]); + }, + [activeInstance?.id, instances, setActiveInstance], + ); const handleStartGame = async () => { - if (!selectedVersion) { - toast.info("Please select a version!"); - return; - } - - if (!instancesStore.activeInstance) { + if (!activeInstance) { toast.info("Please select an instance first!"); return; } - try { - gameUnlisten.current = await listen("game-exited", () => { - setIsLaunched(false); - }); - } catch (error) { - toast.warning(`Failed to listen to game-exited event: ${error}`); - } - - setIsLaunching(true); - try { - await startGame(instancesStore.activeInstance?.id, selectedVersion); - setIsLaunched(true); - } catch (error) { - console.error(`Failed to start game: ${error}`); - toast.error(`Failed to start game: ${error}`); - } finally { - setIsLaunching(false); - } + await startGame( + account, + () => setShowLoginModal(true), + activeInstance.id, + selectedVersion || activeInstance.versionId, + () => undefined, + ); }; - const getVersionTypeColor = (type: string) => { - switch (type) { - case "release": - return "bg-emerald-500"; - case "snapshot": - return "bg-amber-500"; - case "old_beta": - return "bg-rose-500"; - case "old_alpha": - return "bg-violet-500"; - default: - return "bg-gray-500"; - } + const handleStopGame = async () => { + await stopGame(runningInstanceId); }; - const versionOptions = useMemo( - () => - installedVersions.map((v) => ({ - label: `${v.id}${v.type !== "release" ? ` (${v.type})` : ""}`, - value: v.id, - type: v.type, - })), - [installedVersions], - ); - const renderButton = () => { - if (!authStore.account) { + const isGameRunning = runningInstanceId !== null; + + if (!account) { return ( <Button className="px-4 py-2" @@ -173,20 +96,20 @@ export function BottomBar() { ); } - return isLaunched ? ( - <Button - variant="destructive" - onClick={() => { - toast.warning( - "Minecraft Process will not be terminated, please close it manually.", - ); - setIsLaunched(false); - }} - > - <XIcon /> - Game started - </Button> - ) : ( + if (isGameRunning) { + return ( + <Button + variant="destructive" + onClick={handleStopGame} + disabled={stoppingInstanceId !== null} + > + {stoppingInstanceId ? <Spinner /> : <XIcon />} + Close + </Button> + ); + } + + return ( <Button className={cn( "px-4 py-2 shadow-xl", @@ -194,9 +117,9 @@ export function BottomBar() { )} size="lg" onClick={handleStartGame} - disabled={isLaunching} + disabled={launchingInstanceId === activeInstance?.id} > - {isLaunching ? <Spinner /> : <Play />} + {launchingInstanceId === activeInstance?.id ? <Spinner /> : <Play />} Start </Button> ); @@ -206,40 +129,39 @@ export function BottomBar() { <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/30 via-transparent to-transparent p-4 z-10"> <div className="max-w-7xl mx-auto"> <div className="flex items-center justify-between bg-white/5 dark:bg-black/20 backdrop-blur-xl border border-white/10 dark:border-white/5 p-3 shadow-lg"> - <div className="flex items-center gap-4"> - <div className="flex flex-col"> - <span className="text-xs font-mono text-zinc-400 uppercase tracking-wider"> - Active Instance - </span> - <span className="text-sm font-medium text-white"> - {instancesStore.activeInstance?.name || "No instance selected"} - </span> - </div> - + <div className="flex items-center gap-4 min-w-0"> <Select - value={selectedVersion} - items={versionOptions} - onValueChange={setSelectedVersion} - disabled={isLoadingVersions} + value={activeInstance?.id ?? null} + items={instances.map((instance) => ({ + label: instance.name, + value: instance.id, + }))} + onValueChange={(value) => { + if (value) { + void handleInstanceChange(value); + } + }} + disabled={instances.length === 0} > - <SelectTrigger className="max-w-48"> + <SelectTrigger className="w-full min-w-64 max-w-80"> <SelectValue placeholder={ - isLoadingVersions - ? "Loading versions..." - : "Please select a version" + instances.length === 0 + ? "No instances available" + : "Please select an instance" } /> </SelectTrigger> <SelectContent alignItemWithTrigger={false}> <SelectGroup> - {versionOptions.map((item) => ( - <SelectItem - key={item.value} - value={item.value} - className={getVersionTypeColor(item.type)} - > - {item.label} + {instances.map((instance) => ( + <SelectItem key={instance.id} value={instance.id}> + <div className="flex min-w-0 flex-col"> + <span className="truncate">{instance.name}</span> + <span className="text-muted-foreground truncate text-[11px]"> + {instance.versionId ?? "No version selected"} + </span> + </div> </SelectItem> ))} </SelectGroup> diff --git a/packages/ui/src/models/auth.ts b/packages/ui/src/models/auth.ts index 10b2a0d..9c814d2 100644 --- a/packages/ui/src/models/auth.ts +++ b/packages/ui/src/models/auth.ts @@ -13,6 +13,10 @@ import { } from "@/client"; import type { Account, DeviceCodeResponse } from "@/types"; +function getAuthErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export interface AuthState { account: Account | null; loginMode: Account["type"] | null; @@ -68,36 +72,78 @@ export const useAuthStore = create<AuthState>((set, get) => ({ toast.warning("Failed to attch auth-progress listener"); } - const deviceCode = await startMicrosoftLogin(); - navigator.clipboard?.writeText(deviceCode.userCode).catch((err) => { - console.error("Failed to copy to clipboard:", err); - }); - open(deviceCode.verificationUri).catch((err) => { - console.error("Failed to open browser:", err); - }); - const ms = Number(deviceCode.interval) * 1000; - const interval = setInterval(() => { - _pollLoginStatus(deviceCode.deviceCode, onSuccess); - }, ms); - set({ _pollingInterval: interval, deviceCode }); + try { + const deviceCode = await startMicrosoftLogin(); + + navigator.clipboard?.writeText(deviceCode.userCode).catch((err) => { + console.error("Failed to copy to clipboard:", err); + }); + open(deviceCode.verificationUri).catch((err) => { + console.error("Failed to open browser:", err); + }); + + const ms = Math.max(1, Number(deviceCode.interval) || 5) * 1000; + const interval = setInterval(() => { + _pollLoginStatus(deviceCode.deviceCode, onSuccess); + }, ms); + + set({ + _pollingInterval: interval, + deviceCode, + statusMessage: deviceCode.message ?? "Waiting for authorization...", + }); + } catch (error) { + const message = getAuthErrorMessage(error); + console.error("Failed to start Microsoft login:", error); + set({ loginMode: null, statusMessage: `Failed to start login: ${message}` }); + toast.error(`Failed to start Microsoft login: ${message}`); + } }, _pollLoginStatus: async (deviceCode, onSuccess) => { const { _pollingInterval, _mutex: mutex, _progressUnlisten } = get(); if (mutex.isLocked) return; - mutex.acquire(); + + await mutex.acquire(); + try { const account = await completeMicrosoftLogin(deviceCode); clearInterval(_pollingInterval ?? undefined); _progressUnlisten?.(); onSuccess?.(); - set({ account, loginMode: "microsoft" }); - } catch (error) { - if (error === "authorization_pending") { - console.log("Authorization pending..."); - } else { - console.error("Failed to poll login status:", error); - toast.error("Failed to poll login status"); + set({ + account, + loginMode: "microsoft", + deviceCode: null, + _pollingInterval: null, + _progressUnlisten: null, + statusMessage: "Login successful", + }); + } catch (error: unknown) { + const message = getAuthErrorMessage(error); + + if (message.includes("authorization_pending")) { + set({ statusMessage: "Waiting for authorization..." }); + return; + } + + if (message.includes("slow_down")) { + set({ statusMessage: "Microsoft asked to slow down polling..." }); + return; } + + clearInterval(_pollingInterval ?? undefined); + _progressUnlisten?.(); + + set({ + loginMode: null, + deviceCode: null, + _pollingInterval: null, + _progressUnlisten: null, + statusMessage: `Login failed: ${message}`, + }); + + console.error("Failed to poll login status:", error); + toast.error(`Microsoft login failed: ${message}`); } finally { mutex.release(); } @@ -111,6 +157,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({ } set({ loginMode: null, + deviceCode: null, _pollingInterval: null, statusMessage: null, _progressUnlisten: null, 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/index.tsx b/packages/ui/src/pages/index.tsx index d12646b..bccca22 100644 --- a/packages/ui/src/pages/index.tsx +++ b/packages/ui/src/pages/index.tsx @@ -5,11 +5,13 @@ import { Sidebar } from "@/components/sidebar"; import { useAuthStore } from "@/models/auth"; import { useInstanceStore } from "@/models/instance"; import { useSettingsStore } from "@/models/settings"; +import { useGameStore } from "@/stores/game-store"; export function IndexPage() { const authStore = useAuthStore(); const settingsStore = useSettingsStore(); const instanceStore = useInstanceStore(); + const initGameLifecycle = useGameStore((state) => state.initLifecycle); const location = useLocation(); @@ -17,7 +19,10 @@ export function IndexPage() { authStore.init(); settingsStore.refresh(); instanceStore.refresh(); - }, [authStore.init, settingsStore.refresh, instanceStore.refresh]); + void initGameLifecycle().catch((error) => { + console.error("Failed to initialize game lifecycle:", error); + }); + }, [authStore.init, settingsStore.refresh, instanceStore.refresh, initGameLifecycle]); return ( <div className="relative h-screen w-full overflow-hidden bg-background font-sans"> diff --git a/packages/ui/src/pages/instances-view.tsx b/packages/ui/src/pages/instances-view.tsx index e99004c..07a2135 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,83 @@ 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} + disabled={isImporting} + > + {isImporting ? "Importing..." : "Import"} + </Button> + <Button + type="button" + variant="outline" + onClick={handleRepair} + disabled={repairing} + > + {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 +188,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 +251,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..1eaf7e7 100644 --- a/packages/ui/src/stores/game-store.ts +++ b/packages/ui/src/stores/game-store.ts @@ -1,49 +1,92 @@ +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 { GameExitedEvent } from "@/types/bindings/core"; import type { Version } from "@/types/bindings/manifest"; 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 +95,80 @@ 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; } - toast.info("Preparing to launch " + selectedVersion + "..."); + if (isGameRunning) { + toast.info("A game is already running"); + return null; + } + + 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); + toast.error(`Failed to stop game: ${e}`); + return null; + } finally { + set({ stoppingInstanceId: null }); } }, diff --git a/packages/ui/src/types/bindings/core.ts b/packages/ui/src/types/bindings/core.ts index 94e3bde..70cf804 100644 --- a/packages/ui/src/types/bindings/core.ts +++ b/packages/ui/src/types/bindings/core.ts @@ -11,6 +11,13 @@ export type FileInfo = { modified: bigint; }; +export type GameExitedEvent = { + instanceId: string; + versionId: string; + exitCode: number | null; + wasStopped: boolean; +}; + export type GithubRelease = { tagName: string; name: string; diff --git a/packages/ui/src/types/bindings/instance.ts b/packages/ui/src/types/bindings/instance.ts index 2c4f8ae..a8247a9 100644 --- a/packages/ui/src/types/bindings/instance.ts +++ b/packages/ui/src/types/bindings/instance.ts @@ -27,6 +27,13 @@ export type InstanceConfig = { activeInstanceId: string | null; }; +export type InstanceRepairResult = { + restoredInstances: number; + removedStaleEntries: number; + createdDefaultActive: boolean; + activeInstanceId: string | null; +}; + /** * Memory settings override for an instance */ diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 8c90267..241ca8f 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -6,6 +6,10 @@ import { defineConfig } from "vite"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + port: 5173, + strictPort: true, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b375c6e..ccec463 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,6 +52,7 @@ ctor = "0.6.3" inventory = "0.3.21" [build-dependencies] +dotenvy = { version = "0.15", default-features = false } tauri-build = { version = "2.0", features = [] } [target.'cfg(all(windows, target_env = "gnu"))'.build-dependencies] diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 63f98e2..00f5755 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,11 @@ fn main() { + // Load .env file if present so optional build-time vars (e.g. CURSEFORGE_API_KEY) + // are available to option_env!() without requiring CI to have a real .env file. + if let Ok(path) = dotenvy::dotenv() { + println!("cargo:rerun-if-changed={}", path.display()); + } + println!("cargo:rerun-if-env-changed=CURSEFORGE_API_KEY"); + // For MinGW targets, use embed-resource to generate proper COFF format #[cfg(all(windows, target_env = "gnu"))] { diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 81975f2..1b67261 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,6 +3,14 @@ "identifier": "default", "description": "Default capabilities for the DropOut launcher", "windows": ["main"], + "remote": { + "urls": [ + "http://127.0.0.1:5173", + "http://127.0.0.1:5173/*", + "http://localhost:5173", + "http://localhost:5173/*" + ] + }, "permissions": [ "core:default", "core:event:default", diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs index 0237270..bc303c4 100644 --- a/src-tauri/src/core/instance.rs +++ b/src-tauri/src/core/instance.rs @@ -5,13 +5,16 @@ //! - Each instance has its own versions, libraries, assets, mods, and saves //! - Support for instance switching and isolation +use crate::core::config::LauncherConfig; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::{AppHandle, Manager}; use ts_rs::TS; +use zip::write::SimpleFileOptions; /// Represents a game instance/profile #[derive(Debug, Clone, Serialize, Deserialize, TS)] @@ -52,10 +55,69 @@ pub struct InstanceConfig { pub active_instance_id: Option<String>, // 当前活动的实例ID } +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "instance.ts")] +pub struct InstanceRepairResult { + pub restored_instances: usize, + pub removed_stale_entries: usize, + pub created_default_active: bool, + pub active_instance_id: Option<String>, +} + +#[derive(Debug, Clone)] +pub struct InstancePaths { + pub root: PathBuf, + pub metadata_versions: PathBuf, + pub version_cache: PathBuf, + pub libraries: PathBuf, + pub assets: PathBuf, + pub mods: PathBuf, + pub config: PathBuf, + pub saves: PathBuf, + pub resourcepacks: PathBuf, + pub shaderpacks: PathBuf, + pub screenshots: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstanceOperation { + Launch, + Install, + Delete, + ImportExport, +} + +impl InstanceOperation { + fn label(self) -> &'static str { + match self { + Self::Launch => "launching", + Self::Install => "installing", + Self::Delete => "deleting", + Self::ImportExport => "importing or exporting", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExportedInstance { + name: String, + version_id: Option<String>, + icon_path: Option<String>, + notes: Option<String>, + mod_loader: Option<String>, + mod_loader_version: Option<String>, + jvm_args_override: Option<String>, + memory_override: Option<MemoryOverride>, + java_path_override: Option<String>, +} + /// State management for instances pub struct InstanceState { pub instances: Mutex<InstanceConfig>, pub file_path: PathBuf, + operation_locks: Mutex<HashMap<String, InstanceOperation>>, } impl InstanceState { @@ -74,7 +136,158 @@ impl InstanceState { Self { instances: Mutex::new(config), file_path, + operation_locks: Mutex::new(HashMap::new()), + } + } + + fn app_dir(app_handle: &AppHandle) -> Result<PathBuf, String> { + app_handle.path().app_data_dir().map_err(|e| e.to_string()) + } + + fn instances_dir(app_handle: &AppHandle) -> Result<PathBuf, String> { + Ok(Self::app_dir(app_handle)?.join("instances")) + } + + fn validate_instance_name( + config: &InstanceConfig, + name: &str, + exclude_id: Option<&str>, + ) -> Result<(), String> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Instance name cannot be empty".to_string()); + } + + let duplicated = config.instances.iter().any(|instance| { + if let Some(exclude_id) = exclude_id { + if instance.id == exclude_id { + return false; + } + } + + instance.name.trim().eq_ignore_ascii_case(trimmed) + }); + + if duplicated { + return Err(format!("Instance \"{}\" already exists", trimmed)); + } + + Ok(()) + } + + fn create_instance_directory_structure(instance_dir: &Path) -> Result<(), String> { + fs::create_dir_all(instance_dir).map_err(|e| e.to_string())?; + + for folder in [ + "versions", + "libraries", + "assets", + "mods", + "config", + "saves", + "resourcepacks", + "shaderpacks", + "screenshots", + "logs", + ] { + fs::create_dir_all(instance_dir.join(folder)).map_err(|e| e.to_string())?; + } + + Ok(()) + } + + fn insert_instance( + &self, + instance: Instance, + set_active_when_empty: bool, + ) -> Result<(), String> { + let mut config = self.instances.lock().unwrap(); + Self::validate_instance_name(&config, &instance.name, Some(&instance.id))?; + config.instances.push(instance.clone()); + + if set_active_when_empty && config.active_instance_id.is_none() { + config.active_instance_id = Some(instance.id); + } + + drop(config); + self.save() + } + + pub fn begin_operation(&self, id: &str, operation: InstanceOperation) -> Result<(), String> { + let mut locks = self.operation_locks.lock().unwrap(); + if let Some(active) = locks.get(id) { + return Err(format!("Instance {} is busy: {}", id, active.label())); } + + locks.insert(id.to_string(), operation); + Ok(()) + } + + pub fn end_operation(&self, id: &str) { + self.operation_locks.lock().unwrap().remove(id); + } + + pub fn resolve_paths( + &self, + id: &str, + config: &LauncherConfig, + app_handle: &AppHandle, + ) -> Result<InstancePaths, String> { + let instance = self + .get_instance(id) + .ok_or_else(|| format!("Instance {} not found", id))?; + let shared_root = Self::app_dir(app_handle)?; + + Ok(InstancePaths { + root: instance.game_dir.clone(), + metadata_versions: instance.game_dir.join("versions"), + version_cache: if config.use_shared_caches { + shared_root.join("versions") + } else { + instance.game_dir.join("versions") + }, + libraries: if config.use_shared_caches { + shared_root.join("libraries") + } else { + instance.game_dir.join("libraries") + }, + assets: if config.use_shared_caches { + shared_root.join("assets") + } else { + instance.game_dir.join("assets") + }, + mods: instance.game_dir.join("mods"), + config: instance.game_dir.join("config"), + saves: instance.game_dir.join("saves"), + resourcepacks: instance.game_dir.join("resourcepacks"), + shaderpacks: instance.game_dir.join("shaderpacks"), + screenshots: instance.game_dir.join("screenshots"), + }) + } + + pub fn resolve_directory( + &self, + id: &str, + folder: &str, + config: &LauncherConfig, + app_handle: &AppHandle, + ) -> Result<PathBuf, String> { + let paths = self.resolve_paths(id, config, app_handle)?; + let resolved = match folder { + "versions" => paths.metadata_versions, + "version-cache" => paths.version_cache, + "libraries" => paths.libraries, + "assets" => paths.assets, + "mods" => paths.mods, + "config" => paths.config, + "saves" => paths.saves, + "resourcepacks" => paths.resourcepacks, + "shaderpacks" => paths.shaderpacks, + "screenshots" => paths.screenshots, + other => paths.root.join(other), + }; + + Ok(resolved) } /// Save the instance configuration to disk @@ -92,23 +305,22 @@ impl InstanceState { name: String, app_handle: &AppHandle, ) -> Result<Instance, String> { - let app_dir = app_handle.path().app_data_dir().unwrap(); + let trimmed_name = name.trim().to_string(); + { + let config = self.instances.lock().unwrap(); + Self::validate_instance_name(&config, &trimmed_name, None)?; + } + + let app_dir = Self::app_dir(app_handle)?; let instance_id = uuid::Uuid::new_v4().to_string(); let instance_dir = app_dir.join("instances").join(&instance_id); let game_dir = instance_dir.clone(); - // Create instance directory structure - fs::create_dir_all(&instance_dir).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("versions")).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("libraries")).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("assets")).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("mods")).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("config")).map_err(|e| e.to_string())?; - fs::create_dir_all(instance_dir.join("saves")).map_err(|e| e.to_string())?; + Self::create_instance_directory_structure(&instance_dir)?; let instance = Instance { id: instance_id.clone(), - name, + name: trimmed_name, game_dir, version_id: None, created_at: chrono::Utc::now().timestamp(), @@ -122,22 +334,14 @@ impl InstanceState { java_path_override: None, }; - let mut config = self.instances.lock().unwrap(); - config.instances.push(instance.clone()); - - // If this is the first instance, set it as active - if config.active_instance_id.is_none() { - config.active_instance_id = Some(instance_id); - } - - drop(config); - self.save()?; + self.insert_instance(instance.clone(), true)?; Ok(instance) } /// Delete an instance pub fn delete_instance(&self, id: &str) -> Result<(), String> { + self.begin_operation(id, InstanceOperation::Delete)?; let mut config = self.instances.lock().unwrap(); // Find the instance @@ -166,6 +370,8 @@ impl InstanceState { .map_err(|e| format!("Failed to delete instance directory: {}", e))?; } + self.end_operation(id); + Ok(()) } @@ -179,7 +385,13 @@ impl InstanceState { .position(|i| i.id == instance.id) .ok_or_else(|| format!("Instance {} not found", instance.id))?; - config.instances[index] = instance; + Self::validate_instance_name(&config, &instance.name, Some(&instance.id))?; + + let existing = config.instances[index].clone(); + let mut updated = instance; + updated.game_dir = existing.game_dir; + updated.created_at = existing.created_at; + config.instances[index] = updated; drop(config); self.save()?; @@ -236,17 +448,34 @@ impl InstanceState { new_name: String, app_handle: &AppHandle, ) -> Result<Instance, String> { + // Local RAII guard to ensure end_operation is always called + struct OperationGuard<'a> { + manager: &'a InstanceState, + id: &'a str, + } + + impl<'a> Drop for OperationGuard<'a> { + fn drop(&mut self) { + // This will run on all exit paths from duplicate_instance + self.manager.end_operation(self.id); + } + } + + self.begin_operation(id, InstanceOperation::ImportExport)?; + let _operation_guard = OperationGuard { manager: self, id }; + let source_instance = self .get_instance(id) .ok_or_else(|| format!("Instance {} not found", id))?; + { + let config = self.instances.lock().unwrap(); + Self::validate_instance_name(&config, &new_name, None)?; + } + // Prepare new instance metadata (but don't save yet) let new_id = uuid::Uuid::new_v4().to_string(); - let instances_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| e.to_string())? - .join("instances"); + let instances_dir = Self::instances_dir(app_handle)?; let new_game_dir = instances_dir.join(&new_id); // Copy directory FIRST - if this fails, don't create metadata @@ -255,14 +484,13 @@ impl InstanceState { .map_err(|e| format!("Failed to copy instance directory: {}", e))?; } else { // If source dir doesn't exist, create new empty game dir - std::fs::create_dir_all(&new_game_dir) - .map_err(|e| format!("Failed to create instance directory: {}", e))?; + Self::create_instance_directory_structure(&new_game_dir)?; } // NOW create metadata and save let new_instance = Instance { id: new_id, - name: new_name, + name: new_name.trim().to_string(), game_dir: new_game_dir, version_id: source_instance.version_id.clone(), mod_loader: source_instance.mod_loader.clone(), @@ -279,10 +507,238 @@ impl InstanceState { java_path_override: source_instance.java_path_override.clone(), }; - self.update_instance(new_instance.clone())?; + self.insert_instance(new_instance.clone(), false)?; Ok(new_instance) } + + pub fn export_instance(&self, id: &str, archive_path: &Path) -> Result<PathBuf, String> { + self.begin_operation(id, InstanceOperation::ImportExport)?; + let instance = self + .get_instance(id) + .ok_or_else(|| format!("Instance {} not found", id))?; + + if let Some(parent) = archive_path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + let file = fs::File::create(archive_path).map_err(|e| e.to_string())?; + let mut writer = zip::ZipWriter::new(file); + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(0o644); + + let exported = ExportedInstance { + name: instance.name.clone(), + version_id: instance.version_id.clone(), + icon_path: instance.icon_path.clone(), + notes: instance.notes.clone(), + mod_loader: instance.mod_loader.clone(), + mod_loader_version: instance.mod_loader_version.clone(), + jvm_args_override: instance.jvm_args_override.clone(), + memory_override: instance.memory_override.clone(), + java_path_override: instance.java_path_override.clone(), + }; + + writer + .start_file("dropout-instance.json", options) + .map_err(|e| e.to_string())?; + writer + .write_all( + serde_json::to_string_pretty(&exported) + .map_err(|e| e.to_string())? + .as_bytes(), + ) + .map_err(|e| e.to_string())?; + + append_directory_to_zip(&mut writer, &instance.game_dir, &instance.game_dir, options)?; + writer.finish().map_err(|e| e.to_string())?; + self.end_operation(id); + + Ok(archive_path.to_path_buf()) + } + + pub fn import_instance( + &self, + archive_path: &Path, + app_handle: &AppHandle, + new_name: Option<String>, + ) -> Result<Instance, String> { + let file = fs::File::open(archive_path).map_err(|e| e.to_string())?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?; + + let exported: ExportedInstance = { + let mut metadata = archive.by_name("dropout-instance.json").map_err(|_| { + "Invalid instance archive: missing dropout-instance.json".to_string() + })?; + let mut content = String::new(); + metadata + .read_to_string(&mut content) + .map_err(|e| e.to_string())?; + serde_json::from_str(&content).map_err(|e| e.to_string())? + }; + + let final_name = new_name.unwrap_or(exported.name.clone()); + { + let config = self.instances.lock().unwrap(); + Self::validate_instance_name(&config, &final_name, None)?; + } + + let imported = self.create_instance(final_name, app_handle)?; + self.begin_operation(&imported.id, InstanceOperation::ImportExport)?; + + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|e| e.to_string())?; + let Some(enclosed_name) = entry.enclosed_name().map(|p| p.to_path_buf()) else { + continue; + }; + + if enclosed_name == PathBuf::from("dropout-instance.json") { + continue; + } + + let out_path = imported.game_dir.join(&enclosed_name); + if entry.name().ends_with('/') { + fs::create_dir_all(&out_path).map_err(|e| e.to_string())?; + continue; + } + + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + let mut output = fs::File::create(&out_path).map_err(|e| e.to_string())?; + std::io::copy(&mut entry, &mut output).map_err(|e| e.to_string())?; + } + + let mut hydrated = imported.clone(); + hydrated.version_id = exported.version_id; + hydrated.icon_path = exported.icon_path; + hydrated.notes = exported.notes; + hydrated.mod_loader = exported.mod_loader; + hydrated.mod_loader_version = exported.mod_loader_version; + hydrated.jvm_args_override = exported.jvm_args_override; + hydrated.memory_override = exported.memory_override; + hydrated.java_path_override = exported.java_path_override; + self.update_instance(hydrated.clone())?; + self.end_operation(&imported.id); + + Ok(hydrated) + } + + pub fn repair_instances(&self, app_handle: &AppHandle) -> Result<InstanceRepairResult, String> { + let instances_dir = Self::instances_dir(app_handle)?; + fs::create_dir_all(&instances_dir).map_err(|e| e.to_string())?; + + let mut config = self.instances.lock().unwrap().clone(); + let mut restored_instances = 0usize; + let mut removed_stale_entries = 0usize; + + config.instances.retain(|instance| { + let keep = instance.game_dir.exists(); + if !keep { + removed_stale_entries += 1; + } + keep + }); + + let known_ids: std::collections::HashSet<String> = config + .instances + .iter() + .map(|instance| instance.id.clone()) + .collect(); + + for entry in fs::read_dir(&instances_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + if !entry.file_type().map_err(|e| e.to_string())?.is_dir() { + continue; + } + + let id = entry.file_name().to_string_lossy().to_string(); + if known_ids.contains(&id) { + continue; + } + + let recovered = Instance { + id: id.clone(), + name: format!("Recovered {}", &id[..id.len().min(8)]), + game_dir: entry.path(), + version_id: None, + created_at: chrono::Utc::now().timestamp(), + last_played: None, + icon_path: None, + notes: Some("Recovered from instances directory".to_string()), + mod_loader: Some("vanilla".to_string()), + mod_loader_version: None, + jvm_args_override: None, + memory_override: None, + java_path_override: None, + }; + + config.instances.push(recovered); + restored_instances += 1; + } + + config + .instances + .sort_by(|left, right| left.created_at.cmp(&right.created_at)); + + let mut created_default_active = false; + if config.active_instance_id.is_none() + || !config + .instances + .iter() + .any(|instance| Some(&instance.id) == config.active_instance_id.as_ref()) + { + config.active_instance_id = + config.instances.first().map(|instance| instance.id.clone()); + created_default_active = config.active_instance_id.is_some(); + } + + *self.instances.lock().unwrap() = config.clone(); + drop(config); + self.save()?; + + Ok(InstanceRepairResult { + restored_instances, + removed_stale_entries, + created_default_active, + active_instance_id: self.get_active_instance().map(|instance| instance.id), + }) + } +} + +fn append_directory_to_zip( + writer: &mut zip::ZipWriter<fs::File>, + current_dir: &Path, + base_dir: &Path, + options: SimpleFileOptions, +) -> Result<(), String> { + if !current_dir.exists() { + return Ok(()); + } + + for entry in fs::read_dir(current_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + let relative = path.strip_prefix(base_dir).map_err(|e| e.to_string())?; + let zip_name = relative.to_string_lossy().replace('\\', "/"); + + if path.is_dir() { + writer + .add_directory(format!("{}/", zip_name), options) + .map_err(|e| e.to_string())?; + append_directory_to_zip(writer, &path, base_dir, options)?; + } else { + writer + .start_file(zip_name, options) + .map_err(|e| e.to_string())?; + let data = fs::read(&path).map_err(|e| e.to_string())?; + writer.write_all(&data).map_err(|e| e.to_string())?; + } + } + + Ok(()) } /// Copy a directory recursively diff --git a/src-tauri/src/core/modpack.rs b/src-tauri/src/core/modpack.rs index 5ac9493..a580000 100644 --- a/src-tauri/src/core/modpack.rs +++ b/src-tauri/src/core/modpack.rs @@ -294,7 +294,7 @@ fn parse_multimc(archive: &mut Archive) -> Result<ParsedModpack, String> { // ── CurseForge API resolution ───────────────────────────────────────────── -const CURSEFORGE_API_KEY: &str = env!("CURSEFORGE_API_KEY"); +const CURSEFORGE_API_KEY: Option<&str> = option_env!("CURSEFORGE_API_KEY"); async fn resolve_curseforge_files(files: &[ModpackFile]) -> Result<Vec<ModpackFile>, String> { let file_ids: Vec<u64> = files @@ -368,9 +368,12 @@ async fn cf_post( endpoint: &str, body: &serde_json::Value, ) -> Result<serde_json::Value, String> { + let api_key = CURSEFORGE_API_KEY + .ok_or("CurseForge modpack support requires CURSEFORGE_API_KEY set at build time")?; + let resp = client .post(format!("https://api.curseforge.com{endpoint}")) - .header("x-api-key", CURSEFORGE_API_KEY) + .header("x-api-key", api_key) .json(body) .send() .await diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 33c94fe..63287cd 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,7 +6,9 @@ use std::process::Stdio; use std::sync::Mutex; use tauri::{Emitter, Manager, State, Window}; // Added Emitter use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::Command; +use tokio::process::{Child, Command}; +use tokio::sync::Mutex as AsyncMutex; +use tokio::time::{Duration, sleep}; use ts_rs::TS; // Added Serialize #[cfg(target_os = "windows")] @@ -42,6 +44,40 @@ impl MsRefreshTokenState { } } +struct RunningGameProcess { + child: Child, + instance_id: String, + version_id: String, +} + +pub struct GameProcessState { + running_game: AsyncMutex<Option<RunningGameProcess>>, +} + +impl Default for GameProcessState { + fn default() -> Self { + Self::new() + } +} + +impl GameProcessState { + pub fn new() -> Self { + Self { + running_game: AsyncMutex::new(None), + } + } +} + +#[derive(Debug, Clone, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "core.ts")] +struct GameExitedEvent { + instance_id: String, + version_id: String, + exit_code: Option<i32>, + was_stopped: bool, +} + /// Check if a string contains unresolved placeholders in the form ${...} /// /// After the replacement phase, if a string still contains ${...}, it means @@ -63,6 +99,29 @@ fn has_unresolved_placeholder(s: &str) -> bool { false } +fn resolve_minecraft_version(version_id: &str) -> String { + if let Some(rest) = version_id.strip_prefix("fabric-loader-") { + // Fabric version IDs are of the form: fabric-loader-<loader>-<mc> + // After stripping the prefix, we split once to separate loader vs mc + let mut parts = rest.splitn(2, '-'); + let _loader_version = parts.next(); + if let Some(mc_version) = parts.next() { + mc_version.to_string() + } else { + // Malformed Fabric ID, fall back to original + version_id.to_string() + } + } else if version_id.contains("-forge-") { + version_id + .split("-forge-") + .next() + .unwrap_or(version_id) + .to_string() + } else { + version_id.to_string() + } +} + #[tauri::command] #[dropout_macros::api] async fn start_game( @@ -70,6 +129,7 @@ async fn start_game( auth_state: State<'_, core::auth::AccountState>, config_state: State<'_, core::config::ConfigState>, assistant_state: State<'_, core::assistant::AssistantState>, + game_process_state: State<'_, GameProcessState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, version_id: String, @@ -82,6 +142,52 @@ async fn start_game( ) ); + let stale_instance_to_unlock = { + let mut running_game = game_process_state.running_game.lock().await; + + if let Some(existing_game) = running_game.as_mut() { + match existing_game.child.try_wait() { + Ok(Some(status)) => { + emit_log!( + window, + format!( + "Clearing stale game process for instance {} (exit code: {:?})", + existing_game.instance_id, + status.code() + ) + ); + let stale_instance_id = existing_game.instance_id.clone(); + *running_game = None; + Some(stale_instance_id) + } + Ok(None) => { + return Err(format!( + "A game is already running for instance {}", + existing_game.instance_id + )); + } + Err(error) => { + emit_log!( + window, + format!( + "Clearing broken game process state for instance {}: {}", + existing_game.instance_id, error + ) + ); + let stale_instance_id = existing_game.instance_id.clone(); + *running_game = None; + Some(stale_instance_id) + } + } + } else { + None + } + }; + + if let Some(stale_instance_id) = stale_instance_to_unlock { + instance_state.end_operation(&stale_instance_id); + } + // Check for active account emit_log!(window, "Checking for active account...".to_string()); let mut account = auth_state @@ -123,16 +229,18 @@ async fn start_game( emit_log!(window, "Account found".to_string()); let config = config_state.config.lock().unwrap().clone(); + let app_handle = window.app_handle(); + instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Launch)?; + + let launch_result: Result<String, String> = async { emit_log!(window, format!("Java path: {}", config.java_path)); emit_log!( window, format!("Memory: {}MB - {}MB", config.min_memory, config.max_memory) ); - // Get game directory from instance - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let game_dir = resolved_paths.root.clone(); // Ensure game directory exists tokio::fs::create_dir_all(&game_dir) @@ -203,7 +311,6 @@ async fn start_game( // Resolve Java using priority-based resolution // Priority: instance override > global config > user preference > auto-detect // TODO: refactor into a separate function - let app_handle = window.app_handle(); let instance = instance_state .get_instance(&instance_id) .ok_or_else(|| format!("Instance {} not found", instance_id))?; @@ -260,12 +367,7 @@ async fn start_game( .as_ref() .ok_or("Version has no downloads information")?; let client_jar = &downloads.client; - // Use shared caches for versions if enabled - let mut client_path = if config.use_shared_caches { - app_handle.path().app_data_dir().unwrap().join("versions") - } else { - game_dir.join("versions") - }; + let mut client_path = resolved_paths.version_cache.clone(); client_path.push(&minecraft_version); client_path.push(format!("{}.jar", minecraft_version)); @@ -278,12 +380,7 @@ async fn start_game( // --- Libraries --- println!("Processing libraries..."); - // Use shared caches for libraries if enabled - let libraries_dir = if config.use_shared_caches { - app_handle.path().app_data_dir().unwrap().join("libraries") - } else { - game_dir.join("libraries") - }; + let libraries_dir = resolved_paths.libraries.clone(); let mut native_libs_paths = Vec::new(); // Store paths to native jars for extraction for lib in &version_details.libraries { @@ -380,12 +477,7 @@ async fn start_game( // --- Assets --- println!("Fetching asset index..."); - // Use shared caches for assets if enabled - let assets_dir = if config.use_shared_caches { - app_handle.path().app_data_dir().unwrap().join("assets") - } else { - game_dir.join("assets") - }; + let assets_dir = resolved_paths.assets.clone(); let objects_dir = assets_dir.join("objects"); let indexes_dir = assets_dir.join("indexes"); @@ -772,6 +864,15 @@ async fn start_game( .take() .expect("child did not have a handle to stderr"); + { + let mut running_game = game_process_state.running_game.lock().await; + *running_game = Some(RunningGameProcess { + child, + instance_id: instance_id.clone(), + version_id: version_id.clone(), + }); + } + // Emit launcher log that game is running emit_log!( window, @@ -793,6 +894,7 @@ async fn start_game( let window_rx_err = window.clone(); let assistant_arc_err = assistant_state.assistant.clone(); let window_exit = window.clone(); + let app_handle_exit = app_handle.clone(); tokio::spawn(async move { let mut reader = BufReader::new(stderr).lines(); while let Ok(Some(line)) = reader.next_line().await { @@ -804,17 +906,64 @@ async fn start_game( }); // Monitor game process exit + let launch_instance_id = instance_id.clone(); + let launch_version_id = version_id.clone(); tokio::spawn(async move { - match child.wait().await { - Ok(status) => { - let msg = format!("Game process exited with status: {}", status); - let _ = window_exit.emit("launcher-log", &msg); - let _ = window_exit.emit("game-exited", status.code().unwrap_or(-1)); - } - Err(e) => { - let msg = format!("Error waiting for game process: {}", e); + loop { + let exit_event = { + let state: State<'_, GameProcessState> = app_handle_exit.state(); + let mut running_game = state.running_game.lock().await; + + let Some(active_game) = running_game.as_mut() else { + break; + }; + + if active_game.instance_id != launch_instance_id { + break; + } + + match active_game.child.try_wait() { + Ok(Some(status)) => { + let exit_code = status.code(); + *running_game = None; + Some(GameExitedEvent { + instance_id: launch_instance_id.clone(), + version_id: launch_version_id.clone(), + exit_code, + was_stopped: false, + }) + } + Ok(None) => None, + Err(error) => { + let _ = window_exit.emit( + "launcher-log", + format!("Error waiting for game process: {}", error), + ); + *running_game = None; + Some(GameExitedEvent { + instance_id: launch_instance_id.clone(), + version_id: launch_version_id.clone(), + exit_code: None, + was_stopped: false, + }) + } + } + }; + + if let Some(event) = exit_event { + let msg = format!( + "Game process exited for instance {} with status {:?}", + event.instance_id, event.exit_code + ); let _ = window_exit.emit("launcher-log", &msg); + let _ = window_exit.emit("game-exited", &event); + + let state: State<core::instance::InstanceState> = window_exit.app_handle().state(); + state.end_operation(&event.instance_id); + break; } + + sleep(Duration::from_millis(500)).await; } }); @@ -825,6 +974,70 @@ async fn start_game( } Ok(format!("Launched Minecraft {} successfully!", version_id)) + } + .await; + + if launch_result.is_err() { + instance_state.end_operation(&instance_id); + } + + launch_result +} + +#[tauri::command] +#[dropout_macros::api] +async fn stop_game( + window: Window, + game_process_state: State<'_, GameProcessState>, + instance_state: State<'_, core::instance::InstanceState>, +) -> Result<String, String> { + let mut running_game = { + let mut state = game_process_state.running_game.lock().await; + state.take().ok_or("No running game process found")? + }; + + emit_log!( + window, + format!( + "Stopping game process for instance {}...", + running_game.instance_id + ) + ); + + let exit_code = match running_game.child.try_wait() { + Ok(Some(status)) => status.code(), + Ok(None) => { + running_game + .child + .start_kill() + .map_err(|e| format!("Failed to stop game process: {}", e))?; + + running_game + .child + .wait() + .await + .map_err(|e| format!("Failed while waiting for the game to stop: {}", e))? + .code() + } + Err(error) => { + return Err(format!("Failed to inspect running game process: {}", error)); + } + }; + + let event = GameExitedEvent { + instance_id: running_game.instance_id.clone(), + version_id: running_game.version_id.clone(), + exit_code, + was_stopped: true, + }; + + let _ = window.emit("game-exited", &event); + instance_state.end_operation(&running_game.instance_id); + + Ok(format!( + "Stopped Minecraft {} for instance {}", + running_game.version_id, running_game.instance_id + )) } /// Parse JVM arguments from version.json @@ -911,12 +1124,14 @@ async fn get_versions() -> Result<Vec<core::manifest::Version>, String> { #[dropout_macros::api] async fn get_versions_of_instance( _window: Window, + config_state: State<'_, core::config::ConfigState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, ) -> Result<Vec<core::manifest::Version>, String> { - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; + let config = config_state.config.lock().unwrap().clone(); + let app_handle = _window.app_handle(); + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let game_dir = resolved_paths.root.clone(); match core::manifest::fetch_version_manifest().await { Ok(manifest) => { @@ -925,9 +1140,12 @@ async fn get_versions_of_instance( // For each version, try to load Java version info and check installation status for version in &mut versions { // Check if version is installed - let version_dir = game_dir.join("versions").join(&version.id); + let version_dir = resolved_paths.metadata_versions.join(&version.id); let json_path = version_dir.join(format!("{}.json", version.id)); - let client_jar_path = version_dir.join(format!("{}.jar", version.id)); + let client_jar_path = resolved_paths + .version_cache + .join(&version.id) + .join(format!("{}.jar", version.id)); // Version is installed if both JSON and client jar exist let is_installed = json_path.exists() && client_jar_path.exists(); @@ -956,35 +1174,18 @@ async fn get_versions_of_instance( #[dropout_macros::api] async fn check_version_installed( _window: Window, + config_state: State<'_, core::config::ConfigState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, version_id: String, ) -> Result<bool, String> { - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; - - // For modded versions, check the parent vanilla version - let minecraft_version = if version_id.starts_with("fabric-loader-") { - // Format: fabric-loader-X.X.X-1.20.4 - version_id - .split('-') - .next_back() - .unwrap_or(&version_id) - .to_string() - } else if version_id.contains("-forge-") { - // Format: 1.20.4-forge-49.0.38 - version_id - .split("-forge-") - .next() - .unwrap_or(&version_id) - .to_string() - } else { - version_id.clone() - }; + let config = config_state.config.lock().unwrap().clone(); + let app_handle = _window.app_handle(); + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let minecraft_version = resolve_minecraft_version(&version_id); - let client_jar = game_dir - .join("versions") + let client_jar = resolved_paths + .version_cache .join(&minecraft_version) .join(format!("{}.jar", minecraft_version)); @@ -1010,310 +1211,295 @@ async fn install_version( ); let config = config_state.config.lock().unwrap().clone(); + let app_handle = window.app_handle(); + instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?; - // Get game directory from instance - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; - - // Ensure game directory exists - tokio::fs::create_dir_all(&game_dir) - .await - .map_err(|e| e.to_string())?; - - emit_log!(window, format!("Game directory: {:?}", game_dir)); - - // Load version (supports both vanilla and modded versions with inheritance) - emit_log!( - window, - format!("Loading version details for {}...", version_id) - ); - - // First, try to fetch the vanilla version from Mojang and save it locally - let _version_details = match core::manifest::load_local_version(&game_dir, &version_id).await { - Ok(v) => v, - Err(_) => { - // Not found locally, fetch from Mojang - emit_log!( - window, - format!("Fetching version {} from Mojang...", version_id) - ); - let fetched = core::manifest::fetch_vanilla_version(&version_id) - .await - .map_err(|e| e.to_string())?; + let install_result: Result<(), String> = async { + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let game_dir = resolved_paths.root.clone(); - // Save the version JSON locally - emit_log!(window, format!("Saving version JSON...")); - core::manifest::save_local_version(&game_dir, &fetched) - .await - .map_err(|e| e.to_string())?; - - fetched - } - }; + // Ensure game directory exists + tokio::fs::create_dir_all(&game_dir) + .await + .map_err(|e| e.to_string())?; - // Now load the full version with inheritance resolved - let version_details = core::manifest::load_version(&game_dir, &version_id) - .await - .map_err(|e| e.to_string())?; + emit_log!(window, format!("Game directory: {:?}", game_dir)); - emit_log!( - window, - format!( - "Version details loaded: main class = {}", - version_details.main_class - ) - ); + // Load version (supports both vanilla and modded versions with inheritance) + emit_log!( + window, + format!("Loading version details for {}...", version_id) + ); - // Determine the actual minecraft version for client.jar - let minecraft_version = version_details - .inherits_from - .clone() - .unwrap_or_else(|| version_id.clone()); + // First, try to fetch the vanilla version from Mojang and save it locally + let _version_details = + match core::manifest::load_local_version(&game_dir, &version_id).await { + Ok(v) => v, + Err(_) => { + // Not found locally, fetch from Mojang + emit_log!( + window, + format!("Fetching version {} from Mojang...", version_id) + ); + let fetched = core::manifest::fetch_vanilla_version(&version_id) + .await + .map_err(|e| e.to_string())?; + + // Save the version JSON locally + emit_log!(window, format!("Saving version JSON...")); + core::manifest::save_local_version(&game_dir, &fetched) + .await + .map_err(|e| e.to_string())?; + + fetched + } + }; - // Prepare download tasks - emit_log!(window, "Preparing download tasks...".to_string()); - let mut download_tasks = Vec::new(); + // Now load the full version with inheritance resolved + let version_details = core::manifest::load_version(&game_dir, &version_id) + .await + .map_err(|e| e.to_string())?; - // --- Client Jar --- - let downloads = version_details - .downloads - .as_ref() - .ok_or("Version has no downloads information")?; - let client_jar = &downloads.client; - // Use shared caches for versions if enabled - let mut client_path = if config.use_shared_caches { - window - .app_handle() - .path() - .app_data_dir() - .unwrap() - .join("versions") - } else { - game_dir.join("versions") - }; - client_path.push(&minecraft_version); - client_path.push(format!("{}.jar", minecraft_version)); + emit_log!( + window, + format!( + "Version details loaded: main class = {}", + version_details.main_class + ) + ); - download_tasks.push(core::downloader::DownloadTask { - url: client_jar.url.clone(), - path: client_path.clone(), - sha1: client_jar.sha1.clone(), - sha256: None, - }); + // Determine the actual minecraft version for client.jar + let minecraft_version = version_details + .inherits_from + .clone() + .unwrap_or_else(|| version_id.clone()); + + // Prepare download tasks + emit_log!(window, "Preparing download tasks...".to_string()); + let mut download_tasks = Vec::new(); + + // --- Client Jar --- + let downloads = version_details + .downloads + .as_ref() + .ok_or("Version has no downloads information")?; + let client_jar = &downloads.client; + let mut client_path = resolved_paths.version_cache.clone(); + client_path.push(&minecraft_version); + client_path.push(format!("{}.jar", minecraft_version)); - // --- Libraries --- - // Use shared caches for libraries if enabled - let libraries_dir = if config.use_shared_caches { - window - .app_handle() - .path() - .app_data_dir() - .unwrap() - .join("libraries") - } else { - game_dir.join("libraries") - }; + download_tasks.push(core::downloader::DownloadTask { + url: client_jar.url.clone(), + path: client_path.clone(), + sha1: client_jar.sha1.clone(), + sha256: None, + }); - for lib in &version_details.libraries { - if core::rules::is_library_allowed(&lib.rules, Some(&config.feature_flags)) { - if let Some(downloads) = &lib.downloads { - if let Some(artifact) = &downloads.artifact { - let path_str = artifact - .path - .clone() - .unwrap_or_else(|| format!("{}.jar", lib.name)); + // --- Libraries --- + let libraries_dir = resolved_paths.libraries.clone(); - let mut lib_path = libraries_dir.clone(); - lib_path.push(path_str); + for lib in &version_details.libraries { + if core::rules::is_library_allowed(&lib.rules, Some(&config.feature_flags)) { + if let Some(downloads) = &lib.downloads { + if let Some(artifact) = &downloads.artifact { + let path_str = artifact + .path + .clone() + .unwrap_or_else(|| format!("{}.jar", lib.name)); - download_tasks.push(core::downloader::DownloadTask { - url: artifact.url.clone(), - path: lib_path, - sha1: artifact.sha1.clone(), - sha256: None, - }); - } + let mut lib_path = libraries_dir.clone(); + lib_path.push(path_str); - // Native Library (classifiers) - if let Some(classifiers) = &downloads.classifiers { - // Determine candidate keys based on OS and architecture - let arch = std::env::consts::ARCH; - let mut candidates: Vec<String> = Vec::new(); - if cfg!(target_os = "linux") { - candidates.push("natives-linux".to_string()); - candidates.push(format!("natives-linux-{}", arch)); - if arch == "aarch64" { - candidates.push("natives-linux-arm64".to_string()); - } - } else if cfg!(target_os = "windows") { - candidates.push("natives-windows".to_string()); - candidates.push(format!("natives-windows-{}", arch)); - } else if cfg!(target_os = "macos") { - candidates.push("natives-osx".to_string()); - candidates.push("natives-macos".to_string()); - candidates.push(format!("natives-macos-{}", arch)); + download_tasks.push(core::downloader::DownloadTask { + url: artifact.url.clone(), + path: lib_path, + sha1: artifact.sha1.clone(), + sha256: None, + }); } - // Pick the first available classifier key - let mut chosen: Option<core::game_version::DownloadArtifact> = None; - for key in candidates { - if let Some(native_artifact_value) = classifiers.get(&key) { - if let Ok(artifact) = - serde_json::from_value::<core::game_version::DownloadArtifact>( - native_artifact_value.clone(), - ) - { - chosen = Some(artifact); - break; + // Native Library (classifiers) + if let Some(classifiers) = &downloads.classifiers { + // Determine candidate keys based on OS and architecture + let arch = std::env::consts::ARCH; + let mut candidates: Vec<String> = Vec::new(); + if cfg!(target_os = "linux") { + candidates.push("natives-linux".to_string()); + candidates.push(format!("natives-linux-{}", arch)); + if arch == "aarch64" { + candidates.push("natives-linux-arm64".to_string()); } + } else if cfg!(target_os = "windows") { + candidates.push("natives-windows".to_string()); + candidates.push(format!("natives-windows-{}", arch)); + } else if cfg!(target_os = "macos") { + candidates.push("natives-osx".to_string()); + candidates.push("natives-macos".to_string()); + candidates.push(format!("natives-macos-{}", arch)); } - } - if let Some(native_artifact) = chosen { - let path_str = native_artifact.path.clone().unwrap(); - let mut native_path = libraries_dir.clone(); - native_path.push(&path_str); + // Pick the first available classifier key + let mut chosen: Option<core::game_version::DownloadArtifact> = None; + for key in candidates { + if let Some(native_artifact_value) = classifiers.get(&key) { + if let Ok(artifact) = + serde_json::from_value::<core::game_version::DownloadArtifact>( + native_artifact_value.clone(), + ) + { + chosen = Some(artifact); + break; + } + } + } - download_tasks.push(core::downloader::DownloadTask { - url: native_artifact.url, - path: native_path.clone(), - sha1: native_artifact.sha1, - sha256: None, - }); + if let Some(native_artifact) = chosen { + let path_str = native_artifact.path.clone().unwrap(); + let mut native_path = libraries_dir.clone(); + native_path.push(&path_str); + + download_tasks.push(core::downloader::DownloadTask { + url: native_artifact.url, + path: native_path.clone(), + sha1: native_artifact.sha1, + sha256: None, + }); + } } - } - } else { - // Library without explicit downloads (mod loader libraries) - if let Some(url) = - core::maven::resolve_library_url(&lib.name, None, lib.url.as_deref()) - { - if let Some(lib_path) = core::maven::get_library_path(&lib.name, &libraries_dir) + } else { + // Library without explicit downloads (mod loader libraries) + if let Some(url) = + core::maven::resolve_library_url(&lib.name, None, lib.url.as_deref()) { - download_tasks.push(core::downloader::DownloadTask { - url, - path: lib_path, - sha1: None, - sha256: None, - }); + if let Some(lib_path) = + core::maven::get_library_path(&lib.name, &libraries_dir) + { + download_tasks.push(core::downloader::DownloadTask { + url, + path: lib_path, + sha1: None, + sha256: None, + }); + } } } } } - } - // --- Assets --- - // Use shared caches for assets if enabled - let assets_dir = if config.use_shared_caches { - window - .app_handle() - .path() - .app_data_dir() - .unwrap() - .join("assets") - } else { - game_dir.join("assets") - }; - let objects_dir = assets_dir.join("objects"); - let indexes_dir = assets_dir.join("indexes"); + // --- Assets --- + let assets_dir = resolved_paths.assets.clone(); + let objects_dir = assets_dir.join("objects"); + let indexes_dir = assets_dir.join("indexes"); - let asset_index = version_details - .asset_index - .as_ref() - .ok_or("Version has no asset index information")?; - - let asset_index_path = indexes_dir.join(format!("{}.json", asset_index.id)); + let asset_index = version_details + .asset_index + .as_ref() + .ok_or("Version has no asset index information")?; - let asset_index_content: String = if asset_index_path.exists() { - tokio::fs::read_to_string(&asset_index_path) - .await - .map_err(|e| e.to_string())? - } else { - emit_log!(window, format!("Downloading asset index...")); - let content = reqwest::get(&asset_index.url) - .await - .map_err(|e| e.to_string())? - .text() - .await - .map_err(|e| e.to_string())?; + let asset_index_path = indexes_dir.join(format!("{}.json", asset_index.id)); - tokio::fs::create_dir_all(&indexes_dir) - .await - .map_err(|e| e.to_string())?; - tokio::fs::write(&asset_index_path, &content) - .await - .map_err(|e| e.to_string())?; - content - }; + let asset_index_content: String = if asset_index_path.exists() { + tokio::fs::read_to_string(&asset_index_path) + .await + .map_err(|e| e.to_string())? + } else { + emit_log!(window, format!("Downloading asset index...")); + let content = reqwest::get(&asset_index.url) + .await + .map_err(|e| e.to_string())? + .text() + .await + .map_err(|e| e.to_string())?; - #[derive(serde::Deserialize)] - struct AssetObject { - hash: String, - } + tokio::fs::create_dir_all(&indexes_dir) + .await + .map_err(|e| e.to_string())?; + tokio::fs::write(&asset_index_path, &content) + .await + .map_err(|e| e.to_string())?; + content + }; - #[derive(serde::Deserialize)] - struct AssetIndexJson { - objects: std::collections::HashMap<String, AssetObject>, - } + #[derive(serde::Deserialize)] + struct AssetObject { + hash: String, + } - let asset_index_parsed: AssetIndexJson = - serde_json::from_str(&asset_index_content).map_err(|e| e.to_string())?; + #[derive(serde::Deserialize)] + struct AssetIndexJson { + objects: std::collections::HashMap<String, AssetObject>, + } - emit_log!( - window, - format!("Processing {} assets...", asset_index_parsed.objects.len()) - ); + let asset_index_parsed: AssetIndexJson = + serde_json::from_str(&asset_index_content).map_err(|e| e.to_string())?; - for (_name, object) in asset_index_parsed.objects { - let hash = object.hash; - let prefix = &hash[0..2]; - let path = objects_dir.join(prefix).join(&hash); - let url = format!( - "https://resources.download.minecraft.net/{}/{}", - prefix, hash + emit_log!( + window, + format!("Processing {} assets...", asset_index_parsed.objects.len()) ); - download_tasks.push(core::downloader::DownloadTask { - url, - path, - sha1: Some(hash), - sha256: None, - }); - } + for (_name, object) in asset_index_parsed.objects { + let hash = object.hash; + let prefix = &hash[0..2]; + let path = objects_dir.join(prefix).join(&hash); + let url = format!( + "https://resources.download.minecraft.net/{}/{}", + prefix, hash + ); - emit_log!( - window, - format!( - "Total download tasks: {} (Client + Libraries + Assets)", - download_tasks.len() - ) - ); + download_tasks.push(core::downloader::DownloadTask { + url, + path, + sha1: Some(hash), + sha256: None, + }); + } - // Start Download - emit_log!( - window, - format!( - "Starting downloads with {} concurrent threads...", - config.download_threads + emit_log!( + window, + format!( + "Total download tasks: {} (Client + Libraries + Assets)", + download_tasks.len() + ) + ); + + // Start Download + emit_log!( + window, + format!( + "Starting downloads with {} concurrent threads...", + config.download_threads + ) + ); + core::downloader::download_files( + window.clone(), + download_tasks, + config.download_threads as usize, ) - ); - core::downloader::download_files( - window.clone(), - download_tasks, - config.download_threads as usize, - ) - .await - .map_err(|e| e.to_string())?; + .await + .map_err(|e| e.to_string())?; - emit_log!( - window, - format!("Installation of {} completed successfully!", version_id) - ); + emit_log!( + window, + format!("Installation of {} completed successfully!", version_id) + ); - // Emit event to notify frontend that version installation is complete - let _ = window.emit("version-installed", &version_id); + if let Some(mut instance) = instance_state.get_instance(&instance_id) { + instance.version_id = Some(version_id.clone()); + instance.mod_loader = Some("vanilla".to_string()); + instance.mod_loader_version = None; + instance_state.update_instance(instance)?; + } - Ok(()) + // Emit event to notify frontend that version installation is complete + let _ = window.emit("version-installed", &version_id); + + Ok(()) + } + .await; + + instance_state.end_operation(&instance_id); + install_result } #[tauri::command] @@ -1707,31 +1893,39 @@ async fn install_fabric( ) ); - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; + instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?; - let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version) - .await - .map_err(|e| e.to_string())?; + let install_result: Result<core::fabric::InstalledFabricVersion, String> = async { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; - emit_log!( - window, - format!("Fabric installed successfully: {}", result.id) - ); + let result = core::fabric::install_fabric(&game_dir, &game_version, &loader_version) + .await + .map_err(|e| e.to_string())?; - // Update Instance's mod_loader metadata and version_id - if let Some(mut instance) = instance_state.get_instance(&instance_id) { - instance.mod_loader = Some("fabric".to_string()); - instance.mod_loader_version = Some(loader_version.clone()); - instance.version_id = Some(result.id.clone()); - instance_state.update_instance(instance)?; - } + emit_log!( + window, + format!("Fabric installed successfully: {}", result.id) + ); - // Emit event to notify frontend - let _ = window.emit("fabric-installed", &result.id); + // Update Instance's mod_loader metadata and version_id + if let Some(mut instance) = instance_state.get_instance(&instance_id) { + instance.mod_loader = Some("fabric".to_string()); + instance.mod_loader_version = Some(loader_version.clone()); + instance.version_id = Some(result.id.clone()); + instance_state.update_instance(instance)?; + } - Ok(result) + // Emit event to notify frontend + let _ = window.emit("fabric-installed", &result.id); + + Ok(result) + } + .await; + + instance_state.end_operation(&instance_id); + install_result } /// List installed Fabric versions @@ -1786,15 +1980,15 @@ struct VersionMetadata { #[dropout_macros::api] async fn delete_version( window: Window, + config_state: State<'_, core::config::ConfigState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, version_id: String, ) -> Result<(), String> { - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; - - let version_dir = game_dir.join("versions").join(&version_id); + let config = config_state.config.lock().unwrap().clone(); + let app_handle = window.app_handle(); + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let version_dir = resolved_paths.metadata_versions.join(&version_id); if !version_dir.exists() { return Err(format!("Version {} not found", version_id)); @@ -1841,13 +2035,15 @@ async fn delete_version( #[dropout_macros::api] async fn get_version_metadata( _window: Window, + config_state: State<'_, core::config::ConfigState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, version_id: String, ) -> Result<VersionMetadata, String> { - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; + let config = config_state.config.lock().unwrap().clone(); + let app_handle = _window.app_handle(); + let resolved_paths = instance_state.resolve_paths(&instance_id, &config, &app_handle)?; + let game_dir = resolved_paths.root.clone(); // Initialize metadata let mut metadata = VersionMetadata { @@ -1868,35 +2064,15 @@ async fn get_version_metadata( } // Check if version is installed (both JSON and client jar must exist) - let version_dir = game_dir.join("versions").join(&version_id); + let version_dir = resolved_paths.metadata_versions.join(&version_id); let json_path = version_dir.join(format!("{}.json", version_id)); // For modded versions, check the parent vanilla version's client jar - let client_jar_path = if version_id.starts_with("fabric-loader-") { - // Format: fabric-loader-X.X.X-1.20.4 - let minecraft_version = version_id - .split('-') - .next_back() - .unwrap_or(&version_id) - .to_string(); - game_dir - .join("versions") - .join(&minecraft_version) - .join(format!("{}.jar", minecraft_version)) - } else if version_id.contains("-forge-") { - // Format: 1.20.4-forge-49.0.38 - let minecraft_version = version_id - .split("-forge-") - .next() - .unwrap_or(&version_id) - .to_string(); - game_dir - .join("versions") - .join(&minecraft_version) - .join(format!("{}.jar", minecraft_version)) - } else { - version_dir.join(format!("{}.jar", version_id)) - }; + let minecraft_version = resolve_minecraft_version(&version_id); + let client_jar_path = resolved_paths + .version_cache + .join(&minecraft_version) + .join(format!("{}.jar", minecraft_version)); metadata.is_installed = json_path.exists() && client_jar_path.exists(); @@ -2081,83 +2257,91 @@ async fn install_forge( ) ); - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; + instance_state.begin_operation(&instance_id, core::instance::InstanceOperation::Install)?; - // Get Java path from config or detect - let config = config_state.config.lock().unwrap().clone(); - let app_handle = window.app_handle(); - let java_path_str = if !config.java_path.is_empty() && config.java_path != "java" { - config.java_path.clone() - } else { - // Try to find a suitable Java installation - let javas = core::java::detect_all_java_installations(app_handle).await; - if let Some(java) = javas.first() { - java.path.clone() + let install_result: Result<core::forge::InstalledForgeVersion, String> = async { + let game_dir = instance_state + .get_instance_game_dir(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; + + // Get Java path from config or detect + let config = config_state.config.lock().unwrap().clone(); + let app_handle = window.app_handle(); + let java_path_str = if !config.java_path.is_empty() && config.java_path != "java" { + config.java_path.clone() } else { - return Err( - "No Java installation found. Please configure Java in settings.".to_string(), - ); - } - }; - let java_path = utils::path::normalize_java_path(&java_path_str)?; + // Try to find a suitable Java installation + let javas = core::java::detect_all_java_installations(app_handle).await; + if let Some(java) = javas.first() { + java.path.clone() + } else { + return Err( + "No Java installation found. Please configure Java in settings.".to_string(), + ); + } + }; + let java_path = utils::path::normalize_java_path(&java_path_str)?; - emit_log!(window, "Running Forge installer...".to_string()); + emit_log!(window, "Running Forge installer...".to_string()); - // Run the Forge installer to properly patch the client - core::forge::run_forge_installer(&game_dir, &game_version, &forge_version, &java_path) - .await - .map_err(|e| format!("Forge installer failed: {}", e))?; + // Run the Forge installer to properly patch the client + core::forge::run_forge_installer(&game_dir, &game_version, &forge_version, &java_path) + .await + .map_err(|e| format!("Forge installer failed: {}", e))?; - emit_log!( - window, - "Forge installer completed, creating version profile...".to_string() - ); + emit_log!( + window, + "Forge installer completed, creating version profile...".to_string() + ); - // Check if the version JSON already exists - let version_id = core::forge::generate_version_id(&game_version, &forge_version); - let json_path = game_dir - .join("versions") - .join(&version_id) - .join(format!("{}.json", version_id)); + // Check if the version JSON already exists + let version_id = core::forge::generate_version_id(&game_version, &forge_version); + let json_path = game_dir + .join("versions") + .join(&version_id) + .join(format!("{}.json", version_id)); + + let result = if json_path.exists() { + // Version JSON was created by the installer, load it + emit_log!( + window, + "Using version profile created by Forge installer".to_string() + ); + core::forge::InstalledForgeVersion { + id: version_id, + minecraft_version: game_version.clone(), + forge_version: forge_version.clone(), + path: json_path, + } + } else { + // Installer didn't create JSON, create it manually + core::forge::install_forge(&game_dir, &game_version, &forge_version) + .await + .map_err(|e| e.to_string())? + }; - let result = if json_path.exists() { - // Version JSON was created by the installer, load it emit_log!( window, - "Using version profile created by Forge installer".to_string() + format!("Forge installed successfully: {}", result.id) ); - core::forge::InstalledForgeVersion { - id: version_id, - minecraft_version: game_version.clone(), - forge_version: forge_version.clone(), - path: json_path, + + // Update Instance's mod_loader metadata and version_id + if let Some(mut instance) = instance_state.get_instance(&instance_id) { + instance.mod_loader = Some("forge".to_string()); + instance.mod_loader_version = Some(forge_version.clone()); + instance.version_id = Some(result.id.clone()); + instance_state.update_instance(instance)?; } - } else { - // Installer didn't create JSON, create it manually - core::forge::install_forge(&game_dir, &game_version, &forge_version) - .await - .map_err(|e| e.to_string())? - }; - emit_log!( - window, - format!("Forge installed successfully: {}", result.id) - ); + // Emit event to notify frontend + let _ = window.emit("forge-installed", &result.id); - // Update Instance's mod_loader metadata and version_id - if let Some(mut instance) = instance_state.get_instance(&instance_id) { - instance.mod_loader = Some("forge".to_string()); - instance.mod_loader_version = Some(forge_version.clone()); - instance.version_id = Some(result.id.clone()); - instance_state.update_instance(instance)?; + Ok(result) } + .await; - // Emit event to notify frontend - let _ = window.emit("forge-installed", &result.id); - - Ok(result) + instance_state.end_operation(&instance_id); + install_result } #[derive(serde::Serialize, TS)] @@ -2416,6 +2600,43 @@ async fn duplicate_instance( state.duplicate_instance(&instance_id, new_name, app_handle) } +/// Export an instance to a zip archive +#[tauri::command] +#[dropout_macros::api] +async fn export_instance( + state: State<'_, core::instance::InstanceState>, + instance_id: String, + archive_path: String, +) -> Result<String, String> { + state + .export_instance(&instance_id, std::path::Path::new(&archive_path)) + .map(|path| path.to_string_lossy().to_string()) +} + +/// Import an instance from a zip archive +#[tauri::command] +#[dropout_macros::api] +async fn import_instance( + window: Window, + state: State<'_, core::instance::InstanceState>, + archive_path: String, + new_name: Option<String>, +) -> Result<core::instance::Instance, String> { + let app_handle = window.app_handle(); + state.import_instance(std::path::Path::new(&archive_path), app_handle, new_name) +} + +/// Repair instance index from on-disk directories +#[tauri::command] +#[dropout_macros::api] +async fn repair_instances( + window: Window, + state: State<'_, core::instance::InstanceState>, +) -> Result<core::instance::InstanceRepairResult, String> { + let app_handle = window.app_handle(); + state.repair_instances(app_handle) +} + #[tauri::command] #[dropout_macros::api] async fn assistant_chat_stream( @@ -2467,11 +2688,11 @@ async fn migrate_shared_caches( ); // Automatically enable shared caches config - let mut config = config_state.config.lock().unwrap().clone(); - config.use_shared_caches = true; - drop(config); - *config_state.config.lock().unwrap() = config_state.config.lock().unwrap().clone(); - config_state.config.lock().unwrap().use_shared_caches = true; + { + let mut config = config_state.config.lock().unwrap(); + config.use_shared_caches = true; + config.keep_legacy_per_instance_storage = false; + } config_state.save()?; Ok(MigrationResult { @@ -2499,15 +2720,15 @@ struct FileInfo { #[tauri::command] #[dropout_macros::api] async fn list_instance_directory( + app: Window, + config_state: State<'_, core::config::ConfigState>, instance_state: State<'_, core::instance::InstanceState>, instance_id: String, folder: String, // "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots" ) -> Result<Vec<FileInfo>, String> { - let game_dir = instance_state - .get_instance_game_dir(&instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; - - let target_dir = game_dir.join(&folder); + let config = config_state.config.lock().unwrap().clone(); + let target_dir = + instance_state.resolve_directory(&instance_id, &folder, &config, app.app_handle())?; if !target_dir.exists() { tokio::fs::create_dir_all(&target_dir) .await @@ -2599,6 +2820,7 @@ fn main() { .plugin(tauri_plugin_shell::init()) .manage(core::auth::AccountState::new()) .manage(MsRefreshTokenState::new()) + .manage(GameProcessState::new()) .manage(core::assistant::AssistantState::new()) .setup(|app| { let config_state = core::config::ConfigState::new(app.handle()); @@ -2643,6 +2865,7 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ start_game, + stop_game, get_versions, get_versions_of_instance, check_version_installed, @@ -2700,6 +2923,9 @@ fn main() { set_active_instance, get_active_instance, duplicate_instance, + export_instance, + import_instance, + repair_instances, migrate_shared_caches, list_instance_directory, delete_instance_file, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1c31a09..9ab9e6a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "build": { "beforeDevCommand": "pnpm --filter @dropout/ui dev", "beforeBuildCommand": "pnpm --filter @dropout/ui build", - "devUrl": "http://localhost:5173", + "devUrl": "http://127.0.0.1:5173", "frontendDist": "../packages/ui/dist" }, "app": { @@ -20,6 +20,7 @@ } ], "security": { + "devCsp": null, "csp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:;", "capabilities": ["default"] } |