diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0d5a124..54a44b1 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -46,6 +46,16 @@ jobs: - name: Run UI fixture regression suite run: pnpm -C packages/ui test:ui + - name: Upload Linux screenshot candidates + if: failure() + uses: actions/upload-artifact@v4 + with: + name: linux-screenshot-candidates + path: packages/ui/test-results/**/*-actual.png + if-no-files-found: error + compression-level: 0 + retention-days: 7 + - name: Upload UI regression diagnostics if: failure() uses: actions/upload-artifact@v4 diff --git a/packages/docs/content/en/development/implementation.mdx b/packages/docs/content/en/development/implementation.mdx index df123b4..2942a1f 100644 --- a/packages/docs/content/en/development/implementation.mdx +++ b/packages/docs/content/en/development/implementation.mdx @@ -120,6 +120,17 @@ For 1.17+ versions and high-load mod environments, DropOut injects a concise G1G -XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC ``` +### 3.3 Launch Readiness and Recovery APIs + +The home command center calls `get_launch_readiness(instance_id, version_id)` before enabling launch. The command checks the version metadata/client files and calls the same Java resolver used by `start_game` (instance override → global path → preferred managed runtime → automatic detection). This keeps the displayed Java result and the real launch decision consistent. + +| Command / Event | Type | Purpose | +| :--- | :--- | :--- | +| `get_launch_readiness` | Invoke | Return installed-file state, required Java major version, and the resolved compatible runtime | +| `download-start` / `download-progress` / `download-complete` | Event | Drive the live aggregate download card | +| `launcher-log` / `game-stdout` / `game-stderr` | Event | Preserve recent launch and game output for recovery | +| `game-exited` | Event | Distinguish stopped, clean exit, and failed session states | + --- ## 4. Mod Loader Mechanism diff --git a/packages/docs/content/en/manual/getting-started.mdx b/packages/docs/content/en/manual/getting-started.mdx index c172289..385f8a5 100644 --- a/packages/docs/content/en/manual/getting-started.mdx +++ b/packages/docs/content/en/manual/getting-started.mdx @@ -105,14 +105,18 @@ When you first launch DropOut, you'll need to: ### 3. Launch the Game -1. Go to the **Home** tab -2. Select your desired version from the dropdown -3. Adjust settings if needed: - - Memory allocation (RAM) - - Window resolution - - Java path -4. Click **"Launch Game"** -5. Monitor the launch process in the console +1. Go to **Overview** and choose an instance from **Active instance**. +2. Read the **Launch readiness** checks for account, instance, version, loader, Java, memory, and local game files. +3. Use the single primary action. It changes with the current state: + - sign in or create/import an instance; + - configure a missing version or repair Java/memory; + - download required game files; + - launch, stop, or launch again; + - inspect a failed session. +4. During preparation, the download card shows file count, current file, bytes, and overall progress. +5. During and after a session, use **Live launch log** or **Open Minecraft logs** to inspect output without searching for the instance directory. + +DropOut calculates the Java and file checks with the same backend rules used by the real launch command. A green **ready** state therefore means the launch-critical checks passed, not merely that fields were filled in. ## Next Steps diff --git a/packages/docs/content/en/manual/troubleshooting.mdx b/packages/docs/content/en/manual/troubleshooting.mdx index 6d97819..bb89a03 100644 --- a/packages/docs/content/en/manual/troubleshooting.mdx +++ b/packages/docs/content/en/manual/troubleshooting.mdx @@ -97,6 +97,17 @@ sudo pacman -S webkit2gtk gtk3 ## Game Launch Issues +### Use the Home Recovery Action First + +The Overview page summarizes the active account, instance, version, loader, Java runtime, memory allocation, and local files. Follow its primary action before changing configuration manually: + +- **Repair Java** opens the runtime settings when no compatible installation can be resolved. +- **Download & prepare** installs or repairs missing version files and keeps progress visible. +- **Inspect failure** keeps the captured launcher/stdout/stderr lines on screen; **Open Minecraft logs** opens the full log directory. +- **Retry checks** reloads account, settings, and instance data after a read error. + +If the page reports **ready** but launch still fails, preserve the failure details and full Minecraft log when reporting the problem. + ### Java Not Found **Issue:** "No Java installation found" diff --git a/packages/docs/content/zh/development/implementation.mdx b/packages/docs/content/zh/development/implementation.mdx index 2738186..b85a461 100644 --- a/packages/docs/content/zh/development/implementation.mdx +++ b/packages/docs/content/zh/development/implementation.mdx @@ -120,6 +120,17 @@ fn check_java_installation(path: &PathBuf) -> Option { -XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC ``` +### 3.3 启动就绪与恢复接口 + +首页指挥台会在启用启动操作前调用 `get_launch_readiness(instance_id, version_id)`。该命令检查版本元数据和客户端文件,并调用与 `start_game` 相同的 Java 解析器(实例覆盖 → 全局路径 → 首选托管运行时 → 自动检测),确保页面显示的 Java 结果与真实启动判断一致。 + +| 命令 / 事件 | 类型 | 用途 | +| :--- | :--- | :--- | +| `get_launch_readiness` | Invoke | 返回文件安装状态、所需 Java 主版本和已解析的兼容运行时 | +| `download-start` / `download-progress` / `download-complete` | Event | 驱动实时汇总下载卡片 | +| `launcher-log` / `game-stdout` / `game-stderr` | Event | 保留最近的启动和游戏输出,用于失败恢复 | +| `game-exited` | Event | 区分主动停止、正常退出和失败会话 | + --- ## 4. 模组加载机制 (Mod Loaders) diff --git a/packages/docs/content/zh/manual/getting-started.mdx b/packages/docs/content/zh/manual/getting-started.mdx index 5d8c8c5..b76eee6 100644 --- a/packages/docs/content/zh/manual/getting-started.mdx +++ b/packages/docs/content/zh/manual/getting-started.mdx @@ -105,14 +105,18 @@ chmod +x dropout_*.AppImage ### 3. 启动游戏 -1. 转到**主页**标签 -2. 从下拉菜单中选择你想要的版本 -3. 如果需要,调整设置: - - 内存分配(RAM) - - 窗口分辨率 - - Java 路径 -4. 点击**启动游戏** -5. 在控制台中监视启动过程 +1. 进入**概览**,在**当前实例**中选择要游玩的实例。 +2. 查看**启动就绪检查**中的账户、实例、版本、加载器、Java、内存和本地游戏文件状态。 +3. 使用唯一的主操作按钮;它会根据当前状态自动切换: + - 登录,或创建/导入实例; + - 配置缺失版本,或修复 Java/内存; + - 下载所需游戏文件; + - 启动、停止或再次启动; + - 查看失败会话的详情。 +4. 准备期间,下载卡片会显示文件数、当前文件、字节数和总体进度。 +5. 会话运行期间或结束后,可通过**实时启动日志**或**打开 Minecraft 日志**查看输出,无需手动查找实例目录。 + +DropOut 使用与真实启动命令相同的后端规则计算 Java 和文件状态。因此,绿色的**已就绪**表示启动关键检查确实通过,而不只是配置字段已经填写。 ## 下一步 diff --git a/packages/docs/content/zh/manual/troubleshooting.mdx b/packages/docs/content/zh/manual/troubleshooting.mdx index ba0ec66..3a3751b 100644 --- a/packages/docs/content/zh/manual/troubleshooting.mdx +++ b/packages/docs/content/zh/manual/troubleshooting.mdx @@ -97,6 +97,17 @@ sudo pacman -S webkit2gtk gtk3 ## 游戏启动问题 +### 优先使用首页恢复操作 + +概览页会汇总当前账户、实例、版本、加载器、Java 运行时、内存分配和本地文件状态。手动修改配置前,请先执行页面给出的主操作: + +- **修复 Java**:无法解析兼容运行时时,直接进入运行时设置。 +- **下载并准备**:安装或修复缺失的版本文件,并持续显示进度。 +- **查看失败详情**:在页面保留已捕获的启动器、标准输出和错误输出;**打开 Minecraft 日志**可进入完整日志目录。 +- **重新检查**:读取错误后,重新加载账户、设置和实例数据。 + +如果页面显示**已就绪**但启动仍然失败,请在反馈问题时保留失败详情和完整 Minecraft 日志。 + ### 找不到 Java **问题:** "未找到 Java 安装" diff --git a/packages/ui/README.md b/packages/ui/README.md index 9fe732d..5d30a5b 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -16,20 +16,30 @@ Open a fixture with query parameters before the hash route: ```text http://127.0.0.1:1420/?fixture=ready&theme=dark#/ http://127.0.0.1:1420/?fixture=migration&theme=light#/instances/import +http://127.0.0.1:1420/?fixture=failed&theme=dark&locale=zh-CN#/ ``` Available fixture states are: | Fixture | Purpose | |---|---| -| `empty` | No account or instances | -| `ready` | Account, active Fabric instance, versions, Java, and settings | -| `downloading` | Stable active-download presentation | -| `running` | Active game process and stop action | +| `empty` | Legacy combined empty state | +| `no-account` | Instance exists but authentication blocks launch | +| `no-instance` | Account exists but no instance has been created or imported | +| `not-ready` | Active instance has no compatible Java runtime | +| `ready` | All account, instance, version, Java, memory, and file checks pass | +| `downloading` | Live game-file progress in the home command center | +| `java-download-progress` | Java runtime event progress with bounded percentage and byte formatting | +| `launching` | Launch command is assembling the runtime and process | +| `running` | Active game process with the stop action | +| `stopped` | Cleanly ended session with retained logs and relaunch action | +| `failed` | Failed session with captured diagnostic output | | `error` | Instance-index failure and recovery feedback | | `migration` | Detected Prism/MultiMC and PCL/HMCL import sources | -Use `theme=dark` or `theme=light`. Fixture activation is guarded by `import.meta.env.DEV`; production builds ignore fixture parameters and continue to call Tauri directly. +Use `theme=dark` or `theme=light`, and `locale=en` or `locale=zh-CN`. Fixture activation is guarded by `import.meta.env.DEV`; production builds ignore fixture parameters and continue to call Tauri directly. + +The home command center does not infer readiness from frontend configuration alone. `get_launch_readiness` reuses the backend's real Java priority and compatibility rules, while `download-start`, `download-progress`, `download-complete`, `launcher-log`, `game-stdout`, `game-stderr`, and `game-exited` events keep progress and recovery details live. ## Regression Tests @@ -40,7 +50,7 @@ pnpm -C packages/ui exec playwright install chromium pnpm -C packages/ui test:ui ``` -The suite verifies both supported launcher window sizes (`1024x768` and `905x575`), both themes, deterministic screenshots, accessible names/image alternatives/unique IDs on critical routes, and a keyboard-only launch flow. Import and raw-config editor surfaces have independent lazy routes so their heavier dependencies do not inflate the initial route. +The suite verifies both supported launcher window sizes (`1024x768` and `905x575`), both themes, every launch/recovery state, deterministic screenshots, English and Chinese rendering, reduced motion, accessible names/image alternatives/unique IDs on critical routes, and keyboard-only navigation and launch flows. Import and raw-config editor surfaces have independent lazy routes so their heavier dependencies do not inflate the initial route. When an intentional visual change has been reviewed at both window sizes, update and inspect the baselines before committing them: diff --git a/packages/ui/src/client.ts b/packages/ui/src/client.ts index caa0ebc..3bf673a 100644 --- a/packages/ui/src/client.ts +++ b/packages/ui/src/client.ts @@ -22,6 +22,7 @@ import type { JavaDownloadInfo, JavaInstallation, LauncherConfig, + LaunchReadiness, Message, MigrationResult, ModelInfo, @@ -269,6 +270,16 @@ export function getInstance(instanceId: string): Promise { }); } +export function getLaunchReadiness( + instanceId: string, + versionId: string, +): Promise { + return invoke("get_launch_readiness", { + instanceId, + versionId, + }); +} + export function getPendingJavaDownloads(): Promise { return invoke("get_pending_java_downloads"); } diff --git a/packages/ui/src/components/download-monitor.tsx b/packages/ui/src/components/download-monitor.tsx index e531cc9..5a2022f 100644 --- a/packages/ui/src/components/download-monitor.tsx +++ b/packages/ui/src/components/download-monitor.tsx @@ -1,69 +1,101 @@ -import { X } from "lucide-react"; -import { useState } from "react"; +import { Download, FileDown } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import { useDownloadStore } from "@/models/downloads"; -export function DownloadMonitor() { - const [isVisible, setIsVisible] = useState(true); +function formatBytes(value: number) { + if (!Number.isFinite(value) || value <= 0) return "—"; + const units = ["B", "KB", "MB", "GB"]; + const unit = Math.min( + Math.floor(Math.log(value) / Math.log(1024)), + units.length - 1, + ); + return `${(value / 1024 ** unit).toFixed(unit > 1 ? 1 : 0)} ${units[unit]}`; +} + +export function DownloadMonitor({ className }: { className?: string }) { const { t } = useTranslation(); + const download = useDownloadStore(); + + if (!download.active && download.totalFiles === 0) return null; - if (!isVisible) return null; + const percentage = Math.round(download.percentage); return ( -
- {/* Header */} -
-
-
- - {t("downloadMonitor.title")} +
+
+
+ + +
+

