aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages
diff options
context:
space:
mode:
authorNtskwK <natsukawa247@outlook.com>2026-03-26 09:23:07 +0800
committerNtskwK <natsukawa247@outlook.com>2026-03-26 09:23:07 +0800
commitf8b4bcb3bdc8f11323103081ef8c05b06159d684 (patch)
treee86c8d46e73262c67c1755aaf4202cbcd1f8f844 /packages
parent1812ca8974aee347b61bd415c1e2b63a205137dd (diff)
parent94b0d8e208363c802c12b56d8bdbef574dd1fb91 (diff)
downloadDropOut-f8b4bcb3bdc8f11323103081ef8c05b06159d684.tar.gz
DropOut-f8b4bcb3bdc8f11323103081ef8c05b06159d684.zip
Merge branch 'main' of https://github.com/HydroRoll-Team/DropOut
Diffstat (limited to 'packages')
-rw-r--r--packages/docs/app/app.css6
-rw-r--r--packages/docs/app/components/mermaid.tsx8
-rw-r--r--packages/docs/app/docs/page.tsx42
-rw-r--r--packages/docs/app/docs/search.ts8
-rw-r--r--packages/docs/app/lib/i18n.ts10
-rw-r--r--packages/docs/app/lib/layout.shared.tsx22
-rw-r--r--packages/docs/app/lib/source.ts8
-rw-r--r--packages/docs/app/root.tsx49
-rw-r--r--packages/docs/app/routes.ts12
-rw-r--r--packages/docs/app/routes/docs.tsx13
-rw-r--r--packages/docs/app/routes/home.tsx169
-rw-r--r--packages/docs/app/routes/not-found.tsx2
-rw-r--r--packages/docs/biome.json9
-rw-r--r--packages/docs/content/en/development/meta.json6
-rw-r--r--packages/docs/content/en/manual/features/meta.json7
-rw-r--r--packages/docs/content/en/manual/meta.json7
-rw-r--r--packages/docs/content/en/meta.json5
-rw-r--r--packages/docs/content/zh/development/meta.json6
-rw-r--r--packages/docs/content/zh/manual/features/meta.json7
-rw-r--r--packages/docs/content/zh/meta.json5
-rw-r--r--packages/docs/package.json1
-rw-r--r--packages/docs/react-router.config.ts2
-rw-r--r--packages/docs/source.config.ts6
-rw-r--r--packages/docs/tsconfig.json7
-rw-r--r--packages/docs/vite.config.ts12
-rw-r--r--packages/ui/package.json3
-rw-r--r--packages/ui/src/client.ts29
-rw-r--r--packages/ui/src/components/bottom-bar.tsx248
-rw-r--r--packages/ui/src/models/auth.ts87
-rw-r--r--packages/ui/src/models/instance.ts82
-rw-r--r--packages/ui/src/pages/index.tsx7
-rw-r--r--packages/ui/src/pages/instances-view.tsx174
-rw-r--r--packages/ui/src/stores/game-store.ts147
-rw-r--r--packages/ui/src/types/bindings/core.ts7
-rw-r--r--packages/ui/src/types/bindings/instance.ts7
-rw-r--r--packages/ui/vite.config.ts4
36 files changed, 768 insertions, 456 deletions
diff --git a/packages/docs/app/app.css b/packages/docs/app/app.css
index 50b3bc2..dbcc721 100644
--- a/packages/docs/app/app.css
+++ b/packages/docs/app/app.css
@@ -1,3 +1,3 @@
-@import 'tailwindcss';
-@import 'fumadocs-ui/css/neutral.css';
-@import 'fumadocs-ui/css/preset.css';
+@import "tailwindcss";
+@import "fumadocs-ui/css/neutral.css";
+@import "fumadocs-ui/css/preset.css";
diff --git a/packages/docs/app/components/mermaid.tsx b/packages/docs/app/components/mermaid.tsx
index bb25f2d..2df47cc 100644
--- a/packages/docs/app/components/mermaid.tsx
+++ b/packages/docs/app/components/mermaid.tsx
@@ -1,11 +1,11 @@
-'use client';
+"use client";
-import { useEffect, useRef } from 'react';
-import mermaid from 'mermaid';
+import mermaid from "mermaid";
+import { useEffect, useRef } from "react";
mermaid.initialize({
startOnLoad: false,
- theme: 'default',
+ theme: "default",
});
export function Mermaid({ chart }: { chart: string }) {
diff --git a/packages/docs/app/docs/page.tsx b/packages/docs/app/docs/page.tsx
index 49ad005..6ff6b4a 100644
--- a/packages/docs/app/docs/page.tsx
+++ b/packages/docs/app/docs/page.tsx
@@ -1,30 +1,36 @@
-import type { Route } from './+types/page';
-import defaultMdxComponents from 'fumadocs-ui/mdx';
-import { DocsLayout } from 'fumadocs-ui/layouts/docs';
-import { DocsPage, DocsBody, DocsDescription, DocsTitle } from 'fumadocs-ui/page';
-import { Card, Cards } from 'fumadocs-ui/components/card';
-import { source } from '@/lib/source';
-import { i18n } from '@/lib/i18n';
-import { baseOptions } from '@/lib/layout.shared';
-import { useFumadocsLoader } from 'fumadocs-core/source/client';
-import browserCollections from 'fumadocs-mdx:collections/browser';
-import { Mermaid } from '@/components/mermaid';
+import browserCollections from "fumadocs-mdx:collections/browser";
+import { useFumadocsLoader } from "fumadocs-core/source/client";
+import { Card, Cards } from "fumadocs-ui/components/card";
+import { DocsLayout } from "fumadocs-ui/layouts/docs";
+import defaultMdxComponents from "fumadocs-ui/mdx";
+import {
+ DocsBody,
+ DocsDescription,
+ DocsPage,
+ DocsTitle,
+} from "fumadocs-ui/page";
+import { Mermaid } from "@/components/mermaid";
+import { i18n } from "@/lib/i18n";
+import { baseOptions } from "@/lib/layout.shared";
+import { source } from "@/lib/source";
+import type { Route } from "./+types/page";
export async function loader({ params }: Route.LoaderArgs) {
// 从路由参数获取语言,如果没有则使用默认语言
// URL 格式: /docs/manual/getting-started (默认语言 zh)
// URL 格式: /en/docs/manual/getting-started (英语)
- const lang = (params.lang && i18n.languages.includes(params.lang as any))
- ? (params.lang as 'zh' | 'en')
- : (i18n.defaultLanguage as 'zh' | 'en');
+ const lang =
+ params.lang && i18n.languages.includes(params.lang as any)
+ ? (params.lang as "zh" | "en")
+ : (i18n.defaultLanguage as "zh" | "en");
// 获取文档路径 slugs
- const slugs = params['*']?.split('/').filter((v) => v.length > 0) || [];
+ const slugs = params["*"]?.split("/").filter((v) => v.length > 0) || [];
const page = source.getPage(slugs, lang);
if (!page) {
- throw new Response('Not found', { status: 404 });
+ throw new Response("Not found", { status: 404 });
}
return {
@@ -48,11 +54,11 @@ const clientLoader = browserCollections.docs.createClientLoader({
Card: (props: React.ComponentProps<typeof Card>) => (
<Card
{...props}
- className={`border-blue-600/20 hover:border-blue-600/50 transition-colors ${props.className || ''}`}
+ className={`border-blue-600/20 hover:border-blue-600/50 transition-colors ${props.className || ""}`}
/>
),
Cards,
- Mermaid
+ Mermaid,
}}
/>
</DocsBody>
diff --git a/packages/docs/app/docs/search.ts b/packages/docs/app/docs/search.ts
index a98edd5..47a4f38 100644
--- a/packages/docs/app/docs/search.ts
+++ b/packages/docs/app/docs/search.ts
@@ -1,11 +1,11 @@
-import type { Route } from './+types/search';
-import { createFromSource } from 'fumadocs-core/search/server';
-import { source } from '@/lib/source';
+import { createFromSource } from "fumadocs-core/search/server";
+import { source } from "@/lib/source";
+import type { Route } from "./+types/search";
const server = createFromSource(source, {
localeMap: {
zh: {
- language: 'english',
+ language: "english",
},
},
});
diff --git a/packages/docs/app/lib/i18n.ts b/packages/docs/app/lib/i18n.ts
index a9f18b1..cf81ffe 100644
--- a/packages/docs/app/lib/i18n.ts
+++ b/packages/docs/app/lib/i18n.ts
@@ -1,8 +1,8 @@
-import { defineI18n } from 'fumadocs-core/i18n';
+import { defineI18n } from "fumadocs-core/i18n";
export const i18n = defineI18n({
- defaultLanguage: 'zh',
- languages: ['zh', 'en'],
- hideLocale: 'default-locale',
- parser: 'dir', // 使用目录结构 (content/zh/*, content/en/*)
+ defaultLanguage: "zh",
+ languages: ["zh", "en"],
+ hideLocale: "default-locale",
+ parser: "dir", // 使用目录结构 (content/zh/*, content/en/*)
});
diff --git a/packages/docs/app/lib/layout.shared.tsx b/packages/docs/app/lib/layout.shared.tsx
index b3595eb..805d929 100644
--- a/packages/docs/app/lib/layout.shared.tsx
+++ b/packages/docs/app/lib/layout.shared.tsx
@@ -1,27 +1,27 @@
-import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
-import { i18n } from './i18n';
+import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
+import { i18n } from "./i18n";
export function baseOptions(locale: string): BaseLayoutProps {
// 默认语言(zh)不显示前缀,其他语言显示前缀
const isDefaultLocale = locale === i18n.defaultLanguage;
- const localePrefix = isDefaultLocale ? '' : `/${locale}`;
-
+ const localePrefix = isDefaultLocale ? "" : `/${locale}`;
+
return {
i18n,
nav: {
- title: 'DropOut',
- url: localePrefix || '/',
+ title: "DropOut",
+ url: localePrefix || "/",
},
- githubUrl: 'https://github.com/HydroRoll-Team/DropOut',
+ githubUrl: "https://github.com/HydroRoll-Team/DropOut",
links: [
{
- type: 'main',
- text: locale === 'zh' ? '使用文档' : 'Manual',
+ type: "main",
+ text: locale === "zh" ? "使用文档" : "Manual",
url: `${localePrefix}/docs/manual`,
},
{
- type: 'main',
- text: locale === 'zh' ? '开发文档' : 'Development',
+ type: "main",
+ text: locale === "zh" ? "开发文档" : "Development",
url: `${localePrefix}/docs/development`,
},
],
diff --git a/packages/docs/app/lib/source.ts b/packages/docs/app/lib/source.ts
index 4d6cc3a..7fccbc0 100644
--- a/packages/docs/app/lib/source.ts
+++ b/packages/docs/app/lib/source.ts
@@ -1,10 +1,10 @@
-import { loader } from 'fumadocs-core/source';
-import { docs } from 'fumadocs-mdx:collections/server';
-import { i18n } from './i18n';
+import { docs } from "fumadocs-mdx:collections/server";
+import { loader } from "fumadocs-core/source";
+import { i18n } from "./i18n";
export const source = loader({
source: docs.toFumadocsSource(),
- baseUrl: '/docs',
+ baseUrl: "/docs",
i18n,
// hideLocale: 'default-locale' 会自动生成正确的 URL:
// - 默认语言 (zh): /docs/manual/getting-started
diff --git a/packages/docs/app/root.tsx b/packages/docs/app/root.tsx
index 9032c80..621175c 100644
--- a/packages/docs/app/root.tsx
+++ b/packages/docs/app/root.tsx
@@ -1,3 +1,4 @@
+import { RootProvider } from "fumadocs-ui/provider/react-router";
import {
isRouteErrorResponse,
Link,
@@ -7,40 +8,39 @@ import {
Scripts,
ScrollRestoration,
useParams,
-} from 'react-router';
-import { RootProvider } from 'fumadocs-ui/provider/react-router';
-import type { Route } from './+types/root';
-import './app.css';
-import { defineI18nUI } from 'fumadocs-ui/i18n';
-import { i18n } from './lib/i18n';
+} from "react-router";
+import type { Route } from "./+types/root";
+import "./app.css";
+import { defineI18nUI } from "fumadocs-ui/i18n";
+import { i18n } from "./lib/i18n";
const { provider } = defineI18nUI(i18n, {
translations: {
en: {
- displayName: 'English',
+ displayName: "English",
},
zh: {
- displayName: '中文',
- search: '查找文档',
+ displayName: "中文",
+ search: "查找文档",
},
},
});
export const links: Route.LinksFunction = () => [
- { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
+ { rel: "preconnect", href: "https://fonts.googleapis.com" },
{
- rel: 'preconnect',
- href: 'https://fonts.gstatic.com',
- crossOrigin: 'anonymous',
+ rel: "preconnect",
+ href: "https://fonts.gstatic.com",
+ crossOrigin: "anonymous",
},
{
- rel: 'stylesheet',
- href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
+ rel: "stylesheet",
+ href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
const { lang = i18n.defaultLanguage } = useParams();
-
+
return (
<html lang={lang} suppressHydrationWarning>
<head>
@@ -63,14 +63,16 @@ export default function App() {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
- let message = 'Oops!';
- let details = 'An unexpected error occurred.';
+ let message = "Oops!";
+ let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
- message = error.status === 404 ? '404' : 'Error';
+ message = error.status === 404 ? "404" : "Error";
details =
- error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
+ error.status === 404
+ ? "The requested page could not be found."
+ : error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
@@ -83,7 +85,8 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
</h1>
<p className="text-2xl font-semibold mb-2">{details}</p>
<p className="text-fd-muted-foreground mb-8 max-w-md">
- Sorry, we couldn't find the page you're looking for. It might have been moved or deleted.
+ Sorry, we couldn't find the page you're looking for. It might have been
+ moved or deleted.
</p>
<Link
to="/"
@@ -93,7 +96,9 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
</Link>
{stack && (
<div className="mt-12 w-full max-w-2xl text-left">
- <p className="text-xs font-mono text-fd-muted-foreground mb-2 uppercase tracking-widest">Error Stack</p>
+ <p className="text-xs font-mono text-fd-muted-foreground mb-2 uppercase tracking-widest">
+ Error Stack
+ </p>
<pre className="p-4 overflow-x-auto text-sm bg-fd-muted rounded-xl border border-fd-border font-mono whitespace-pre-wrap break-all shadow-inner">
<code>{stack}</code>
</pre>
diff --git a/packages/docs/app/routes.ts b/packages/docs/app/routes.ts
index 2997ecf..d12d93c 100644
--- a/packages/docs/app/routes.ts
+++ b/packages/docs/app/routes.ts
@@ -1,16 +1,16 @@
-import { route, type RouteConfig } from '@react-router/dev/routes';
+import { type RouteConfig, route } from "@react-router/dev/routes";
export default [
// Home routes: / and /:lang
- route(':lang?', 'routes/home.tsx', { id: 'home' }),
+ route(":lang?", "routes/home.tsx", { id: "home" }),
// Docs routes: /docs/* and /:lang/docs/*
- route(':lang?/docs', 'routes/docs.tsx', { id: 'docs' }),
- route(':lang?/docs/*', 'docs/page.tsx', { id: 'docs-page' }),
+ route(":lang?/docs", "routes/docs.tsx", { id: "docs" }),
+ route(":lang?/docs/*", "docs/page.tsx", { id: "docs-page" }),
// API routes
- route('api/search', 'docs/search.ts', { id: 'api-search' }),
+ route("api/search", "docs/search.ts", { id: "api-search" }),
// Catch-all 404
- route('*', 'routes/not-found.tsx', { id: 'not-found' }),
+ route("*", "routes/not-found.tsx", { id: "not-found" }),
] satisfies RouteConfig;
diff --git a/packages/docs/app/routes/docs.tsx b/packages/docs/app/routes/docs.tsx
index 5090ccf..382a4e5 100644
--- a/packages/docs/app/routes/docs.tsx
+++ b/packages/docs/app/routes/docs.tsx
@@ -1,16 +1,15 @@
-import type { Route } from './+types/docs';
-import { redirect } from 'react-router';
-
-import { i18n } from '@/lib/i18n';
+import { redirect } from "react-router";
+import { i18n } from "@/lib/i18n";
+import type { Route } from "./+types/docs";
export function loader({ params }: Route.LoaderArgs) {
const lang = params.lang as string | undefined;
-
+
// 如果没有语言参数或是默认语言,重定向到 /docs/manual/getting-started
if (!lang || lang === i18n.defaultLanguage) {
- return redirect('/docs/manual/getting-started');
+ return redirect("/docs/manual/getting-started");
}
-
+
// 其他语言重定向到 /:lang/docs/manual/getting-started
return redirect(`/${lang}/docs/manual/getting-started`);
}
diff --git a/packages/docs/app/routes/home.tsx b/packages/docs/app/routes/home.tsx
index 427bf4e..fe561e4 100644
--- a/packages/docs/app/routes/home.tsx
+++ b/packages/docs/app/routes/home.tsx
@@ -1,89 +1,133 @@
-import type { Route } from './+types/home';
-import { HomeLayout } from 'fumadocs-ui/layouts/home';
-import { baseOptions } from '@/lib/layout.shared';
-import { i18n } from '@/lib/i18n';
+import { HomeLayout } from "fumadocs-ui/layouts/home";
+import { i18n } from "@/lib/i18n";
+import { baseOptions } from "@/lib/layout.shared";
+import type { Route } from "./+types/home";
const texts = {
en: {
hero: {
- title: 'DropOut Minecraft Launcher',
- subtitle: 'Modern. Reproducible. Developer-Grade.',
- description: 'Built with Tauri v2 and Rust for native performance and minimal resource usage',
- start: 'Get Started',
- development: 'Development',
+ title: "DropOut Minecraft Launcher",
+ subtitle: "Modern. Reproducible. Developer-Grade.",
+ description:
+ "Built with Tauri v2 and Rust for native performance and minimal resource usage",
+ start: "Get Started",
+ development: "Development",
},
features: {
items: [
- { title: 'High Performance', desc: 'Built with Rust and Tauri for minimal resource usage and fast startup times' },
- { title: 'Modern UI', desc: 'Clean, distraction-free interface with Svelte 5 and Tailwind CSS 4' },
- { title: 'Secure Auth', desc: 'Microsoft OAuth 2.0 with device code flow and offline mode support' },
- { title: 'Mod Loaders', desc: 'Built-in support for Fabric and Forge with automatic version management' },
- { title: 'Java Management', desc: 'Auto-detection and integrated downloader for Adoptium JDK/JRE' },
- { title: 'Instance System', desc: 'Isolated game environments with independent configs and mods' },
- ]
+ {
+ title: "High Performance",
+ desc: "Built with Rust and Tauri for minimal resource usage and fast startup times",
+ },
+ {
+ title: "Modern UI",
+ desc: "Clean, distraction-free interface with Svelte 5 and Tailwind CSS 4",
+ },
+ {
+ title: "Secure Auth",
+ desc: "Microsoft OAuth 2.0 with device code flow and offline mode support",
+ },
+ {
+ title: "Mod Loaders",
+ desc: "Built-in support for Fabric and Forge with automatic version management",
+ },
+ {
+ title: "Java Management",
+ desc: "Auto-detection and integrated downloader for Adoptium JDK/JRE",
+ },
+ {
+ title: "Instance System",
+ desc: "Isolated game environments with independent configs and mods",
+ },
+ ],
},
why: {
- title: 'Why DropOut?',
+ title: "Why DropOut?",
items: [
- { q: 'Your instance worked yesterday but broke today?', a: '→ DropOut makes it traceable.' },
- { q: 'Sharing a modpack means zipping gigabytes?', a: '→ DropOut shares exact dependency manifests.' },
- { q: 'Java, loader, mods, configs drift out of sync?', a: '→ DropOut locks them together.' },
- ]
+ {
+ q: "Your instance worked yesterday but broke today?",
+ a: "→ DropOut makes it traceable.",
+ },
+ {
+ q: "Sharing a modpack means zipping gigabytes?",
+ a: "→ DropOut shares exact dependency manifests.",
+ },
+ {
+ q: "Java, loader, mods, configs drift out of sync?",
+ a: "→ DropOut locks them together.",
+ },
+ ],
},
cta: {
- title: 'Ready to get started?',
- desc: 'Check out the documentation to learn more about DropOut',
- button: 'Read the Docs',
- }
+ title: "Ready to get started?",
+ desc: "Check out the documentation to learn more about DropOut",
+ button: "Read the Docs",
+ },
},
zh: {
hero: {
- title: 'DropOut Minecraft 启动器',
- subtitle: '现代、可复现、开发者级',
- description: '基于 Tauri v2 和 Rust 构建,拥有原生性能和极低的资源占用',
- start: '开始使用',
- development: '参与开发',
+ title: "DropOut Minecraft 启动器",
+ subtitle: "现代、可复现、开发者级",
+ description: "基于 Tauri v2 和 Rust 构建,拥有原生性能和极低的资源占用",
+ start: "开始使用",
+ development: "参与开发",
},
features: {
items: [
- { title: '高性能', desc: '使用 Rust 和 Tauri 构建,资源占用最小,启动速度极快' },
- { title: '现代化界面', desc: '简洁、无干扰的界面,使用 Svelte 5 和 Tailwind CSS 4' },
- { title: '安全认证', desc: '支持微软 OAuth 2.0 设备代码流和离线模式' },
- { title: '模组支持', desc: '内置 Fabric 和 Forge 支持,自动管理版本' },
- { title: 'Java 管理', desc: '自动检测并集成 Adoptium JDK/JRE 下载器' },
- { title: '实例系统', desc: '独立的游戏环境,独立的配置和模组' },
- ]
+ {
+ title: "高性能",
+ desc: "使用 Rust 和 Tauri 构建,资源占用最小,启动速度极快",
+ },
+ {
+ title: "现代化界面",
+ desc: "简洁、无干扰的界面,使用 Svelte 5 和 Tailwind CSS 4",
+ },
+ { title: "安全认证", desc: "支持微软 OAuth 2.0 设备代码流和离线模式" },
+ { title: "模组支持", desc: "内置 Fabric 和 Forge 支持,自动管理版本" },
+ { title: "Java 管理", desc: "自动检测并集成 Adoptium JDK/JRE 下载器" },
+ { title: "实例系统", desc: "独立的游戏环境,独立的配置和模组" },
+ ],
},
why: {
- title: '为什么选择 DropOut?',
+ title: "为什么选择 DropOut?",
items: [
- { q: '你的实例昨天还能用,今天就坏了?', a: '→ DropOut 让它可追溯。' },
- { q: '分享模组包意味着打包数GB的文件?', a: '→ DropOut 分享精确的依赖清单。' },
- { q: 'Java、加载器、模组、配置不同步?', a: '→ DropOut 将它们锁定在一起。' },
- ]
+ { q: "你的实例昨天还能用,今天就坏了?", a: "→ DropOut 让它可追溯。" },
+ {
+ q: "分享模组包意味着打包数GB的文件?",
+ a: "→ DropOut 分享精确的依赖清单。",
+ },
+ {
+ q: "Java、加载器、模组、配置不同步?",
+ a: "→ DropOut 将它们锁定在一起。",
+ },
+ ],
},
cta: {
- title: '准备好开始了?',
- desc: '查看文档以了解更多关于 DropOut 的信息',
- button: '阅读文档',
- }
- }
+ title: "准备好开始了?",
+ desc: "查看文档以了解更多关于 DropOut 的信息",
+ button: "阅读文档",
+ },
+ },
};
export function meta({ params }: Route.MetaArgs) {
return [
- { title: 'DropOut - Modern Minecraft Launcher' },
- { name: 'description', content: 'A modern, reproducible, and developer-grade Minecraft launcher built with Tauri v2 and Rust.' },
+ { title: "DropOut - Modern Minecraft Launcher" },
+ {
+ name: "description",
+ content:
+ "A modern, reproducible, and developer-grade Minecraft launcher built with Tauri v2 and Rust.",
+ },
];
}
export default function Home({ params }: Route.ComponentProps) {
- const lang = (params.lang as 'en' | 'zh') || i18n.defaultLanguage;
+ const lang = (params.lang as "en" | "zh") || i18n.defaultLanguage;
const t = texts[lang];
-
+
// 默认语言(zh)不显示前缀,其他语言显示前缀
const isDefaultLocale = lang === i18n.defaultLanguage;
- const localePrefix = isDefaultLocale ? '' : `/${lang}`;
+ const localePrefix = isDefaultLocale ? "" : `/${lang}`;
return (
<HomeLayout {...baseOptions(lang)}>
@@ -110,7 +154,7 @@ export default function Home({ params }: Route.ComponentProps) {
className="bg-fd-secondary hover:bg-fd-secondary/80 text-fd-secondary-foreground font-semibold rounded-lg px-6 py-3 transition-colors cursor-pointer border border-blue-600/50"
href={`${localePrefix}/docs/development`}
>
- {t.hero.development}
+ {t.hero.development}
</a>
</div>
</div>
@@ -118,9 +162,9 @@ export default function Home({ params }: Route.ComponentProps) {
{/* Launcher Showcase */}
<div className="mb-16">
<div className="rounded-xl overflow-hidden shadow-2xl border border-fd-border">
- <img
- src="/image.png"
- alt="DropOut Launcher Interface"
+ <img
+ src="/image.png"
+ alt="DropOut Launcher Interface"
className="w-full h-auto"
/>
</div>
@@ -129,11 +173,12 @@ export default function Home({ params }: Route.ComponentProps) {
{/* Features Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-16">
{t.features.items.map((item, i) => (
- <div key={i} className="p-6 rounded-lg border border-blue-600/20 bg-fd-card hover:border-blue-600/50 transition-colors">
+ <div
+ key={i}
+ className="p-6 rounded-lg border border-blue-600/20 bg-fd-card hover:border-blue-600/50 transition-colors"
+ >
<h3 className="font-semibold text-lg mb-2">{item.title}</h3>
- <p className="text-sm text-fd-muted-foreground">
- {item.desc}
- </p>
+ <p className="text-sm text-fd-muted-foreground">{item.desc}</p>
</div>
))}
</div>
@@ -157,9 +202,7 @@ export default function Home({ params }: Route.ComponentProps) {
{/* CTA Section */}
<div className="text-center py-12 px-6 rounded-xl bg-gradient-to-r from-blue-600/10 to-cyan-500/10 border border-blue-600/20">
<h2 className="text-3xl font-bold mb-4">{t.cta.title}</h2>
- <p className="text-lg text-fd-muted-foreground mb-6">
- {t.cta.desc}
- </p>
+ <p className="text-lg text-fd-muted-foreground mb-6">{t.cta.desc}</p>
<a
className="inline-block bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg px-8 py-3 transition-colors"
href={`${localePrefix}/docs/manual/getting-started`}
diff --git a/packages/docs/app/routes/not-found.tsx b/packages/docs/app/routes/not-found.tsx
index 1d9e041..7be6081 100644
--- a/packages/docs/app/routes/not-found.tsx
+++ b/packages/docs/app/routes/not-found.tsx
@@ -1,5 +1,5 @@
export function loader() {
- throw new Response('Not Found', { status: 404 });
+ throw new Response("Not Found", { status: 404 });
}
export default function NotFound() {
diff --git a/packages/docs/biome.json b/packages/docs/biome.json
index a637e58..ea55c8c 100644
--- a/packages/docs/biome.json
+++ b/packages/docs/biome.json
@@ -1,5 +1,6 @@
{
- "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
+ "root": false,
+ "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@@ -7,11 +8,7 @@
},
"files": {
"ignoreUnknown": true,
- "includes": [
- "**",
- "!node_modules",
- "!.source"
- ]
+ "includes": ["**", "!node_modules", "!.source"]
},
"formatter": {
"enabled": true,
diff --git a/packages/docs/content/en/development/meta.json b/packages/docs/content/en/development/meta.json
index dc0d5c5..627f2ab 100644
--- a/packages/docs/content/en/development/meta.json
+++ b/packages/docs/content/en/development/meta.json
@@ -1,8 +1,4 @@
{
"title": "Development",
- "pages": [
- "guide",
- "architecture",
- "implementation"
- ]
+ "pages": ["guide", "architecture", "implementation"]
}
diff --git a/packages/docs/content/en/manual/features/meta.json b/packages/docs/content/en/manual/features/meta.json
index 4725321..fcaa9ad 100644
--- a/packages/docs/content/en/manual/features/meta.json
+++ b/packages/docs/content/en/manual/features/meta.json
@@ -1,9 +1,4 @@
{
"title": "Features",
- "pages": [
- "index",
- "authentication",
- "java",
- "mod-loaders"
- ]
+ "pages": ["index", "authentication", "java", "mod-loaders"]
}
diff --git a/packages/docs/content/en/manual/meta.json b/packages/docs/content/en/manual/meta.json
index 38506c1..56c5ceb 100644
--- a/packages/docs/content/en/manual/meta.json
+++ b/packages/docs/content/en/manual/meta.json
@@ -1,9 +1,4 @@
{
"title": "User Manual",
- "pages": [
- "index",
- "getting-started",
- "features",
- "troubleshooting"
- ]
+ "pages": ["index", "getting-started", "features", "troubleshooting"]
}
diff --git a/packages/docs/content/en/meta.json b/packages/docs/content/en/meta.json
index a877cab..af40af3 100644
--- a/packages/docs/content/en/meta.json
+++ b/packages/docs/content/en/meta.json
@@ -1,7 +1,4 @@
{
"title": "Documentation",
- "pages": [
- "manual",
- "development"
- ]
+ "pages": ["manual", "development"]
}
diff --git a/packages/docs/content/zh/development/meta.json b/packages/docs/content/zh/development/meta.json
index 69cc009..48afa86 100644
--- a/packages/docs/content/zh/development/meta.json
+++ b/packages/docs/content/zh/development/meta.json
@@ -1,8 +1,4 @@
{
"title": "开发文档",
- "pages": [
- "index",
- "architecture",
- "implementation"
- ]
+ "pages": ["index", "architecture", "implementation"]
}
diff --git a/packages/docs/content/zh/manual/features/meta.json b/packages/docs/content/zh/manual/features/meta.json
index 2fb2ded..3bf3d24 100644
--- a/packages/docs/content/zh/manual/features/meta.json
+++ b/packages/docs/content/zh/manual/features/meta.json
@@ -1,9 +1,4 @@
{
"title": "功能特性",
- "pages": [
- "index",
- "authentication",
- "java",
- "mod-loaders"
- ]
+ "pages": ["index", "authentication", "java", "mod-loaders"]
}
diff --git a/packages/docs/content/zh/meta.json b/packages/docs/content/zh/meta.json
index b0825f3..a5aa1d7 100644
--- a/packages/docs/content/zh/meta.json
+++ b/packages/docs/content/zh/meta.json
@@ -1,7 +1,4 @@
{
"title": "文档",
- "pages": [
- "development",
- "manual"
- ]
+ "pages": ["development", "manual"]
}
diff --git a/packages/docs/package.json b/packages/docs/package.json
index 4ee4baf..08cd10d 100644
--- a/packages/docs/package.json
+++ b/packages/docs/package.json
@@ -2,6 +2,7 @@
"name": "@dropout/docs",
"private": true,
"type": "module",
+ "version": "0.1.0-alpha",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
diff --git a/packages/docs/react-router.config.ts b/packages/docs/react-router.config.ts
index 51e8967..e45e273 100644
--- a/packages/docs/react-router.config.ts
+++ b/packages/docs/react-router.config.ts
@@ -1,4 +1,4 @@
-import type { Config } from '@react-router/dev/config';
+import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
diff --git a/packages/docs/source.config.ts b/packages/docs/source.config.ts
index 7880853..37480ac 100644
--- a/packages/docs/source.config.ts
+++ b/packages/docs/source.config.ts
@@ -1,8 +1,8 @@
-import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
-import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins';
+import { remarkMdxMermaid } from "fumadocs-core/mdx-plugins";
+import { defineConfig, defineDocs } from "fumadocs-mdx/config";
export const docs = defineDocs({
- dir: 'content',
+ dir: "content",
});
export default defineConfig({
diff --git a/packages/docs/tsconfig.json b/packages/docs/tsconfig.json
index 717253d..0a4a5b2 100644
--- a/packages/docs/tsconfig.json
+++ b/packages/docs/tsconfig.json
@@ -1,5 +1,10 @@
{
- "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
+ "include": [
+ "**/*",
+ "**/.server/**/*",
+ "**/.client/**/*",
+ ".react-router/types/**/*"
+ ],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
diff --git a/packages/docs/vite.config.ts b/packages/docs/vite.config.ts
index f408dc5..6ad484b 100644
--- a/packages/docs/vite.config.ts
+++ b/packages/docs/vite.config.ts
@@ -1,9 +1,9 @@
-import { reactRouter } from '@react-router/dev/vite';
-import tailwindcss from '@tailwindcss/vite';
-import { defineConfig } from 'vite';
-import tsconfigPaths from 'vite-tsconfig-paths';
-import mdx from 'fumadocs-mdx/vite';
-import * as MdxConfig from './source.config';
+import { reactRouter } from "@react-router/dev/vite";
+import tailwindcss from "@tailwindcss/vite";
+import mdx from "fumadocs-mdx/vite";
+import { defineConfig } from "vite";
+import tsconfigPaths from "vite-tsconfig-paths";
+import * as MdxConfig from "./source.config";
export default defineConfig({
plugins: [
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 42705f8..9f329e4 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -11,6 +11,8 @@
},
"dependencies": {
"@base-ui/react": "^1.2.0",
+ "@hookform/resolvers": "^5.2.2",
+ "@stepperize/react": "^6.1.0",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
@@ -24,6 +26,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "react-hook-form": "^7.71.2",
"react-router": "^7.12.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.1",
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 093ccb2..209a1b2 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"),