Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 115 additions & 57 deletions .github/copilot-instructions.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ release/

# claude code
.claude/

# Vscode
.vscode/
35 changes: 35 additions & 0 deletions packages/ui/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ import type {
VersionMetadata,
} from "@/types";

export interface InstanceRepairResult {
restoredInstances: number;
removedStaleEntries: number;
createdDefaultActive: boolean;
activeInstanceId: string | null;
}

export function assistantChat(messages: Message[]): Promise<Message> {
return invoke<Message>("assistant_chat", {
messages,
Expand Down Expand Up @@ -119,6 +126,16 @@ export function duplicateInstance(
});
}

export function exportInstance(
instanceId: string,
archivePath: string,
): Promise<string> {
return invoke<string>("export_instance", {
instanceId,
archivePath,
});
}

export function fetchAdoptiumJava(
majorVersion: number,
imageType: string,
Expand Down Expand Up @@ -267,6 +284,16 @@ export function installVersion(
});
}

export function importInstance(
archivePath: string,
newName?: string,
): Promise<Instance> {
return invoke<Instance>("import_instance", {
archivePath,
newName,
});
}

export function isFabricInstalled(
instanceId: string,
gameVersion: string,
Expand Down Expand Up @@ -351,6 +378,10 @@ export function refreshJavaCatalog(): Promise<JavaCatalog> {
return invoke<JavaCatalog>("refresh_java_catalog");
}

export function repairInstances(): Promise<InstanceRepairResult> {
return invoke<InstanceRepairResult>("repair_instances");
}

export function resumeJavaDownloads(): Promise<JavaInstallation[]> {
return invoke<JavaInstallation[]>("resume_java_downloads");
}
Expand Down Expand Up @@ -383,6 +414,10 @@ export function startGame(
});
}

export function stopGame(): Promise<string> {
return invoke<string>("stop_game");
}

export function startMicrosoftLogin(): Promise<DeviceCodeResponse> {
return invoke<DeviceCodeResponse>("start_microsoft_login");
}
Expand Down
248 changes: 85 additions & 163 deletions packages/ui/src/components/bottom-bar.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"
Expand All @@ -173,30 +96,30 @@ 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",
"bg-emerald-600! hover:bg-emerald-500!",
)}
size="lg"
onClick={handleStartGame}
disabled={isLaunching}
disabled={launchingInstanceId === activeInstance?.id}
>
{isLaunching ? <Spinner /> : <Play />}
{launchingInstanceId === activeInstance?.id ? <Spinner /> : <Play />}
Start
</Button>
);
Expand All @@ -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>
Expand Down
Loading
Loading