+ {t("downloadMonitor.title")} +

+

+ {download.kind === "java" + ? t("downloadMonitor.javaRuntime") + : t("downloadMonitor.gameFiles")} +

+
- + + {percentage}% +
- {/* Content */} -
-
- {/* Download Item */} -
-
- Minecraft 1.20.4 - 65% -
-
-
-
-
- 142 MB / 218 MB - 2.1 MB/s • 36s remaining -
-
+
+
+
+
- {/* Download Item */} -
-
- Java 17 - 100% -
-
-
-
-
- {t("downloadMonitor.completed")} +
+
-
+
); } diff --git a/packages/ui/src/fixtures/bootstrap.ts b/packages/ui/src/fixtures/bootstrap.ts index a4a1b5e..df1b13e 100644 --- a/packages/ui/src/fixtures/bootstrap.ts +++ b/packages/ui/src/fixtures/bootstrap.ts @@ -1,7 +1,9 @@ import { + getLauncherFixtureLocale, getLauncherFixtureName, getLauncherFixtureTheme, } from "@/lib/launcher-runtime"; +import { useDownloadStore } from "@/models/downloads"; import { useGameStore } from "@/models/game"; export function bootstrapLauncherFixture() { @@ -11,13 +13,78 @@ export function bootstrapLauncherFixture() { document.documentElement.dataset.launcherFixture = fixtureName; document.documentElement.dataset.launcherFixtureTheme = getLauncherFixtureTheme() ?? "dark"; + document.documentElement.dataset.launcherFixtureLocale = + getLauncherFixtureLocale() ?? "en"; - if (fixtureName === "running") { + if (fixtureName === "downloading") { + useDownloadStore.setState({ + active: true, + kind: "game", + currentFile: "client-1.21.1.jar", + status: "Downloading", + completedFiles: 37, + totalFiles: 58, + downloadedBytes: 84 * 1024 * 1024, + totalBytes: 128 * 1024 * 1024, + percentage: 65, + }); + } + + if (fixtureName === "launching") { + useGameStore.setState({ + launchingInstanceId: "fixture-fabric-1211", + recentLogs: [ + { id: 1, source: "launcher", message: "Resolving Java runtime" }, + { id: 2, source: "launcher", message: "Building launch arguments" }, + ], + }); + } else if (fixtureName === "running") { useGameStore.setState({ runningInstanceId: "fixture-fabric-1211", runningVersionId: "1.21.1", launchingInstanceId: null, stoppingInstanceId: null, + recentLogs: [ + { id: 1, source: "launcher", message: "Minecraft process started" }, + { + id: 2, + source: "stdout", + message: "[Render thread/INFO]: OpenGL initialized", + }, + ], + }); + } else if (fixtureName === "stopped") { + useGameStore.setState({ + lastExit: { + instanceId: "fixture-fabric-1211", + versionId: "1.21.1", + exitCode: 0, + wasStopped: false, + }, + recentLogs: [ + { id: 1, source: "stdout", message: "Stopping integrated server" }, + { id: 2, source: "launcher", message: "Minecraft exited with code 0" }, + ], + }); + } else if (fixtureName === "failed") { + useGameStore.setState({ + lastError: "Minecraft 1.21.1 exited with code 1", + lastErrorInstanceId: "fixture-fabric-1211", + lastExit: { + instanceId: "fixture-fabric-1211", + versionId: "1.21.1", + exitCode: 1, + wasStopped: false, + }, + recentLogs: [ + { id: 1, source: "launcher", message: "Starting Minecraft 1.21.1" }, + { + id: 2, + source: "stderr", + message: "Could not initialize class net.fabricmc.loader", + }, + { id: 3, source: "stderr", message: "Process exited with code 1" }, + ], }); } } diff --git a/packages/ui/src/fixtures/launcher.ts b/packages/ui/src/fixtures/launcher.ts index b8097f1..c88b2bb 100644 --- a/packages/ui/src/fixtures/launcher.ts +++ b/packages/ui/src/fixtures/launcher.ts @@ -1,5 +1,6 @@ import type { EventCallback, UnlistenFn } from "@tauri-apps/api/event"; import { + getLauncherFixtureLocale, getLauncherFixtureName, getLauncherFixtureTheme, } from "@/lib/launcher-runtime"; @@ -13,9 +14,14 @@ import type { JavaCatalog, JavaInstallation, LauncherConfig, + LaunchReadiness, Version, } from "@/types"; import type { GameExitedEvent } from "@/types/bindings/core"; +import type { + JavaDownloadProgress, + ProgressEvent, +} from "@/types/bindings/downloader"; const account: Account = { type: "microsoft", @@ -198,8 +204,9 @@ const fixtureState: FixtureState = { function fixturesForCurrentScenario() { const name = getLauncherFixtureName() ?? "ready"; - const empty = name === "empty"; - const instances = empty ? [] : [readyInstance, vanillaInstance]; + const noAccount = name === "empty" || name === "no-account"; + const noInstances = name === "empty" || name === "no-instance"; + const instances = noInstances ? [] : [readyInstance, vanillaInstance]; const activeInstance = instances.find( (instance) => instance.id === fixtureState.activeInstanceId, @@ -209,8 +216,8 @@ function fixturesForCurrentScenario() { return { name, - account: empty ? null : account, - accounts: empty ? [] : [accountSummary], + account: noAccount ? null : account, + accounts: noAccount ? [] : [accountSummary], instances, activeInstance, }; @@ -232,6 +239,23 @@ export async function fixtureListen( eventListeners.add(handler as EventCallback); listeners.set(eventName, eventListeners); + if ( + getLauncherFixtureName() === "java-download-progress" && + eventName === "java-download-progress" + ) { + queueMicrotask(() => { + emitFixtureEvent("java-download-progress", { + fileName: "OpenJDK21U-jre_aarch64_mac_hotspot_21.0.7_6.tar.gz", + downloadedBytes: 1_610_612_736n, + totalBytes: 2_147_483_648n, + speedBytesPerSec: 33_554_432n, + etaSeconds: 16n, + status: "Verifying", + percentage: 125, + }); + }); + } + return () => { eventListeners.delete(handler as EventCallback); }; @@ -267,11 +291,19 @@ export async function fixtureInvoke( return { ...fixtureState.settings, theme: getLauncherFixtureTheme() ?? fixtureState.settings.theme, + language: + getLauncherFixtureLocale() ?? fixtureState.settings.language, }; case "list_instances": return fixture.instances; case "get_versions": return versions; + case "get_launch_readiness": + return { + versionInstalled: fixture.name !== "downloading", + requiredJavaMajor: 21n, + java: fixture.name === "not-ready" ? null : javaInstallations[0], + } satisfies LaunchReadiness; case "detect_launchers": return fixture.name === "migration" ? detectedLaunchers : []; case "scan_launcher_instances": @@ -306,6 +338,22 @@ export async function fixtureInvoke( return undefined; case "start_game": return `Fixture: started Minecraft ${String(args.versionId)}`; + case "install_version": { + emitFixtureEvent("download-start", 4); + emitFixtureEvent("download-progress", { + file: "client-1.21.1.jar", + downloaded: 64n, + total: 128n, + status: "Downloading", + completedFiles: 2, + totalFiles: 4, + totalDownloadedBytes: 192n, + }); + emitFixtureEvent("download-complete", undefined); + return undefined; + } + case "open_file_explorer": + return undefined; case "stop_game": { const active = fixturesForCurrentScenario().activeInstance; queueMicrotask(() => { diff --git a/packages/ui/src/i18n.ts b/packages/ui/src/i18n.ts index 498e33c..0871ea3 100644 --- a/packages/ui/src/i18n.ts +++ b/packages/ui/src/i18n.ts @@ -33,13 +33,15 @@ i18n.use(initReactI18next).init({ * `language` config changes. */ export function setLanguage(lang: string) { - if (lang === "auto") { - i18n.changeLanguage(detectLanguage()); - } else if (resources[lang as keyof typeof resources]) { - i18n.changeLanguage(lang); - } else { - i18n.changeLanguage("en"); - } + const nextLanguage = + lang === "auto" + ? detectLanguage() + : resources[lang as keyof typeof resources] + ? lang + : "en"; + + document.documentElement.lang = nextLanguage; + void i18n.changeLanguage(nextLanguage); } export default i18n; diff --git a/packages/ui/src/lib/launch-readiness.ts b/packages/ui/src/lib/launch-readiness.ts new file mode 100644 index 0000000..902eea2 --- /dev/null +++ b/packages/ui/src/lib/launch-readiness.ts @@ -0,0 +1,94 @@ +import type { + Account, + Instance, + LauncherConfig, + LaunchReadiness, +} from "@/types"; +import type { GameExitedEvent } from "@/types/bindings/core"; + +export type LoadStatus = "idle" | "loading" | "ready" | "error"; + +export type HomeLaunchState = + | "checking" + | "data-error" + | "no-account" + | "no-instance" + | "version-missing" + | "java-missing" + | "files-missing" + | "memory-invalid" + | "downloading" + | "launching" + | "running" + | "stopped" + | "failed" + | "ready"; + +export interface HomeLaunchStateInput { + account: Account | null; + activeInstance: Instance | null; + config: LauncherConfig | null; + authStatus: LoadStatus; + instanceStatus: LoadStatus; + settingsStatus: LoadStatus; + probe: LaunchReadiness | null; + probeLoading: boolean; + probeError: string | null; + downloadActive: boolean; + launchingInstanceId: string | null; + runningInstanceId: string | null; + lastExit: GameExitedEvent | null; + lastError: string | null; +} + +export function resolveHomeLaunchState( + input: HomeLaunchStateInput, +): HomeLaunchState { + if ( + input.authStatus === "idle" || + input.authStatus === "loading" || + input.instanceStatus === "idle" || + input.instanceStatus === "loading" || + input.settingsStatus === "idle" || + input.settingsStatus === "loading" + ) { + return "checking"; + } + + if ( + input.authStatus === "error" || + input.instanceStatus === "error" || + input.settingsStatus === "error" + ) { + return "data-error"; + } + + if (!input.account) return "no-account"; + if (!input.activeInstance) return "no-instance"; + + if (input.runningInstanceId) return "running"; + if (input.downloadActive) return "downloading"; + if (input.launchingInstanceId) return "launching"; + if (input.lastError) return "failed"; + + if ( + input.lastExit?.instanceId === input.activeInstance.id && + (input.lastExit.wasStopped || input.lastExit.exitCode === 0) + ) { + return "stopped"; + } + + if (!input.activeInstance.versionId) return "version-missing"; + if (input.probeError) return "data-error"; + if (input.probeLoading || !input.probe) return "checking"; + if (!input.probe.java) return "java-missing"; + if (!input.probe.versionInstalled) return "files-missing"; + + const memory = input.activeInstance.memoryOverride ?? { + min: input.config?.minMemory ?? 0, + max: input.config?.maxMemory ?? 0, + }; + if (memory.min <= 0 || memory.max < memory.min) return "memory-invalid"; + + return "ready"; +} diff --git a/packages/ui/src/lib/launcher-runtime.ts b/packages/ui/src/lib/launcher-runtime.ts index 99f95c6..7ff1cfe 100644 --- a/packages/ui/src/lib/launcher-runtime.ts +++ b/packages/ui/src/lib/launcher-runtime.ts @@ -9,9 +9,16 @@ export type { UnlistenFn }; export const launcherFixtureNames = [ "empty", + "no-account", + "no-instance", + "not-ready", "ready", "downloading", + "java-download-progress", + "launching", "running", + "stopped", + "failed", "error", "migration", ] as const; @@ -43,6 +50,15 @@ export function getLauncherFixtureTheme(): "light" | "dark" | null { return requested === "light" || requested === "dark" ? requested : null; } +export function getLauncherFixtureLocale(): "en" | "zh-CN" | null { + if (!import.meta.env.DEV || typeof window === "undefined") { + return null; + } + + const requested = new URLSearchParams(window.location.search).get("locale"); + return requested === "en" || requested === "zh-CN" ? requested : null; +} + export function isLauncherFixtureMode(): boolean { return getLauncherFixtureName() !== null; } diff --git a/packages/ui/src/locales/en.json b/packages/ui/src/locales/en.json index 358205f..1f9ebfc 100644 --- a/packages/ui/src/locales/en.json +++ b/packages/ui/src/locales/en.json @@ -41,7 +41,140 @@ "launcherActive": "Launcher Active", "title": "MINECRAFT", "javaEdition": "Java Edition", - "readyToLaunch": "> Ready to launch session." + "readyToLaunch": "> Ready to launch session.", + "commandCenter": "Launch command center", + "readinessProtocol": "Readiness protocol / 01", + "readinessTitle": "Launch readiness", + "readinessDescription": "Every launch-critical dependency, checked in one place.", + "checksCount": "{{ready}} / {{total}} ready", + "activeInstance": "Active instance", + "selectInstance": "Select an instance", + "activity": "Session activity", + "sessionLog": "Live launch log", + "failureDetails": "Failure details", + "noActivity": "No session activity yet. Launch events will appear here.", + "openLogs": "Open Minecraft logs", + "openLogsFailed": "Failed to open logs: {{error}}", + "activateFailed": "Failed to activate instance: {{error}}", + "actionFailed": "Action failed: {{error}}", + "manage": "Manage", + "folder": "Folder", + "downloadFiles": "{{completed}} of {{total}} files complete", + "check": { + "account": "Account", + "instance": "Instance", + "version": "Version", + "loader": "Loader", + "java": "Java", + "memory": "Memory", + "files": "Game files" + }, + "checkTone": { + "pass": "Ready", + "warn": "Needs attention", + "fail": "Blocked", + "pending": "Checking", + "neutral": "Not applicable" + }, + "value": { + "notSignedIn": "Not signed in", + "noInstance": "No instance selected", + "noVersion": "No version selected", + "vanilla": "Vanilla", + "notConfigured": "Not configured", + "checking": "Checking…", + "javaRequired": "Java {{version}} required", + "javaMissing": "No compatible runtime", + "downloadProgress": "Downloading · {{percentage}}%", + "filesReady": "Installed and verified", + "filesMissing": "Download required" + }, + "state": { + "checking": { + "eyebrow": "Checking system", + "title": "Confirming launch readiness", + "description": "DropOut is validating the account, instance, runtime, and game files.", + "action": "Checking…" + }, + "data-error": { + "eyebrow": "Launcher data unavailable", + "title": "Readiness check interrupted", + "description": "A launcher data source could not be read. Retry the checks before launching.", + "action": "Retry checks" + }, + "no-account": { + "eyebrow": "Account required", + "title": "Sign in to continue", + "description": "Add a Microsoft account or an offline profile before starting Minecraft.", + "action": "Sign in" + }, + "no-instance": { + "eyebrow": "Instance required", + "title": "Choose what to play", + "description": "Create a clean instance or import one from Prism Launcher, HMCL, or PCL.", + "action": "Create or import" + }, + "version-missing": { + "eyebrow": "Instance incomplete", + "title": "Select a Minecraft version", + "description": "The active instance needs a game version before its files and Java can be checked.", + "action": "Configure instance" + }, + "java-missing": { + "eyebrow": "Runtime blocked", + "title": "Compatible Java required", + "description": "DropOut could not resolve the Java runtime required by this Minecraft version.", + "action": "Repair Java" + }, + "files-missing": { + "eyebrow": "Preparation required", + "title": "Game files need attention", + "description": "Download or repair the active instance before the first launch.", + "action": "Download & prepare" + }, + "memory-invalid": { + "eyebrow": "Memory blocked", + "title": "Fix the memory allocation", + "description": "Maximum memory must be greater than or equal to the minimum allocation.", + "action": "Fix memory" + }, + "downloading": { + "eyebrow": "Files in transit", + "title": "Preparing {{instance}}", + "description": "The required files are downloading. Progress remains visible without leaving the home screen.", + "action": "Track download" + }, + "launching": { + "eyebrow": "Launch sequence active", + "title": "Starting {{instance}}", + "description": "DropOut is assembling the runtime, arguments, and game process.", + "action": "Launching…" + }, + "running": { + "eyebrow": "Session online", + "title": "{{instance}} is running", + "description": "Minecraft is active. You can stop the process here if it no longer responds.", + "action": "Stop game" + }, + "stopped": { + "eyebrow": "Session ended cleanly", + "title": "{{instance}} stopped", + "description": "The last session has closed. Logs remain available if you need them.", + "action": "Launch again" + }, + "failed": { + "eyebrow": "Launch failure", + "title": "{{instance}} needs recovery", + "description": "The launch or game process failed. Inspect the captured output before retrying.", + "action": "Inspect failure" + }, + "ready": { + "eyebrow": "All checks passed", + "title": "{{instance}} is ready", + "description": "Account, runtime, memory, and local game files are ready for launch.", + "action": "Launch" + } + } }, "bottomBar": { "noInstances": "No instances available", @@ -52,7 +185,12 @@ "downloadMonitor": { "title": "Downloads", "close": "Close downloads", - "completed": "Completed" + "completed": "Completed", + "javaRuntime": "Java runtime", + "gameFiles": "Minecraft files", + "progress": "Download progress", + "preparing": "Preparing download queue…", + "files": "{{completed}} / {{total}} files" }, "instances": { "title": "Instances", diff --git a/packages/ui/src/locales/zh-CN.json b/packages/ui/src/locales/zh-CN.json index 69308e8..eee1ff1 100644 --- a/packages/ui/src/locales/zh-CN.json +++ b/packages/ui/src/locales/zh-CN.json @@ -41,7 +41,140 @@ "launcherActive": "启动器就绪", "title": "MINECRAFT", "javaEdition": "Java 版", - "readyToLaunch": "> 准备启动游戏会话。" + "readyToLaunch": "> 准备启动游戏会话。", + "commandCenter": "启动指挥台", + "readinessProtocol": "就绪协议 / 01", + "readinessTitle": "启动就绪检查", + "readinessDescription": "在一个页面核对所有启动关键依赖。", + "checksCount": "{{ready}} / {{total}} 已就绪", + "activeInstance": "当前实例", + "selectInstance": "选择实例", + "activity": "会话活动", + "sessionLog": "实时启动日志", + "failureDetails": "失败详情", + "noActivity": "暂无会话活动,启动事件会显示在这里。", + "openLogs": "打开 Minecraft 日志", + "openLogsFailed": "打开日志失败:{{error}}", + "activateFailed": "切换实例失败:{{error}}", + "actionFailed": "操作失败:{{error}}", + "manage": "管理", + "folder": "文件夹", + "downloadFiles": "已完成 {{completed}} / {{total}} 个文件", + "check": { + "account": "账户", + "instance": "实例", + "version": "版本", + "loader": "加载器", + "java": "Java", + "memory": "内存", + "files": "游戏文件" + }, + "checkTone": { + "pass": "已就绪", + "warn": "需要处理", + "fail": "已阻塞", + "pending": "检查中", + "neutral": "不适用" + }, + "value": { + "notSignedIn": "尚未登录", + "noInstance": "未选择实例", + "noVersion": "未选择版本", + "vanilla": "原版", + "notConfigured": "未配置", + "checking": "检查中…", + "javaRequired": "需要 Java {{version}}", + "javaMissing": "没有兼容运行时", + "downloadProgress": "下载中 · {{percentage}}%", + "filesReady": "已安装并验证", + "filesMissing": "需要下载" + }, + "state": { + "checking": { + "eyebrow": "正在检查系统", + "title": "确认启动就绪状态", + "description": "DropOut 正在验证账户、实例、运行时和游戏文件。", + "action": "检查中…" + }, + "data-error": { + "eyebrow": "启动器数据不可用", + "title": "就绪检查被中断", + "description": "部分启动器数据无法读取,请重新检查后再启动。", + "action": "重新检查" + }, + "no-account": { + "eyebrow": "需要账户", + "title": "登录后继续", + "description": "启动 Minecraft 前,请添加 Microsoft 账户或离线档案。", + "action": "登录" + }, + "no-instance": { + "eyebrow": "需要实例", + "title": "选择要游玩的内容", + "description": "创建全新实例,或从 Prism Launcher、HMCL、PCL 导入。", + "action": "创建或导入" + }, + "version-missing": { + "eyebrow": "实例未完成", + "title": "选择 Minecraft 版本", + "description": "当前实例需要先选择游戏版本,才能检查文件和 Java。", + "action": "配置实例" + }, + "java-missing": { + "eyebrow": "运行时阻塞", + "title": "需要兼容的 Java", + "description": "DropOut 无法解析此 Minecraft 版本所需的 Java 运行时。", + "action": "修复 Java" + }, + "files-missing": { + "eyebrow": "需要准备", + "title": "游戏文件需要处理", + "description": "首次启动前,请下载或修复当前实例。", + "action": "下载并准备" + }, + "memory-invalid": { + "eyebrow": "内存配置阻塞", + "title": "修正内存分配", + "description": "最大内存必须大于或等于最小内存。", + "action": "修复内存" + }, + "downloading": { + "eyebrow": "文件传输中", + "title": "正在准备 {{instance}}", + "description": "所需文件正在下载,无需离开首页即可持续查看进度。", + "action": "查看下载" + }, + "launching": { + "eyebrow": "启动流程进行中", + "title": "正在启动 {{instance}}", + "description": "DropOut 正在装配运行时、启动参数和游戏进程。", + "action": "启动中…" + }, + "running": { + "eyebrow": "会话在线", + "title": "{{instance}} 正在运行", + "description": "Minecraft 进程已启动;如进程失去响应,可在此停止。", + "action": "停止游戏" + }, + "stopped": { + "eyebrow": "会话已正常结束", + "title": "{{instance}} 已停止", + "description": "上次会话已经关闭,需要时仍可查看日志。", + "action": "再次启动" + }, + "failed": { + "eyebrow": "启动失败", + "title": "{{instance}} 需要恢复", + "description": "启动或游戏进程失败,请先查看已捕获的输出再重试。", + "action": "查看失败详情" + }, + "ready": { + "eyebrow": "全部检查通过", + "title": "{{instance}} 已就绪", + "description": "账户、运行时、内存和本地游戏文件均已准备完成。", + "action": "启动" + } + } }, "bottomBar": { "noInstances": "暂无可用实例", @@ -52,7 +185,12 @@ "downloadMonitor": { "title": "下载任务", "close": "关闭下载任务", - "completed": "已完成" + "completed": "已完成", + "javaRuntime": "Java 运行时", + "gameFiles": "Minecraft 文件", + "progress": "下载进度", + "preparing": "正在准备下载队列…", + "files": "{{completed}} / {{total}} 个文件" }, "instances": { "title": "实例管理", diff --git a/packages/ui/src/models/auth.ts b/packages/ui/src/models/auth.ts index aa11713..35372fb 100644 --- a/packages/ui/src/models/auth.ts +++ b/packages/ui/src/models/auth.ts @@ -23,6 +23,8 @@ function getAuthErrorMessage(error: unknown): string { export interface AuthState { account: Account | null; accounts: AccountSummary[]; + status: "idle" | "loading" | "ready" | "error"; + error: string | null; loginMode: Account["type"] | null; deviceCode: DeviceCodeResponse | null; _pollingInterval: number | null; @@ -48,6 +50,8 @@ export interface AuthState { export const useAuthStore = create((set, get) => ({ account: null, accounts: [], + status: "idle", + error: null, loginMode: null, deviceCode: null, _pollingInterval: null, @@ -56,14 +60,16 @@ export const useAuthStore = create((set, get) => ({ _mutex: new Mutex(), init: async () => { + set({ status: "loading", error: null }); try { const [account, accounts] = await Promise.all([ getActiveAccount(), getAllAccounts(), ]); - set({ account, accounts }); + set({ account, accounts, status: "ready", error: null }); } catch (error) { console.error("Failed to initialize auth store:", error); + set({ status: "error", error: getAuthErrorMessage(error) }); } }, setLoginMode: (mode) => set({ loginMode: mode }), @@ -186,7 +192,7 @@ export const useAuthStore = create((set, get) => ({ try { const account = await loginOffline(trimmedUsername); - set({ account, loginMode: "offline" }); + set({ account, loginMode: "offline", status: "ready", error: null }); get().refreshAccounts(); } catch (error) { console.error("Failed to login offline:", error); @@ -196,7 +202,7 @@ export const useAuthStore = create((set, get) => ({ logout: async () => { try { await logout(); - set({ account: null }); + set({ account: null, status: "ready", error: null }); get().refreshAccounts(); } catch (error) { console.error("Failed to logout:", error); @@ -214,7 +220,7 @@ export const useAuthStore = create((set, get) => ({ switchAccount: async (uuid: string) => { try { const account = await switchAccountApi(uuid); - set({ account }); + set({ account, status: "ready", error: null }); get().refreshAccounts(); toast.success(`Switched to ${account.username}`); } catch (error) { diff --git a/packages/ui/src/models/downloads.ts b/packages/ui/src/models/downloads.ts new file mode 100644 index 0000000..edd0e34 --- /dev/null +++ b/packages/ui/src/models/downloads.ts @@ -0,0 +1,123 @@ +import { create } from "zustand"; +import { listen, type UnlistenFn } from "@/lib/launcher-runtime"; +import type { + JavaDownloadProgress, + ProgressEvent, +} from "@/types/bindings/downloader"; + +interface DownloadState { + active: boolean; + kind: "game" | "java" | null; + currentFile: string | null; + status: string | null; + completedFiles: number; + totalFiles: number; + downloadedBytes: number; + totalBytes: number; + percentage: number; + eventUnlisteners: UnlistenFn[]; + initialization: Promise | null; + + initialize: () => Promise; + reset: () => void; +} + +const initialProgress = { + active: false, + kind: null, + currentFile: null, + status: null, + completedFiles: 0, + totalFiles: 0, + downloadedBytes: 0, + totalBytes: 0, + percentage: 0, +} as const; + +function boundedPercentage(value: number) { + return Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0)); +} + +export const useDownloadStore = create((set, get) => ({ + ...initialProgress, + eventUnlisteners: [], + initialization: null, + + initialize: async () => { + const current = get(); + if (current.eventUnlisteners.length > 0) return; + if (current.initialization) return current.initialization; + + const initialization = Promise.all([ + listen("download-start", (event) => { + set({ + ...initialProgress, + active: true, + kind: "game", + status: "Preparing", + totalFiles: Number(event.payload), + }); + }), + listen("download-progress", (event) => { + const progress = event.payload; + const completedFiles = Number(progress.completedFiles); + const totalFiles = Number(progress.totalFiles); + const downloadedBytes = Number(progress.downloaded); + const totalBytes = Number(progress.total); + const currentFileFraction = + totalBytes > 0 ? downloadedBytes / totalBytes : 0; + const percentage = + totalFiles > 0 + ? ((completedFiles + currentFileFraction) / totalFiles) * 100 + : 0; + + set({ + active: true, + kind: "game", + currentFile: progress.file, + status: progress.status, + completedFiles, + totalFiles, + downloadedBytes, + totalBytes, + percentage: boundedPercentage(percentage), + }); + }), + listen("download-complete", () => { + set((state) => ({ + active: false, + status: "Completed", + completedFiles: state.totalFiles, + percentage: 100, + })); + }), + listen("java-download-progress", (event) => { + const progress = event.payload; + const completed = progress.status.toLowerCase() === "completed"; + set({ + active: !completed, + kind: "java", + currentFile: progress.fileName, + status: progress.status, + completedFiles: completed ? 1 : 0, + totalFiles: 1, + downloadedBytes: Number(progress.downloadedBytes), + totalBytes: Number(progress.totalBytes), + percentage: boundedPercentage(progress.percentage), + }); + }), + ]) + .then((eventUnlisteners) => { + set({ eventUnlisteners, initialization: null }); + }) + .catch((error) => { + console.error("Failed to initialize download listeners:", error); + set({ initialization: null }); + }); + + set({ initialization }); + return initialization; + }, + + reset: () => set(initialProgress), +})); diff --git a/packages/ui/src/models/game.ts b/packages/ui/src/models/game.ts index 46b454d..5c15383 100644 --- a/packages/ui/src/models/game.ts +++ b/packages/ui/src/models/game.ts @@ -7,66 +7,138 @@ import { import { listen, type UnlistenFn } from "@/lib/launcher-runtime"; import type { GameExitedEvent } from "@/types/bindings/core"; +export type GameLogSource = "launcher" | "stdout" | "stderr"; + +export interface GameLogEntry { + id: number; + source: GameLogSource; + message: string; +} + interface GameState { runningInstanceId: string | null; runningVersionId: string | null; launchingInstanceId: string | null; stoppingInstanceId: string | null; - lifecycleUnlisten: UnlistenFn | null; + lastExit: GameExitedEvent | null; + lastError: string | null; + lastErrorInstanceId: string | null; + recentLogs: GameLogEntry[]; + eventUnlisteners: UnlistenFn[]; + initialization: Promise | null; isGameRunning: boolean; + initialize: () => Promise; + appendLog: (source: GameLogSource, message: string) => void; + clearFailure: () => void; startGame: (instanceId: string, versionId: string) => Promise; stopGame: (instanceId?: string | null) => Promise; } +let nextLogId = 1; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + export const useGameStore = create((set, get) => ({ runningInstanceId: null, runningVersionId: null, launchingInstanceId: null, stoppingInstanceId: null, - lifecycleUnlisten: null, + lastExit: null, + lastError: null, + lastErrorInstanceId: null, + recentLogs: [], + eventUnlisteners: [], + initialization: null, get isGameRunning() { return get().runningInstanceId !== null; }, + appendLog: (source, message) => { + const normalized = message.trim(); + if (!normalized) return; + + set((state) => ({ + recentLogs: [ + ...state.recentLogs, + { id: nextLogId++, source, message: normalized }, + ].slice(-80), + })); + }, + + initialize: async () => { + const current = get(); + if (current.eventUnlisteners.length > 0) return; + if (current.initialization) return current.initialization; + + const initialization = Promise.all([ + listen("game-exited", (event) => { + const { instanceId, versionId, wasStopped, exitCode } = event.payload; + const failed = !wasStopped && exitCode !== 0; + + set({ + runningInstanceId: null, + runningVersionId: null, + launchingInstanceId: null, + stoppingInstanceId: null, + lastExit: event.payload, + lastError: failed + ? `Minecraft ${versionId} exited with code ${exitCode ?? "unknown"}` + : null, + lastErrorInstanceId: failed ? instanceId : null, + }); + + get().appendLog( + failed ? "stderr" : "launcher", + wasStopped + ? `Stopped Minecraft ${versionId} for ${instanceId}` + : `Minecraft ${versionId} exited with code ${exitCode ?? "unknown"}`, + ); + }), + listen("launcher-log", (event) => { + get().appendLog("launcher", event.payload); + }), + listen("game-stdout", (event) => { + get().appendLog("stdout", event.payload); + }), + listen("game-stderr", (event) => { + get().appendLog("stderr", event.payload); + }), + ]) + .then((eventUnlisteners) => { + set({ eventUnlisteners, initialization: null }); + }) + .catch((error) => { + console.error("Failed to initialize game event listeners:", error); + set({ initialization: null }); + }); + + set({ initialization }); + return initialization; + }, + + clearFailure: () => + set({ lastError: null, lastErrorInstanceId: null, lastExit: null }), + startGame: async (instanceId, versionId) => { - const { isGameRunning, lifecycleUnlisten } = get(); + const { isGameRunning, initialize } = get(); + await initialize(); if (isGameRunning) { toast.info("A game is already running"); return null; - } else { - lifecycleUnlisten?.(); } set({ launchingInstanceId: instanceId, + lastError: null, + lastErrorInstanceId: null, + lastExit: null, }); - toast.info(`Preparing to launch ${versionId}...`); - - const unlisten = await listen("game-exited", (event) => { - const { instanceId, versionId, wasStopped, exitCode } = 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 with code ${exitCode} for instance ${instanceId}`, - ); - } - }); - - set({ lifecycleUnlisten: unlisten }); + get().appendLog("launcher", `Preparing Minecraft ${versionId}`); try { const message = await startGameCommand(instanceId, versionId); @@ -75,12 +147,19 @@ export const useGameStore = create((set, get) => ({ runningInstanceId: instanceId, runningVersionId: versionId, }); + get().appendLog("launcher", message); toast.success(message); return message; - } catch (e) { - console.error(e); - set({ launchingInstanceId: null }); - toast.error(`Error: ${e}`); + } catch (error) { + const message = errorMessage(error); + console.error("Failed to start game:", error); + set({ + launchingInstanceId: null, + lastError: message, + lastErrorInstanceId: instanceId, + }); + get().appendLog("stderr", message); + toast.error(`Error: ${message}`); return null; } }, @@ -101,10 +180,15 @@ export const useGameStore = create((set, get) => ({ set({ stoppingInstanceId: runningInstanceId }); try { - return await stopGameCommand(); - } catch (e) { - console.error("Failed to stop game:", e); - toast.error(`Failed to stop game: ${e}`); + const message = await stopGameCommand(); + get().appendLog("launcher", message); + return message; + } catch (error) { + const message = errorMessage(error); + console.error("Failed to stop game:", error); + set({ lastError: message, lastErrorInstanceId: runningInstanceId }); + get().appendLog("stderr", message); + toast.error(`Failed to stop game: ${message}`); return null; } finally { set({ stoppingInstanceId: null }); diff --git a/packages/ui/src/models/instance.ts b/packages/ui/src/models/instance.ts index 555df80..b4fb0c2 100644 --- a/packages/ui/src/models/instance.ts +++ b/packages/ui/src/models/instance.ts @@ -19,6 +19,8 @@ import type { Instance } from "@/types"; interface InstanceState { instances: Instance[]; activeInstance: Instance | null; + status: "idle" | "loading" | "ready" | "error"; + error: string | null; refresh: () => Promise; create: (name: string) => Promise; @@ -38,8 +40,11 @@ interface InstanceState { export const useInstanceStore = create((set, get) => ({ instances: [], activeInstance: null, + status: "idle", + error: null, refresh: async () => { + set({ status: "loading", error: null }); try { const instances = await listInstances(); let activeInstance = await getActiveInstance(); @@ -56,9 +61,13 @@ export const useInstanceStore = create((set, get) => ({ activeInstance = instances[0]; } - set({ instances, activeInstance }); + set({ instances, activeInstance, status: "ready", error: null }); } catch (e) { console.error("Failed to load instances:", e); + set({ + status: "error", + error: e instanceof Error ? e.message : String(e), + }); toast.error(translate("store.instancesLoadFailed")); } }, diff --git a/packages/ui/src/models/settings.ts b/packages/ui/src/models/settings.ts index cdba396..81663e0 100644 --- a/packages/ui/src/models/settings.ts +++ b/packages/ui/src/models/settings.ts @@ -7,6 +7,8 @@ import type { LauncherConfig } from "@/types"; export interface SettingsState { config: LauncherConfig | null; configPath: string | null; + status: "idle" | "loading" | "ready" | "error"; + error: string | null; /* Theme getter */ get theme(): string; @@ -26,6 +28,8 @@ export interface SettingsState { export const useSettingsStore = create((set, get) => ({ config: null, configPath: null, + status: "idle", + error: null, get theme() { const { config } = get(); @@ -49,10 +53,16 @@ export const useSettingsStore = create((set, get) => ({ refresh: async () => { const { applyTheme } = get(); + set({ status: "loading", error: null }); try { const settings = await getSettings(); const path = await getConfigPath(); - set({ config: settings, configPath: path }); + set({ + config: settings, + configPath: path, + status: "ready", + error: null, + }); applyTheme(settings.theme); // Apply language from config if (settings.language) { @@ -60,6 +70,10 @@ export const useSettingsStore = create((set, get) => ({ } } catch (error) { console.error("Failed to load settings:", error); + set({ + status: "error", + error: error instanceof Error ? error.message : String(error), + }); toast.error("Failed to load settings"); } }, diff --git a/packages/ui/src/pages/home.tsx b/packages/ui/src/pages/home.tsx index e163799..22fcc69 100644 --- a/packages/ui/src/pages/home.tsx +++ b/packages/ui/src/pages/home.tsx @@ -1,103 +1,805 @@ -import { useState } from "react"; +import { + AlertTriangle, + Blocks, + Box, + Check, + ChevronRight, + CircleDashed, + Coffee, + Download, + FolderOpen, + HardDrive, + MemoryStick, + Play, + RefreshCw, + ScrollText, + Settings2, + Square, + UserRound, + Wrench, + X, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BottomBar } from "@/components/bottom-bar"; -import { useSaturnEffect } from "@/components/particle-background"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { getLaunchReadiness, installVersion, openFileExplorer } from "@/client"; +import { DownloadMonitor } from "@/components/download-monitor"; +import { LoginModal } from "@/components/login-modal"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { resolveHomeLaunchState } from "@/lib/launch-readiness"; +import { cn } from "@/lib/utils"; +import { useAuthStore } from "@/models/auth"; +import { useDownloadStore } from "@/models/downloads"; +import { useGameStore } from "@/models/game"; +import { useInstanceStore } from "@/models/instance"; +import { useSettingsStore } from "@/models/settings"; +import type { LaunchReadiness } from "@/types"; + +type CheckTone = "pass" | "warn" | "fail" | "pending" | "neutral"; + +interface ReadinessCheck { + id: string; + label: string; + value: string; + tone: CheckTone; + icon: React.ComponentType>; +} + +const toneStyles: Record = { + pass: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + warn: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + fail: "bg-red-500/10 text-red-700 dark:text-red-300", + pending: "bg-sky-500/10 text-sky-700 dark:text-sky-300", + neutral: "bg-muted text-muted-foreground", +}; + +function formatMemory(megabytes: number) { + if (megabytes >= 1024) { + const gigabytes = megabytes / 1024; + return `${Number.isInteger(gigabytes) ? gigabytes : gigabytes.toFixed(1)} GB`; + } + return `${megabytes} MB`; +} + +function CheckStatusIcon({ tone }: { tone: CheckTone }) { + if (tone === "pass") return
); } diff --git a/packages/ui/src/pages/index.tsx b/packages/ui/src/pages/index.tsx index 225953f..a412b4e 100644 --- a/packages/ui/src/pages/index.tsx +++ b/packages/ui/src/pages/index.tsx @@ -1,12 +1,12 @@ import { useEffect } from "react"; import { Outlet, useLocation } from "react-router"; -import { DownloadMonitor } from "@/components/download-monitor"; import { GuidedTour } from "@/components/guided-tour"; import { ParticleBackground } from "@/components/particle-background"; import { Sidebar } from "@/components/sidebar"; import { UpdateDialog, useUpdater } from "@/components/updater"; -import { getLauncherFixtureName } from "@/lib/launcher-runtime"; import { useAuthStore } from "@/models/auth"; +import { useDownloadStore } from "@/models/downloads"; +import { useGameStore } from "@/models/game"; import { useInstanceStore } from "@/models/instance"; import { useSettingsStore } from "@/models/settings"; @@ -15,19 +15,24 @@ export function IndexPage() { const settingsStore = useSettingsStore(); const instanceStore = useInstanceStore(); const updater = useUpdater(); + const initializeDownloads = useDownloadStore((state) => state.initialize); + const initializeGame = useGameStore((state) => state.initialize); const location = useLocation(); - const fixtureName = getLauncherFixtureName(); useEffect(() => { authStore.init(); settingsStore.refresh(); instanceStore.refresh(); + void initializeDownloads(); + void initializeGame(); updater.checkForUpdate(true); }, [ authStore.init, settingsStore.refresh, instanceStore.refresh, + initializeDownloads, + initializeGame, updater.checkForUpdate, ]); @@ -99,12 +104,6 @@ export function IndexPage() { onConfirm={updater.downloadAndInstall} /> - {import.meta.env.DEV && fixtureName === "downloading" && ( -
- -
- )} -
); diff --git a/packages/ui/src/types/bindings/core.ts b/packages/ui/src/types/bindings/core.ts index 70cf804..8971770 100644 --- a/packages/ui/src/types/bindings/core.ts +++ b/packages/ui/src/types/bindings/core.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JavaInstallation } from "./java/core"; /** * File information for instance file browser @@ -31,6 +32,18 @@ export type GithubRelease = { */ export type InstalledVersion = { id: string; type: string }; +/** + * The launch-critical checks used by the home command center. + * + * Java resolution intentionally reuses the same priority and compatibility + * rules as `start_game`, so the UI cannot report a false-ready state. + */ +export type LaunchReadiness = { + versionInstalled: boolean; + requiredJavaMajor: bigint | null; + java: JavaInstallation | null; +}; + /** * Migrate instance caches to shared global caches */ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/downloading-dark.png b/packages/ui/tests/__screenshots__/darwin/default-window/downloading-dark.png index 54dd8f9..94335f3 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/default-window/downloading-dark.png and b/packages/ui/tests/__screenshots__/darwin/default-window/downloading-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/downloading-light.png b/packages/ui/tests/__screenshots__/darwin/default-window/downloading-light.png index 70033a8..f03f630 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/default-window/downloading-light.png and b/packages/ui/tests/__screenshots__/darwin/default-window/downloading-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/empty-dark.png b/packages/ui/tests/__screenshots__/darwin/default-window/empty-dark.png deleted file mode 100644 index e62c2a8..0000000 Binary files a/packages/ui/tests/__screenshots__/darwin/default-window/empty-dark.png and /dev/null differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/empty-light.png b/packages/ui/tests/__screenshots__/darwin/default-window/empty-light.png deleted file mode 100644 index 2ceb26c..0000000 Binary files a/packages/ui/tests/__screenshots__/darwin/default-window/empty-light.png and /dev/null differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/error-dark.png b/packages/ui/tests/__screenshots__/darwin/default-window/error-dark.png index c780871..e532036 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/default-window/error-dark.png and b/packages/ui/tests/__screenshots__/darwin/default-window/error-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/error-light.png b/packages/ui/tests/__screenshots__/darwin/default-window/error-light.png index dd94ae1..5eec875 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/default-window/error-light.png and b/packages/ui/tests/__screenshots__/darwin/default-window/error-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/failed-dark.png b/packages/ui/tests/__screenshots__/darwin/default-window/failed-dark.png new file mode 100644 index 0000000..c13a3e2 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/failed-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/failed-light.png b/packages/ui/tests/__screenshots__/darwin/default-window/failed-light.png new file mode 100644 index 0000000..4660cdc Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/failed-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/launching-dark.png b/packages/ui/tests/__screenshots__/darwin/default-window/launching-dark.png new file mode 100644 index 0000000..43d2582 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/launching-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/launching-light.png b/packages/ui/tests/__screenshots__/darwin/default-window/launching-light.png new file mode 100644 index 0000000..98e10e1 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/launching-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/no-account-dark.png b/packages/ui/tests/__screenshots__/darwin/default-window/no-account-dark.png new file mode 100644 index 0000000..a480def Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/no-account-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/no-account-light.png b/packages/ui/tests/__screenshots__/darwin/default-window/no-account-light.png new file mode 100644 index 0000000..85af72b Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/no-account-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/no-instance-dark.png b/packages/ui/tests/__screenshots__/darwin/default-window/no-instance-dark.png new file mode 100644 index 0000000..3c004f9 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/no-instance-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/no-instance-light.png b/packages/ui/tests/__screenshots__/darwin/default-window/no-instance-light.png new file mode 100644 index 0000000..18852d5 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/no-instance-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/not-ready-dark.png b/packages/ui/tests/__screenshots__/darwin/default-window/not-ready-dark.png new file mode 100644 index 0000000..28efc99 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/not-ready-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/not-ready-light.png b/packages/ui/tests/__screenshots__/darwin/default-window/not-ready-light.png new file mode 100644 index 0000000..469ef7f Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/not-ready-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/ready-dark.png b/packages/ui/tests/__screenshots__/darwin/default-window/ready-dark.png index dd2da3f..a703188 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/default-window/ready-dark.png and b/packages/ui/tests/__screenshots__/darwin/default-window/ready-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/ready-light.png b/packages/ui/tests/__screenshots__/darwin/default-window/ready-light.png index a1ee28b..ec7aae4 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/default-window/ready-light.png and b/packages/ui/tests/__screenshots__/darwin/default-window/ready-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/running-dark.png b/packages/ui/tests/__screenshots__/darwin/default-window/running-dark.png index 18c91b9..4ee39b9 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/default-window/running-dark.png and b/packages/ui/tests/__screenshots__/darwin/default-window/running-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/running-light.png b/packages/ui/tests/__screenshots__/darwin/default-window/running-light.png index c4b4583..a8b816c 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/default-window/running-light.png and b/packages/ui/tests/__screenshots__/darwin/default-window/running-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/stopped-dark.png b/packages/ui/tests/__screenshots__/darwin/default-window/stopped-dark.png new file mode 100644 index 0000000..97085a4 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/stopped-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/default-window/stopped-light.png b/packages/ui/tests/__screenshots__/darwin/default-window/stopped-light.png new file mode 100644 index 0000000..73aba03 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/default-window/stopped-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/downloading-dark.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/downloading-dark.png index e78e33d..47af866 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/minimum-window/downloading-dark.png and b/packages/ui/tests/__screenshots__/darwin/minimum-window/downloading-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/downloading-light.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/downloading-light.png index 9ae9801..50c5ae1 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/minimum-window/downloading-light.png and b/packages/ui/tests/__screenshots__/darwin/minimum-window/downloading-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/empty-dark.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/empty-dark.png deleted file mode 100644 index 839fbb2..0000000 Binary files a/packages/ui/tests/__screenshots__/darwin/minimum-window/empty-dark.png and /dev/null differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/empty-light.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/empty-light.png deleted file mode 100644 index e0b8be8..0000000 Binary files a/packages/ui/tests/__screenshots__/darwin/minimum-window/empty-light.png and /dev/null differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/error-dark.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/error-dark.png index 9335bb7..5a16499 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/minimum-window/error-dark.png and b/packages/ui/tests/__screenshots__/darwin/minimum-window/error-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/error-light.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/error-light.png index a235fb6..265acce 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/minimum-window/error-light.png and b/packages/ui/tests/__screenshots__/darwin/minimum-window/error-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/failed-dark.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/failed-dark.png new file mode 100644 index 0000000..f203e27 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/failed-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/failed-light.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/failed-light.png new file mode 100644 index 0000000..fc9df09 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/failed-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/launching-dark.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/launching-dark.png new file mode 100644 index 0000000..1c55ab2 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/launching-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/launching-light.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/launching-light.png new file mode 100644 index 0000000..3120af9 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/launching-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/no-account-dark.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/no-account-dark.png new file mode 100644 index 0000000..9e4921b Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/no-account-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/no-account-light.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/no-account-light.png new file mode 100644 index 0000000..a9707a3 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/no-account-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/no-instance-dark.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/no-instance-dark.png new file mode 100644 index 0000000..bd1adaa Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/no-instance-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/no-instance-light.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/no-instance-light.png new file mode 100644 index 0000000..925440e Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/no-instance-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/not-ready-dark.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/not-ready-dark.png new file mode 100644 index 0000000..de638b8 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/not-ready-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/not-ready-light.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/not-ready-light.png new file mode 100644 index 0000000..b88c1e0 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/not-ready-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/ready-dark.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/ready-dark.png index a49a5b7..e3c7c19 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/minimum-window/ready-dark.png and b/packages/ui/tests/__screenshots__/darwin/minimum-window/ready-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/ready-light.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/ready-light.png index 0e3bc21..66b5023 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/minimum-window/ready-light.png and b/packages/ui/tests/__screenshots__/darwin/minimum-window/ready-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/running-dark.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/running-dark.png index e31793b..c4fff3c 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/minimum-window/running-dark.png and b/packages/ui/tests/__screenshots__/darwin/minimum-window/running-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/running-light.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/running-light.png index 10e0052..b46665d 100644 Binary files a/packages/ui/tests/__screenshots__/darwin/minimum-window/running-light.png and b/packages/ui/tests/__screenshots__/darwin/minimum-window/running-light.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/stopped-dark.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/stopped-dark.png new file mode 100644 index 0000000..94f336d Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/stopped-dark.png differ diff --git a/packages/ui/tests/__screenshots__/darwin/minimum-window/stopped-light.png b/packages/ui/tests/__screenshots__/darwin/minimum-window/stopped-light.png new file mode 100644 index 0000000..0ac6657 Binary files /dev/null and b/packages/ui/tests/__screenshots__/darwin/minimum-window/stopped-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/downloading-dark.png b/packages/ui/tests/__screenshots__/linux/default-window/downloading-dark.png index 3df6881..42720a1 100644 Binary files a/packages/ui/tests/__screenshots__/linux/default-window/downloading-dark.png and b/packages/ui/tests/__screenshots__/linux/default-window/downloading-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/downloading-light.png b/packages/ui/tests/__screenshots__/linux/default-window/downloading-light.png index 6c7ef69..492c8eb 100644 Binary files a/packages/ui/tests/__screenshots__/linux/default-window/downloading-light.png and b/packages/ui/tests/__screenshots__/linux/default-window/downloading-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/empty-dark.png b/packages/ui/tests/__screenshots__/linux/default-window/empty-dark.png deleted file mode 100644 index a5bba6e..0000000 Binary files a/packages/ui/tests/__screenshots__/linux/default-window/empty-dark.png and /dev/null differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/empty-light.png b/packages/ui/tests/__screenshots__/linux/default-window/empty-light.png deleted file mode 100644 index 14920a8..0000000 Binary files a/packages/ui/tests/__screenshots__/linux/default-window/empty-light.png and /dev/null differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/error-dark.png b/packages/ui/tests/__screenshots__/linux/default-window/error-dark.png index 987b0dc..00a526b 100644 Binary files a/packages/ui/tests/__screenshots__/linux/default-window/error-dark.png and b/packages/ui/tests/__screenshots__/linux/default-window/error-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/error-light.png b/packages/ui/tests/__screenshots__/linux/default-window/error-light.png index af5109e..0c9c0c0 100644 Binary files a/packages/ui/tests/__screenshots__/linux/default-window/error-light.png and b/packages/ui/tests/__screenshots__/linux/default-window/error-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/failed-dark.png b/packages/ui/tests/__screenshots__/linux/default-window/failed-dark.png new file mode 100644 index 0000000..0ab14d4 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/failed-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/failed-light.png b/packages/ui/tests/__screenshots__/linux/default-window/failed-light.png new file mode 100644 index 0000000..78649b4 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/failed-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/launching-dark.png b/packages/ui/tests/__screenshots__/linux/default-window/launching-dark.png new file mode 100644 index 0000000..3ae177a Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/launching-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/launching-light.png b/packages/ui/tests/__screenshots__/linux/default-window/launching-light.png new file mode 100644 index 0000000..8a009fd Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/launching-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/no-account-dark.png b/packages/ui/tests/__screenshots__/linux/default-window/no-account-dark.png new file mode 100644 index 0000000..40d86ab Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/no-account-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/no-account-light.png b/packages/ui/tests/__screenshots__/linux/default-window/no-account-light.png new file mode 100644 index 0000000..a83f552 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/no-account-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/no-instance-dark.png b/packages/ui/tests/__screenshots__/linux/default-window/no-instance-dark.png new file mode 100644 index 0000000..778751f Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/no-instance-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/no-instance-light.png b/packages/ui/tests/__screenshots__/linux/default-window/no-instance-light.png new file mode 100644 index 0000000..79d19d5 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/no-instance-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/not-ready-dark.png b/packages/ui/tests/__screenshots__/linux/default-window/not-ready-dark.png new file mode 100644 index 0000000..09d052c Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/not-ready-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/not-ready-light.png b/packages/ui/tests/__screenshots__/linux/default-window/not-ready-light.png new file mode 100644 index 0000000..91b2c60 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/not-ready-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/ready-dark.png b/packages/ui/tests/__screenshots__/linux/default-window/ready-dark.png index 7a2dfb9..95502a5 100644 Binary files a/packages/ui/tests/__screenshots__/linux/default-window/ready-dark.png and b/packages/ui/tests/__screenshots__/linux/default-window/ready-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/ready-light.png b/packages/ui/tests/__screenshots__/linux/default-window/ready-light.png index 7a6a59b..f121cac 100644 Binary files a/packages/ui/tests/__screenshots__/linux/default-window/ready-light.png and b/packages/ui/tests/__screenshots__/linux/default-window/ready-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/running-dark.png b/packages/ui/tests/__screenshots__/linux/default-window/running-dark.png index 1896684..0842323 100644 Binary files a/packages/ui/tests/__screenshots__/linux/default-window/running-dark.png and b/packages/ui/tests/__screenshots__/linux/default-window/running-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/running-light.png b/packages/ui/tests/__screenshots__/linux/default-window/running-light.png index e8f8fc2..e4204b2 100644 Binary files a/packages/ui/tests/__screenshots__/linux/default-window/running-light.png and b/packages/ui/tests/__screenshots__/linux/default-window/running-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/stopped-dark.png b/packages/ui/tests/__screenshots__/linux/default-window/stopped-dark.png new file mode 100644 index 0000000..1f33db8 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/stopped-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/default-window/stopped-light.png b/packages/ui/tests/__screenshots__/linux/default-window/stopped-light.png new file mode 100644 index 0000000..5088f29 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/default-window/stopped-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/downloading-dark.png b/packages/ui/tests/__screenshots__/linux/minimum-window/downloading-dark.png index e2b8bbc..d17138c 100644 Binary files a/packages/ui/tests/__screenshots__/linux/minimum-window/downloading-dark.png and b/packages/ui/tests/__screenshots__/linux/minimum-window/downloading-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/downloading-light.png b/packages/ui/tests/__screenshots__/linux/minimum-window/downloading-light.png index 38f515a..bee6d15 100644 Binary files a/packages/ui/tests/__screenshots__/linux/minimum-window/downloading-light.png and b/packages/ui/tests/__screenshots__/linux/minimum-window/downloading-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/empty-dark.png b/packages/ui/tests/__screenshots__/linux/minimum-window/empty-dark.png deleted file mode 100644 index b10ab04..0000000 Binary files a/packages/ui/tests/__screenshots__/linux/minimum-window/empty-dark.png and /dev/null differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/empty-light.png b/packages/ui/tests/__screenshots__/linux/minimum-window/empty-light.png deleted file mode 100644 index 3c48f72..0000000 Binary files a/packages/ui/tests/__screenshots__/linux/minimum-window/empty-light.png and /dev/null differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/error-dark.png b/packages/ui/tests/__screenshots__/linux/minimum-window/error-dark.png index ec0f3bc..65bd029 100644 Binary files a/packages/ui/tests/__screenshots__/linux/minimum-window/error-dark.png and b/packages/ui/tests/__screenshots__/linux/minimum-window/error-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/error-light.png b/packages/ui/tests/__screenshots__/linux/minimum-window/error-light.png index 89a267f..c311afc 100644 Binary files a/packages/ui/tests/__screenshots__/linux/minimum-window/error-light.png and b/packages/ui/tests/__screenshots__/linux/minimum-window/error-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/failed-dark.png b/packages/ui/tests/__screenshots__/linux/minimum-window/failed-dark.png new file mode 100644 index 0000000..498de92 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/failed-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/failed-light.png b/packages/ui/tests/__screenshots__/linux/minimum-window/failed-light.png new file mode 100644 index 0000000..8202258 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/failed-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/launching-dark.png b/packages/ui/tests/__screenshots__/linux/minimum-window/launching-dark.png new file mode 100644 index 0000000..da41480 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/launching-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/launching-light.png b/packages/ui/tests/__screenshots__/linux/minimum-window/launching-light.png new file mode 100644 index 0000000..55a2150 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/launching-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/no-account-dark.png b/packages/ui/tests/__screenshots__/linux/minimum-window/no-account-dark.png new file mode 100644 index 0000000..36f288d Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/no-account-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/no-account-light.png b/packages/ui/tests/__screenshots__/linux/minimum-window/no-account-light.png new file mode 100644 index 0000000..c0eefbc Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/no-account-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/no-instance-dark.png b/packages/ui/tests/__screenshots__/linux/minimum-window/no-instance-dark.png new file mode 100644 index 0000000..e6b2ab9 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/no-instance-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/no-instance-light.png b/packages/ui/tests/__screenshots__/linux/minimum-window/no-instance-light.png new file mode 100644 index 0000000..127e41d Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/no-instance-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/not-ready-dark.png b/packages/ui/tests/__screenshots__/linux/minimum-window/not-ready-dark.png new file mode 100644 index 0000000..41cb64b Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/not-ready-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/not-ready-light.png b/packages/ui/tests/__screenshots__/linux/minimum-window/not-ready-light.png new file mode 100644 index 0000000..b0b7f8f Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/not-ready-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/ready-dark.png b/packages/ui/tests/__screenshots__/linux/minimum-window/ready-dark.png index 73b1fa9..08e1abe 100644 Binary files a/packages/ui/tests/__screenshots__/linux/minimum-window/ready-dark.png and b/packages/ui/tests/__screenshots__/linux/minimum-window/ready-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/ready-light.png b/packages/ui/tests/__screenshots__/linux/minimum-window/ready-light.png index 610af32..28ae0f4 100644 Binary files a/packages/ui/tests/__screenshots__/linux/minimum-window/ready-light.png and b/packages/ui/tests/__screenshots__/linux/minimum-window/ready-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/running-dark.png b/packages/ui/tests/__screenshots__/linux/minimum-window/running-dark.png index b50c0a8..fc042fd 100644 Binary files a/packages/ui/tests/__screenshots__/linux/minimum-window/running-dark.png and b/packages/ui/tests/__screenshots__/linux/minimum-window/running-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/running-light.png b/packages/ui/tests/__screenshots__/linux/minimum-window/running-light.png index 4e98e4f..feb4c2e 100644 Binary files a/packages/ui/tests/__screenshots__/linux/minimum-window/running-light.png and b/packages/ui/tests/__screenshots__/linux/minimum-window/running-light.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/stopped-dark.png b/packages/ui/tests/__screenshots__/linux/minimum-window/stopped-dark.png new file mode 100644 index 0000000..72a6edc Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/stopped-dark.png differ diff --git a/packages/ui/tests/__screenshots__/linux/minimum-window/stopped-light.png b/packages/ui/tests/__screenshots__/linux/minimum-window/stopped-light.png new file mode 100644 index 0000000..fd4ba91 Binary files /dev/null and b/packages/ui/tests/__screenshots__/linux/minimum-window/stopped-light.png differ diff --git a/packages/ui/tests/launcher-fixtures.spec.ts b/packages/ui/tests/launcher-fixtures.spec.ts index 581d30f..cd1ac04 100644 --- a/packages/ui/tests/launcher-fixtures.spec.ts +++ b/packages/ui/tests/launcher-fixtures.spec.ts @@ -9,16 +9,25 @@ const screenshotTolerance = { } as const; const visualScenarios = [ - { fixture: "empty", route: "/" }, - { fixture: "ready", route: "/" }, - { fixture: "downloading", route: "/" }, - { fixture: "running", route: "/" }, - { fixture: "error", route: "/" }, - { fixture: "migration", route: "/instances/import" }, + { fixture: "no-account", route: "/", heading: "Sign in to continue" }, + { fixture: "no-instance", route: "/", heading: "Choose what to play" }, + { fixture: "not-ready", route: "/", heading: "Compatible Java required" }, + { fixture: "ready", route: "/", heading: "Copper Valley is ready" }, + { fixture: "downloading", route: "/", heading: "Preparing Copper Valley" }, + { fixture: "launching", route: "/", heading: "Starting Copper Valley" }, + { fixture: "running", route: "/", heading: "Copper Valley is running" }, + { fixture: "stopped", route: "/", heading: "Copper Valley stopped" }, + { fixture: "failed", route: "/", heading: "Copper Valley needs recovery" }, + { fixture: "error", route: "/", heading: "Readiness check interrupted" }, + { + fixture: "migration", + route: "/instances/import", + heading: "Import from another launcher", + }, ] as const; for (const theme of themes) { - for (const { fixture, route } of visualScenarios) { + for (const { fixture, heading, route } of visualScenarios) { test(`${fixture} launcher fixture is deterministic in ${theme} mode`, async ({ page, }) => { @@ -35,15 +44,10 @@ for (const theme of themes) { theme, ); + await expect(page.getByRole("heading", { name: heading })).toBeVisible(); + if (fixture === "migration") { - await expect( - page.getByRole("heading", { name: "Import from another launcher" }), - ).toBeVisible(); await expect(page.getByText("MultiMC compatible")).toBeVisible(); - } else { - await expect( - page.getByRole("heading", { name: "MINECRAFT" }), - ).toBeVisible(); } if (fixture === "downloading") { @@ -52,9 +56,7 @@ for (const theme of themes) { await page.evaluate(() => document.fonts.ready); - if (fixture !== "error") { - expect(pageErrors).toEqual([]); - } + expect(pageErrors).toEqual([]); await expect(page).toHaveScreenshot( `${fixture}-${theme}.png`, @@ -65,7 +67,7 @@ for (const theme of themes) { } const accessibleRoutes = [ - { fixture: "ready", route: "/", heading: "MINECRAFT" }, + { fixture: "ready", route: "/", heading: "Copper Valley is ready" }, { fixture: "ready", route: "/instances", heading: "Instances" }, { fixture: "ready", @@ -156,7 +158,9 @@ for (const theme of themes) { test("primary launcher flow is keyboard operable", async ({ page }) => { await page.goto("/?fixture=ready&theme=dark#/"); - await expect(page.getByRole("heading", { name: "MINECRAFT" })).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Copper Valley is ready" }), + ).toBeVisible(); const tabTo = async (target: ReturnType) => { for (let index = 0; index < 10; index += 1) { @@ -186,3 +190,83 @@ test("primary launcher flow is keyboard operable", async ({ page }) => { await page.keyboard.press("Enter"); await expect(page.getByRole("heading", { name: "Instances" })).toBeVisible(); }); + +test("home primary action follows launcher recovery state", async ({ + page, +}) => { + await page.goto("/?fixture=no-account&theme=dark#/"); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page.getByRole("heading", { name: "Login" })).toBeVisible(); + + await page.goto("/?fixture=no-instance&theme=dark#/"); + await page.getByRole("button", { name: "Create or import" }).click(); + await expect(page.getByRole("heading", { name: "Instances" })).toBeVisible(); + + await page.goto("/?fixture=not-ready&theme=dark#/"); + await page.getByRole("button", { name: "Repair Java" }).click(); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); +}); + +test("home launch and stop actions follow the game lifecycle", async ({ + page, +}) => { + await page.goto("/?fixture=ready&theme=dark#/"); + await page.getByRole("button", { name: "Launch", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Copper Valley is running" }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Stop game" }).click(); + await expect( + page.getByRole("heading", { name: "Copper Valley stopped" }), + ).toBeVisible(); +}); + +test("download and failure states expose actionable diagnostics", async ({ + page, +}) => { + await page.goto("/?fixture=downloading&theme=dark#/"); + await expect( + page.getByRole("progressbar", { name: "Download progress" }), + ).toHaveAttribute("aria-valuenow", "65"); + await page.getByRole("button", { name: "Track download" }).click(); + await expect(page.locator("#download-monitor")).toBeFocused(); + + await page.goto("/?fixture=failed&theme=dark#/"); + await page.getByRole("button", { name: "Inspect failure" }).click(); + await expect(page.getByText("Process exited with code 1")).toBeVisible(); + await expect( + page.locator('section[aria-labelledby="activity-title"]'), + ).toBeFocused(); +}); + +test("Java runtime download progress stays bounded and accessible", async ({ + page, +}) => { + await page.goto("/?fixture=java-download-progress&theme=dark#/"); + + const monitor = page.locator("#download-monitor"); + await expect(monitor.getByText("Java runtime")).toBeVisible(); + await expect(monitor.getByText("100%", { exact: true })).toBeVisible(); + await expect( + monitor.getByRole("progressbar", { name: "Download progress" }), + ).toHaveAttribute("aria-valuenow", "100"); + await expect(monitor).toContainText("1.5 GB / 2.0 GB"); + + await page.getByRole("button", { name: "Track download" }).click(); + await expect(monitor).toBeFocused(); +}); + +test("home supports Chinese and reduced motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/?fixture=ready&theme=dark&locale=zh-CN#/"); + + await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN"); + await expect( + page.getByRole("heading", { name: "Copper Valley 已就绪" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "启动", exact: true }), + ).toBeVisible(); + await expectAccessibilitySmoke(page); +}); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f83745c..9ebbdf2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2208,6 +2208,19 @@ struct VersionMetadata { is_installed: bool, } +/// The launch-critical checks used by the home command center. +/// +/// Java resolution intentionally reuses the same priority and compatibility +/// rules as `start_game`, so the UI cannot report a false-ready state. +#[derive(serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "core.ts")] +struct LaunchReadiness { + version_installed: bool, + required_java_major: Option, + java: Option, +} + /// Delete a version (remove version directory) #[tauri::command] #[dropout_macros::api] @@ -2332,6 +2345,60 @@ async fn get_version_metadata( Ok(metadata) } +/// Resolve the files and Java runtime required by an instance before launch. +#[tauri::command] +#[dropout_macros::api] +async fn get_launch_readiness( + window: Window, + config_state: State<'_, core::config::ConfigState>, + instance_state: State<'_, core::instance::InstanceState>, + instance_id: String, + version_id: String, +) -> Result { + let config = config_state.config.lock().unwrap().clone(); + let instance = instance_state + .get_instance(&instance_id) + .ok_or_else(|| format!("Instance {} not found", instance_id))?; + let resolved_paths = + instance_state.resolve_paths(&instance_id, &config, &window.app_handle())?; + let minecraft_version = resolve_minecraft_version(&version_id); + + let version_json = resolved_paths + .metadata_versions + .join(&version_id) + .join(format!("{}.json", version_id)); + let client_jar = resolved_paths + .version_cache + .join(&minecraft_version) + .join(format!("{}.jar", minecraft_version)); + let loaded_version = if version_json.exists() { + core::manifest::load_version(&resolved_paths.root, &version_id) + .await + .ok() + } else { + None + }; + let version_installed = + version_json.exists() && client_jar.exists() && loaded_version.is_some(); + let required_java_major = + loaded_version.and_then(|version| version.java_version.map(|java| java.major_version)); + + let java = resolve_java_path( + &config, + &instance, + required_java_major, + &window.app_handle(), + ) + .await + .ok(); + + Ok(LaunchReadiness { + version_installed, + required_java_major, + java, + }) +} + /// Installed version info #[derive(serde::Serialize, TS)] #[serde(rename_all = "camelCase")] @@ -3479,6 +3546,7 @@ fn main() { list_installed_versions, get_version_java_version, get_version_metadata, + get_launch_readiness, delete_version, login_offline, get_active_account, diff --git a/src-tauri/src/utils/api.rs b/src-tauri/src/utils/api.rs index fce7978..3b85539 100644 --- a/src-tauri/src/utils/api.rs +++ b/src-tauri/src/utils/api.rs @@ -27,7 +27,9 @@ pub fn export_api_bindings(import_from: &str, export_to: &str) { sort_api_infos(&mut api_infos); let mut ts_lines = Vec::new(); - ts_lines.push(r#"import { invoke } from "@tauri-apps/api/core""#.to_string()); + // Keep generated clients behind the runtime adapter so the same API works + // in Tauri production builds and deterministic browser fixtures. + ts_lines.push(r#"import { invoke } from "@/lib/launcher-runtime""#.to_string()); let mut import_types: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); let mut ts_funcs = Vec::new();