Add crash monitor, OOBE flow & factory-reset

Introduce crash monitoring and an OOBE onboarding flow. Adds a crash logger (koring-crash.log), crash-monitor handler/window, preload-crash bridge, crash UI (crash.html, src/crash.tsx, pages/crash) and a debug page. Main process now sets up crash listeners, startup checks (first-launch, .minecraft), and a config reset/factory-reset that removes Koring.yml, koring-auth.json and background cache and can relaunch the app. Exposes config preloading to renderer, adds OOBE pages/layout, confirm dialog store/UI, trims Silk renderer settings, bumps version to 1.0.1 and adds motion dependency, and registers crash.html in Vite. Various related type and config updates included.
This commit is contained in:
2026-07-12 01:24:31 +08:00
parent 2b19144352
commit 0a2b8bd018
50 changed files with 1867 additions and 296 deletions
+23 -1
View File
@@ -7,6 +7,7 @@ import { syncThemeFromConfig } from "./stores/themeStore";
import { syncA11yFromConfig } from "./stores/a11yStore";
import { syncBackgroundFromConfig } from "./stores/backgroundStore";
import { useAuthStore } from "./stores/authStore";
import type { AppConfig } from "./api/config";
import { Home } from "./pages/home";
import { Store } from "./pages/store";
import { Today } from "./pages/today";
@@ -19,7 +20,13 @@ import { SplashDebug } from "./pages/debug/splash-debug";
import { DisplayDebug } from "./pages/debug/display-debug";
import { VersionCardDebug } from "./pages/debug/version-card-debug";
import { TaskDebug } from "./pages/debug/task-debug";
import { CrashDebug } from "./pages/debug/crash-debug";
import { Oobe } from "./pages/oobe";
import { OobeLanguage } from "./pages/oobe/step-language";
import { OobeAgreement } from "./pages/oobe/step-agreement";
import { OobeVersion } from "./pages/oobe/step-version";
import { OobeBetaTest } from "./pages/oobe/step-beta-test";
import { OobeFinish } from "./pages/oobe/step-finish";
import { OobeAboutInfo } from "./pages/oobe/about-info";
const pageMap = {
@@ -31,12 +38,18 @@ const pageMap = {
gallery: Gallery,
"task-queue": TaskQueue,
oobe: Oobe,
"oobe/language": OobeLanguage,
"oobe/agreement": OobeAgreement,
"oobe/version": OobeVersion,
"oobe/beta-test": OobeBetaTest,
"oobe/finish": OobeFinish,
"oobe/about-info": OobeAboutInfo,
debug: Debug,
"debug-splash": SplashDebug,
"debug-display": DisplayDebug,
"debug-version-card": VersionCardDebug,
"debug-task": TaskDebug,
"debug-crash": CrashDebug,
} as const;
function App() {
@@ -45,12 +58,21 @@ function App() {
const Page = pageMap[current];
useEffect(() => {
useConfigStore.getState().init().then(() => {
// Listen for preloaded config from main process
const unsub = window.electronAPI?.onConfigPreload((data) => {
const { config, isFirstLaunch } = data;
useConfigStore.getState().applyPreloaded(config as AppConfig, isFirstLaunch);
syncThemeFromConfig();
syncA11yFromConfig();
syncBackgroundFromConfig();
useAuthStore.getState().initFromRegistry();
// Navigate to OOBE on first launch or if oobe not completed
if (isFirstLaunch || config.oobe) {
useRouteStore.getState().navigate("oobe");
}
});
return () => { unsub?.(); };
}, []);
return (
+1
View File
@@ -62,6 +62,7 @@ export interface NetworkConfig {
export interface AppConfig {
version: number;
oobe: boolean;
theme: ThemeConfig;
a11y: A11yConfig;
background: BackgroundConfig;
-17
View File
@@ -1,17 +0,0 @@
import { useEffect } from "react";
import { toast } from "sonner";
import { BUILD_MODE } from "@/lib/mode";
import { VERSION } from "@/lib/version";
export function BetaWarning() {
useEffect(() => {
if (BUILD_MODE === "beta" || BUILD_MODE === "dev") {
toast.warning(`当前为 v${VERSION} BETA 测试版,不代表最终品质。`, {
duration: Infinity,
dismissible: true,
});
}
}, []);
return null;
}
-47
View File
@@ -1,47 +0,0 @@
import { useState, useEffect } from "react";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { AlertTriangle } from "lucide-react";
const enabled = import.meta.env.VITE_START_POP === "true";
const title = import.meta.env.VITE_START_POP_TITLE ?? "";
const info = import.meta.env.VITE_START_POP_INFO ?? "";
const buttonText = import.meta.env.VITE_START_POP_BOUTTON ?? "确定";
export function StartupPopup() {
const [open, setOpen] = useState(false);
useEffect(() => {
if (enabled) {
setOpen(true);
}
}, []);
if (!enabled) return null;
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<div className="mb-2 inline-flex size-10 items-center justify-center rounded-md bg-amber-500/10 sm:group-data-[size=default]/alert-dialog-content:row-span-2">
<AlertTriangle className="size-5 text-amber-500" />
</div>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{info}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setOpen(false)}>
{buttonText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+3 -2
View File
@@ -124,9 +124,10 @@ const Silk = ({ speed = 5, scale = 1, color = "#7B7481", noiseIntensity = 1.5, r
return (
<Canvas
dpr={[1, 2]}
dpr={[1, 1.5]}
frameloop="demand"
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", background: "black" }}
gl={{ antialias: true, alpha: false }}
gl={{ antialias: false, alpha: false }}
>
<SilkPlane ref={meshRef} uniforms={uniforms} />
</Canvas>
+70
View File
@@ -0,0 +1,70 @@
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
const initialProps = {
pathLength: 0,
opacity: 0,
} as const
const animateProps = {
pathLength: 1,
opacity: 1,
} as const
type Props = React.ComponentProps<typeof motion.svg> & {
speed?: number
onAnimationComplete?: () => void
}
function AppleHelloEnglishEffect({ className, speed = 1, onAnimationComplete, ...props }: Props) {
const calc = (x: number) => x * speed
return (
<motion.svg
className={cn("h-20", className)}
exit={{ opacity: 0 }}
fill="none"
initial={{ opacity: 1 }}
stroke="currentColor"
strokeWidth="14.8883"
transition={{ duration: 0.5 }}
viewBox="0 0 638 200"
xmlns="http://www.w3.org/2000/svg"
{...(props as any)}
>
<title>hello</title>
{/* h1 */}
<motion.path
animate={animateProps}
d="M8.69214 166.553C36.2393 151.239 61.3409 131.548 89.8191 98.0295C109.203 75.1488 119.625 49.0228 120.122 31.0026C120.37 17.6036 113.836 7.43883 101.759 7.43883C88.3598 7.43883 79.9231 17.6036 74.7122 40.9363C69.005 66.5793 64.7866 96.0036 54.1166 190.356"
initial={initialProps}
style={{ strokeLinecap: "round" }}
transition={{
duration: calc(0.8),
ease: "easeInOut",
opacity: { duration: 0.4 },
}}
/>
{/* h2, ello */}
<motion.path
animate={animateProps}
d="M55.1624 181.135C60.6251 133.114 81.4118 98.0479 107.963 98.0479C123.844 98.0479 133.937 110.703 131.071 128.817C129.457 139.487 127.587 150.405 125.408 163.06C122.869 178.941 130.128 191.348 152.122 191.348C184.197 191.348 219.189 173.523 237.097 145.915C243.198 136.509 245.68 128.073 245.928 119.884C246.176 104.996 237.739 93.8296 222.851 93.8296C203.992 93.8296 189.6 115.17 189.6 142.465C189.6 171.745 205.481 192.341 239.208 192.341C285.066 192.341 335.86 137.292 359.199 75.8585C365.788 58.513 368.26 42.4065 368.26 31.1512C368.26 17.8057 364.042 7.55823 352.131 7.55823C340.469 7.55823 332.777 16.6141 325.829 30.9129C317.688 47.4967 311.667 71.4162 309.203 98.4549C303 166.301 316.896 191.348 349.936 191.348C390 191.348 434.542 135.534 457.286 75.6686C463.803 58.513 466.275 42.4065 466.275 31.1512C466.275 17.8057 462.057 7.55823 450.146 7.55823C438.484 7.55823 430.792 16.6141 423.844 30.9129C415.703 47.4967 409.682 71.4162 407.218 98.4549C401.015 166.301 414.911 191.348 444.416 191.348C473.874 191.348 489.877 165.67 499.471 138.402C508.955 111.447 520.618 94.8221 544.935 94.8221C565.035 94.8221 580.916 109.71 580.916 137.75C580.916 168.768 560.792 192.093 535.362 192.341C512.984 192.589 498.285 174.475 499.774 147.179C501.511 116.907 519.873 94.8221 543.943 94.8221C557.839 94.8221 569.51 100.999 578.682 107.725C603.549 125.866 622.709 114.656 630.047 96.7186"
initial={initialProps}
onAnimationComplete={onAnimationComplete}
style={{ strokeLinecap: "round" }}
transition={{
duration: calc(2.8),
ease: "easeInOut",
delay: calc(0.7),
opacity: { duration: 0.7, delay: calc(0.7) },
}}
/>
</motion.svg>
)
}
export { AppleHelloEnglishEffect}
+47
View File
@@ -0,0 +1,47 @@
import { useState, useEffect } from "react";
import { useConfirmDialogStore } from "@/stores/confirmDialogStore";
export function ConfirmDialog() {
const { open, title, description, confirmLabel, countdown: initCountdown, showCountdown, onConfirm, closeDialog } =
useConfirmDialogStore();
const [countdown, setCountdown] = useState(initCountdown);
useEffect(() => {
if (!open) return;
setCountdown(initCountdown);
}, [open, initCountdown]);
useEffect(() => {
if (!open || !showCountdown || countdown <= 0) return;
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [open, showCountdown, countdown]);
if (!open) return null;
const canConfirm = !showCountdown || countdown <= 0;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50">
<div className="glass-card w-[380px] p-6 space-y-4">
<h3 className="text-base font-bold text-foreground">{title}</h3>
<p className="text-[13px] text-muted-foreground leading-relaxed">{description}</p>
<div className="flex justify-end gap-2 pt-2">
<button
onClick={closeDialog}
className="px-4 py-1.5 rounded-md text-[13px] font-medium bg-foreground/[0.06] hover:bg-foreground/[0.12] text-foreground/60 hover:text-foreground transition-colors"
>
</button>
<button
onClick={() => { closeDialog(); onConfirm(); }}
disabled={!canConfirm}
className="px-4 py-1.5 rounded-md text-[13px] font-medium bg-red-500/15 text-red-600 dark:text-red-400 hover:bg-red-500/25 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{showCountdown && countdown > 0 ? `${confirmLabel} (${countdown}s)` : confirmLabel}
</button>
</div>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { CrashPage } from "./pages/crash";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<CrashPage />
</React.StrictMode>,
);
+3 -7
View File
@@ -1,8 +1,7 @@
import { type ReactNode } from "react";
import { BackgroundLayer } from "@/components/background/BackgroundLayer";
import { SystemLayer } from "@/components/system/SystemLayer";
import { StartupPopup } from "@/components/StartupPopup";
import { BetaWarning } from "@/components/BetaWarning";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Toaster } from "sonner";
import { useA11yStore } from "@/stores/a11yStore";
import clsx from "clsx";
@@ -52,11 +51,8 @@ export function RootLayout({
showClose={showClose}
/>
{/* Startup popup — only when VITE_START_POP=true */}
<StartupPopup />
{/* Beta warning toast */}
<BetaWarning />
{/* Global confirm dialog */}
<ConfirmDialog />
{/* Sonner toaster */}
<Toaster
+144
View File
@@ -0,0 +1,144 @@
import { useState, useEffect } from "react";
import { VERSION } from "@/lib/version";
type CrashInfo = {
type: string;
message: string;
timestamp: string;
};
export function CrashPage() {
const [crashInfo, setCrashInfo] = useState<CrashInfo | null>(null);
const [copying, setCopying] = useState(false);
const [copyDone, setCopyDone] = useState(false);
useEffect(() => {
const unsub = window.electronAPI?.on("crash:show", (...args: unknown[]) => {
const info = args[0] as CrashInfo;
setCrashInfo(info);
});
return () => { unsub?.(); };
}, []);
const handleClose = () => {
window.electronAPI?.close();
};
const handleCopyLog = async () => {
setCopying(true);
try {
const log = await window.electronAPI?.invoke("crash:readLog") as string;
if (log) {
await navigator.clipboard.writeText(log);
setCopyDone(true);
setTimeout(() => setCopyDone(false), 2000);
}
} catch {
} finally {
setCopying(false);
}
};
const handleFactoryReset = async () => {
await window.electronAPI?.invoke("crash:factoryReset");
};
const handleRestart = async () => {
await window.electronAPI?.invoke("crash:restart");
};
return (
<div className="w-screen h-screen flex flex-col overflow-hidden bg-background text-foreground select-none">
{/* 顶栏 — 只有关闭按钮 */}
<div
className="h-[40px] flex items-center shrink-0 relative z-10"
style={{
WebkitAppRegion: "drag" as React.CSSProperties["WebkitAppRegion"],
background: "var(--titlebar-bg, rgba(255,255,255,0.03))",
backdropFilter: "blur(3px)",
WebkitBackdropFilter: "blur(3px)",
borderBottom: "1px solid var(--titlebar-border, rgba(255,255,255,0.06))",
}}
>
<div className="flex items-center pl-3 shrink-0" style={{ WebkitAppRegion: "no-drag" as React.CSSProperties["WebkitAppRegion"] }}>
<span className="text-sm font-semibold tracking-wide text-foreground/80">
Koring Launcher
</span>
</div>
<div className="flex items-center shrink-0 justify-end ml-auto pr-2" style={{ WebkitAppRegion: "no-drag" as React.CSSProperties["WebkitAppRegion"] }}>
<button
type="button"
onClick={handleClose}
className="flex items-center justify-center w-[25px] h-[25px] rounded transition-colors cursor-default hover:bg-red-500 text-black/70 dark:text-white/70 hover:text-white"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="2" y1="2" x2="10" y2="10" />
<line x1="10" y1="2" x2="2" y2="10" />
</svg>
</button>
</div>
</div>
{/* 崩溃内容 */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="w-full max-w-[520px] text-center space-y-6">
{/* 图标 */}
<div className="flex justify-center">
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-red-500">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
</div>
{/* 标题 */}
<div className="space-y-2">
<h1 className="text-xl font-bold text-foreground">
</h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
{/* 说明文字 */}
<p className="text-sm text-muted-foreground leading-relaxed text-left">
使
</p>
{/* 按钮组 */}
<div className="flex items-center justify-center gap-3 pt-2">
<button
type="button"
onClick={handleCopyLog}
disabled={copying}
className="px-4 py-2 text-sm font-medium rounded-lg border border-border bg-background hover:bg-muted transition-colors disabled:opacity-50"
>
{copyDone ? "已复制" : copying ? "复制中..." : "复制日志"}
</button>
<button
type="button"
onClick={handleFactoryReset}
className="px-4 py-2 text-sm font-medium rounded-lg border border-border bg-background hover:bg-muted transition-colors"
>
</button>
<button
type="button"
onClick={handleRestart}
className="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/80 transition-colors"
>
</button>
</div>
</div>
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useState } from "react";
import { AlertTriangle, TestTube, RotateCcw, Copy, Trash2 } from "lucide-react";
export function CrashDebug() {
const [log, setLog] = useState("");
const [copied, setCopied] = useState(false);
const [status, setStatus] = useState("");
const handleSimulateCrash = async () => {
setStatus("正在模拟崩溃...");
await window.electronAPI?.simulateCrash();
};
const handleTestDialog = async () => {
setStatus("正在打开崩溃弹窗...");
await window.electronAPI?.testCrashDialog();
setStatus("崩溃弹窗已打开");
};
const handleReadLog = async () => {
const result = await window.electronAPI?.invoke("crash:readLog") as string;
setLog(result || "(空)");
setStatus("日志已加载");
};
const handleCopyLog = async () => {
if (!log) return;
await navigator.clipboard.writeText(log);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleFactoryReset = async () => {
if (!confirm("确定要执行强还原配置吗?这将删除 Koring.yml、koring-auth.json 和背景缓存,但不会影响实例。")) return;
setStatus("正在还原...");
await window.electronAPI?.invoke("crash:factoryReset");
setStatus("还原完成");
};
const btnBase = "flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer active:scale-[0.97]";
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<div className="space-y-6">
{/* 模拟崩溃 */}
<section className="glass-card p-5 space-y-3">
<div className="flex items-center gap-2.5">
<AlertTriangle className="w-4 h-4 text-red-500" />
<h3 className="text-sm font-semibold text-foreground"></h3>
</div>
<p className="text-[13px] text-muted-foreground">
<code className="px-1.5 py-0.5 rounded bg-foreground/[0.06] text-foreground/80 text-xs">forcefullyCrashRenderer()</code> <code className="px-1.5 py-0.5 rounded bg-foreground/[0.06] text-foreground/80 text-xs">render-process-gone</code>
</p>
<button onClick={handleSimulateCrash} className={`${btnBase} bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20`}>
<AlertTriangle className="w-4 h-4" />
</button>
</section>
{/* 测试崩溃弹窗 */}
<section className="glass-card p-5 space-y-3">
<div className="flex items-center gap-2.5">
<TestTube className="w-4 h-4 text-amber-500" />
<h3 className="text-sm font-semibold text-foreground"></h3>
</div>
<p className="text-[13px] text-muted-foreground">
UI
</p>
<button onClick={handleTestDialog} className={`${btnBase} bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20`}>
<TestTube className="w-4 h-4" />
</button>
</section>
{/* 崩溃日志 */}
<section className="glass-card p-5 space-y-3">
<div className="flex items-center gap-2.5">
<Copy className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-foreground"></h3>
</div>
<p className="text-[13px] text-muted-foreground">
<code className="px-1.5 py-0.5 rounded bg-foreground/[0.06] text-foreground/80 text-xs">koring-crash.log</code>
</p>
<div className="flex gap-2">
<button onClick={handleReadLog} className={`${btnBase} bg-blue-500/10 text-blue-600 dark:text-blue-400 hover:bg-blue-500/20`}>
<Copy className="w-4 h-4" />
</button>
<button onClick={handleCopyLog} disabled={!log} className={`${btnBase} bg-blue-500/10 text-blue-600 dark:text-blue-400 hover:bg-blue-500/20 disabled:opacity-40`}>
<Copy className="w-4 h-4" />
{copied ? "已复制" : "复制到剪贴板"}
</button>
</div>
{log && (
<pre className="mt-2 p-3 rounded-lg bg-foreground/[0.03] border border-border/50 text-xs text-foreground/70 overflow-auto max-h-40 font-mono">
{log}
</pre>
)}
</section>
{/* 强还原配置 */}
<section className="glass-card p-5 space-y-3">
<div className="flex items-center gap-2.5">
<Trash2 className="w-4 h-4 text-orange-500" />
<h3 className="text-sm font-semibold text-foreground"></h3>
</div>
<p className="text-[13px] text-muted-foreground">
<code className="px-1.5 py-0.5 rounded bg-foreground/[0.06] text-foreground/80 text-xs">Koring.yml</code>
<code className="px-1.5 py-0.5 rounded bg-foreground/[0.06] text-foreground/80 text-xs">koring-auth.json</code>
<strong className="text-foreground/80"> </strong>
</p>
<button onClick={handleFactoryReset} className={`${btnBase} bg-orange-500/10 text-orange-600 dark:text-orange-400 hover:bg-orange-500/20`}>
<Trash2 className="w-4 h-4" />
</button>
</section>
{/* 状态 */}
{status && (
<p className="text-xs text-muted-foreground/60">{status}</p>
)}
</div>
</div>
);
}
+9 -1
View File
@@ -1,7 +1,15 @@
import { useRouteStore } from "@/stores/routeStore";
import { Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket } from "lucide-react";
import { Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket, AlertTriangle } from "lucide-react";
const debugPages = [
{
key: "debug-crash" as const,
icon: AlertTriangle,
title: "崩溃测试",
desc: "模拟崩溃、测试崩溃弹窗、查看崩溃日志与强还原配置",
color: "text-red-500",
bg: "bg-red-500/10",
},
{
key: "debug-splash" as const,
icon: Monitor,
+2 -1
View File
@@ -9,6 +9,7 @@ import {
Clock,
Loader2,
} from "lucide-react";
import { VERSION } from "@/lib/version";
interface InfoItem {
icon: typeof Package;
@@ -35,7 +36,7 @@ export function OobeAboutInfo() {
const items: InfoItem[] = systemInfo && localeInfo
? [
{ icon: Package, label: "App Version", value: `v${systemInfo.app_version}`, color: "text-primary", bg: "bg-primary/10" },
{ icon: Package, label: "App Version", value: `v${VERSION}`, color: "text-primary", bg: "bg-primary/10" },
{ icon: Cpu, label: "BIOS ID", value: systemInfo.bios_id, color: "text-blue-500", bg: "bg-blue-500/10" },
{ icon: Monitor, label: "OS Name", value: `${systemInfo.os_name} (${systemInfo.os_version})`, color: "text-purple-500", bg: "bg-purple-500/10" },
{ icon: Globe, label: "Region", value: localeInfo.region, color: "text-green-500", bg: "bg-green-500/10" },
+53 -18
View File
@@ -1,29 +1,64 @@
import { useRouteStore } from "@/stores/routeStore";
import { Rocket, ChevronRight } from "lucide-react";
import { useEffect, useState, useRef } from "react";
export function Oobe() {
const goBack = useRouteStore((s) => s.goBack);
const words = ["Hello.", "你好。", "こんにちは。", "안녕하세요。", "Bonjour."];
function TypewriterText() {
const [wordIndex, setWordIndex] = useState(0);
const [displayed, setDisplayed] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
const word = words[wordIndex];
if (!isDeleting) {
if (displayed.length < word.length) {
timerRef.current = setTimeout(() => {
setDisplayed(word.slice(0, displayed.length + 1));
}, 120);
} else {
timerRef.current = setTimeout(() => setIsDeleting(true), 1800);
}
} else {
if (displayed.length > 0) {
timerRef.current = setTimeout(() => {
setDisplayed(displayed.slice(0, -1));
}, 60);
} else {
setIsDeleting(false);
setWordIndex((i) => (i + 1) % words.length);
}
}
return () => clearTimeout(timerRef.current);
}, [displayed, isDeleting, wordIndex]);
return (
<div className="h-full flex flex-col items-center justify-center px-8">
<div className="flex flex-col items-center text-center max-w-md">
<div className="p-4 rounded-2xl bg-primary/10 mb-6">
<Rocket className="w-10 h-10 text-primary" />
</div>
<span className="inline-block min-w-[200px] text-center">
{displayed}
<span className="inline-block w-[2px] h-[1em] bg-foreground/60 ml-0.5 align-middle animate-pulse" />
</span>
);
}
<h1 className="text-2xl font-bold text-foreground mb-2">
使 Koring Launcher
</h1>
<p className="text-sm text-muted-foreground mb-8 leading-relaxed">
OOBE
</p>
export function Oobe() {
const navigate = useRouteStore((s) => s.navigate);
return (
<div className="h-full flex flex-col items-center justify-center relative">
{/* 中心动画文字 */}
<div className="text-5xl font-bold tracking-tight text-foreground">
<TypewriterText />
</div>
{/* 下方按钮 */}
<div className="absolute bottom-12">
<button
onClick={goBack}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-foreground/[0.06] hover:bg-foreground/[0.1] text-sm font-medium transition-colors"
onClick={() => navigate("oobe/language")}
className="w-12 h-12 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-xl"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
+13
View File
@@ -0,0 +1,13 @@
import { type ReactNode } from "react";
interface OobeLayoutProps {
children: ReactNode;
}
export function OobeLayout({ children }: OobeLayoutProps) {
return (
<div className="h-full flex flex-col items-center justify-center relative">
{children}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
interface NextButtonProps {
onClick: () => void;
disabled?: boolean;
}
export function NextButton({ onClick, disabled = false }: NextButtonProps) {
return (
<div className="absolute bottom-12">
<button
onClick={onClick}
disabled={disabled}
className="w-12 h-12 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-xl disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-foreground/[0.06] disabled:hover:text-foreground/60"
>
</button>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useState, useEffect } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { OobeLayout } from "./layout";
import { NextButton } from "./next-button";
export function OobeAgreement() {
const navigate = useRouteStore((s) => s.navigate);
const [text, setText] = useState("");
const [checked, setChecked] = useState(false);
useEffect(() => {
fetch(`${import.meta.env.BASE_URL}protocol-user.txt`)
.then((r) => r.text())
.then(setText)
.catch(() => setText("无法加载协议内容"));
}, []);
return (
<OobeLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
{/* 标题 */}
<div className="text-center space-y-1">
<h2 className="text-lg font-bold text-foreground">Koring Team </h2>
<p className="text-xs text-muted-foreground"></p>
</div>
{/* 协议内容 */}
<div className="w-full h-[300px] rounded-xl bg-foreground/[0.03] border border-border/50 p-4 overflow-y-auto">
<pre className="text-xs text-foreground/70 whitespace-pre-wrap font-sans leading-relaxed">
{text}
</pre>
</div>
{/* 勾选框 */}
<label className="flex items-start gap-2.5 cursor-pointer select-none group">
<div className="relative mt-0.5">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="sr-only peer"
/>
<div className="w-4 h-4 rounded border border-border/60 bg-foreground/[0.03] peer-checked:bg-primary peer-checked:border-primary transition-colors flex items-center justify-center">
{checked && (
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2 6 5 9 10 3" />
</svg>
)}
</div>
</div>
<span className="text-xs text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
</span>
</label>
</div>
<NextButton onClick={() => navigate("oobe/version")} disabled={!checked} />
</OobeLayout>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useState, useEffect } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { OobeLayout } from "./layout";
import { NextButton } from "./next-button";
import { Loader2 } from "lucide-react";
export function OobeBetaTest() {
const navigate = useRouteStore((s) => s.navigate);
const [loading, setLoading] = useState(true);
const [text, setText] = useState("");
const [checked, setChecked] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
fetch(`${import.meta.env.BASE_URL}protocol-beta.txt`)
.then((r) => r.text())
.then(setText)
.catch(() => setText("无法加载协议内容"))
.finally(() => setLoading(false));
}, 1500);
return () => clearTimeout(timer);
}, []);
if (loading) {
return (
<OobeLayout>
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-6 h-6 animate-spin text-foreground/40" />
<span className="text-sm text-muted-foreground">...</span>
</div>
</OobeLayout>
);
}
return (
<OobeLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
{/* 标题 */}
<div className="text-center space-y-1">
<h2 className="text-lg font-bold text-foreground">Koring APP Beta </h2>
<p className="text-xs text-muted-foreground"></p>
</div>
{/* 协议内容 */}
<div className="w-full h-[300px] rounded-xl bg-foreground/[0.03] border border-border/50 p-4 overflow-y-auto">
<pre className="text-xs text-foreground/70 whitespace-pre-wrap font-sans leading-relaxed">
{text}
</pre>
</div>
{/* 勾选框 */}
<label className="flex items-start gap-2.5 cursor-pointer select-none group">
<div className="relative mt-0.5">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="sr-only peer"
/>
<div className="w-4 h-4 rounded border border-border/60 bg-foreground/[0.03] peer-checked:bg-primary peer-checked:border-primary transition-colors flex items-center justify-center">
{checked && (
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2 6 5 9 10 3" />
</svg>
)}
</div>
</div>
<span className="text-xs text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
</span>
</label>
</div>
<NextButton onClick={() => navigate("oobe/finish")} disabled={!checked} />
</OobeLayout>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useRouteStore } from "@/stores/routeStore";
import { useConfigStore } from "@/stores/configStore";
import { OobeLayout } from "./layout";
import { AppleHelloEnglishEffect } from "@/components/ui/apple-hello-effect";
export function OobeFinish() {
const navigate = useRouteStore((s) => s.navigate);
const setOobe = useConfigStore((s) => s.setOobe);
const handleFinish = () => {
setOobe(false);
navigate("home");
};
return (
<OobeLayout>
<div className="flex flex-col items-center gap-6">
<AppleHelloEnglishEffect className="text-foreground" />
</div>
<div className="absolute bottom-12">
<button
onClick={handleFinish}
className="h-12 px-6 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-sm font-medium"
>
</button>
</div>
</OobeLayout>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useState } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { OobeLayout } from "./layout";
import { NextButton } from "./next-button";
import { Check } from "lucide-react";
const languages = [
{ key: "zh-CN", label: "简体中文", available: true },
{ key: "zh-TW", label: "繁体中文", available: false },
{ key: "en", label: "English", available: false },
{ key: "ja", label: "日本語", available: false },
{ key: "ko", label: "한국어", available: false },
{ key: "lzh", label: "文言文(中国)", available: false },
];
export function OobeLanguage() {
const navigate = useRouteStore((s) => s.navigate);
const [selected, setSelected] = useState("zh-CN");
return (
<OobeLayout>
<div className="w-full max-w-sm space-y-3">
{languages.map((lang) => (
<button
key={lang.key}
disabled={!lang.available}
onClick={() => lang.available && setSelected(lang.key)}
className={[
"w-full flex items-center justify-between px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200",
lang.available
? selected === lang.key
? "bg-foreground/[0.08] text-foreground ring-1 ring-foreground/10"
: "bg-foreground/[0.03] text-foreground/60 hover:bg-foreground/[0.06] hover:text-foreground/80"
: "bg-foreground/[0.02] text-foreground/25 cursor-not-allowed",
].join(" ")}
>
<span>{lang.label}</span>
{!lang.available && (
<span className="text-[11px] text-foreground/20"></span>
)}
{selected === lang.key && lang.available && (
<Check className="w-4 h-4 text-foreground/60" />
)}
</button>
))}
</div>
<NextButton onClick={() => navigate("oobe/agreement")} />
</OobeLayout>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useState, useEffect } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { OobeLayout } from "./layout";
import { NextButton } from "./next-button";
import { VERSION } from "@/lib/version";
import { BUILD_MODE } from "@/lib/mode";
export function OobeVersion() {
const navigate = useRouteStore((s) => s.navigate);
const [changelog, setChangelog] = useState("");
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
const badgeLabel = BUILD_MODE === "dev" ? "DEV" : BUILD_MODE === "beta" ? "BETA" : null;
const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/finish";
useEffect(() => {
fetch(`${import.meta.env.BASE_URL}changelog-${VERSION}.txt`)
.then((r) => r.text())
.then(setChangelog)
.catch(() => setChangelog("暂无更新内容"));
}, []);
return (
<OobeLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
{/* 标题 */}
<h2 className="text-lg font-bold text-foreground"></h2>
{/* 版本号 + Badge */}
<div className="flex items-center gap-2.5">
<span className="text-3xl font-bold tracking-tight text-foreground">
v{VERSION}
</span>
{badgeLabel && (
<span
className={[
"text-[11px] font-bold px-2 py-0.5 rounded-full leading-none",
BUILD_MODE === "dev"
? "bg-amber-500/15 text-amber-600 dark:text-amber-400"
: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
].join(" ")}
>
{badgeLabel}
</span>
)}
</div>
{/* 测试版警告 */}
{isTestBuild && (
<p className="text-xs text-muted-foreground text-center max-w-sm leading-relaxed">
使
</p>
)}
{/* 更新内容 */}
<div className="w-full h-[260px] rounded-xl bg-foreground/[0.03] border border-border/50 p-4 overflow-y-auto">
<pre className="text-xs text-foreground/70 whitespace-pre-wrap font-sans leading-relaxed">
{changelog}
</pre>
</div>
</div>
<NextButton onClick={() => navigate(nextRoute)} />
</OobeLayout>
);
}
+30 -1
View File
@@ -1,6 +1,7 @@
import { VersionCard } from "@/components/VersionCard";
import { BUILD_MODE } from "@/lib/mode";
import { ExternalLink, GitFork } from "lucide-react";
import { ExternalLink, GitFork, RotateCcw } from "lucide-react";
import { useConfirmDialogStore } from "@/stores/confirmDialogStore";
function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>;
@@ -28,10 +29,22 @@ const GITHUB_URL = "https://github.com/koring-launcher/koring-launcher";
const OFFICIAL_URL = "https://koring.app";
export function AboutSetting() {
const openDialog = useConfirmDialogStore((s) => s.openDialog);
const openLink = (url: string) => {
window.electronAPI?.openExternal(url);
};
const handleResetClick = () => {
openDialog({
title: "您确定要还原所有配置吗?",
description: "您还原后,您的实例将会保留,但是所有个性化配置将全部丢失,并且需要重新进行激活",
confirmLabel: "确认还原",
countdown: 5,
onConfirm: () => window.electronAPI?.resetConfig(),
});
};
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
@@ -92,6 +105,22 @@ export function AboutSetting() {
</GlassCard>
</div>
</div>
{/* 危险操作 */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<GlassCard>
<SettingRow label="还原所有设置" desc="删除所有配置文件并重启应用">
<button
onClick={handleResetClick}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 transition-colors"
>
<RotateCcw className="w-4 h-4" />
</button>
</SettingRow>
</GlassCard>
</div>
</div>
</div>
);
+58 -27
View File
@@ -14,22 +14,23 @@ import {
} from "@/api/config";
import { DEFAULT_BG } from "@/lib/mode";
// TODO: re-enable when ready
// let saveTimer: ReturnType<typeof setTimeout> | null = null;
// function debouncedSave(config: AppConfig) {
// if (saveTimer) clearTimeout(saveTimer);
// saveTimer = setTimeout(() => {
// saveConfig(config).catch((e) => {
// console.error("[config] save failed:", e);
// });
// }, 300);
// }
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function debouncedSave(config: AppConfig) {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveConfig(config).catch((e) => {
console.error("[config] save failed:", e);
});
}, 300);
}
interface ConfigState {
config: AppConfig;
loaded: boolean;
isFirstLaunch: boolean;
init: () => Promise<void>;
applyPreloaded: (config: AppConfig, isFirstLaunch: boolean) => void;
setTheme: (patch: Partial<ThemeConfig>) => void;
setA11y: (patch: Partial<A11yConfig>) => void;
setBackground: (patch: Partial<BackgroundConfig>) => void;
@@ -38,10 +39,12 @@ interface ConfigState {
setAdvanced: (patch: Partial<AdvancedConfig>) => void;
setDownload: (patch: Partial<DownloadConfig>) => void;
setNetwork: (patch: Partial<NetworkConfig>) => void;
setOobe: (value: boolean) => void;
}
const DEFAULT_CONFIG: AppConfig = {
version: 1,
oobe: true,
theme: { darkMode: "auto", parallax: true },
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
background: { bgType: "image", image: DEFAULT_BG, blur: 0, opacity: 100 },
@@ -55,56 +58,84 @@ const DEFAULT_CONFIG: AppConfig = {
export const useConfigStore = create<ConfigState>((set, get) => ({
config: DEFAULT_CONFIG,
loaded: false,
isFirstLaunch: false,
applyPreloaded: (config, isFirstLaunch) => {
set({ config, isFirstLaunch, loaded: true });
},
init: async () => {
// TODO: re-enable when ready load from Rust/Koring.yml
// try {
// const config = await getConfig();
// set({ config, loaded: true });
// } catch (e) {
// console.error("[config] init failed, using defaults:", e);
// set({ loaded: true });
// }
set({ loaded: true });
// If already preloaded, skip IPC call
if (get().loaded) return;
try {
const config = await getConfig();
set({ config, loaded: true });
} catch (e) {
console.error("[config] init failed, using defaults:", e);
set({ loaded: true });
}
},
setTheme: (patch) => {
const { config } = get();
set({ config: { ...config, theme: { ...config.theme, ...patch } } });
const next = { ...config, theme: { ...config.theme, ...patch } };
set({ config: next });
debouncedSave(next);
},
setA11y: (patch) => {
const { config } = get();
set({ config: { ...config, a11y: { ...config.a11y, ...patch } } });
const next = { ...config, a11y: { ...config.a11y, ...patch } };
set({ config: next });
debouncedSave(next);
},
setBackground: (patch) => {
const { config } = get();
set({ config: { ...config, background: { ...config.background, ...patch } } });
const next = { ...config, background: { ...config.background, ...patch } };
set({ config: next });
debouncedSave(next);
},
setGame: (patch) => {
const { config } = get();
set({ config: { ...config, game: { ...config.game, ...patch } } });
const next = { ...config, game: { ...config.game, ...patch } };
set({ config: next });
debouncedSave(next);
},
setJava: (patch) => {
const { config } = get();
set({ config: { ...config, java: { ...config.java, ...patch } } });
const next = { ...config, java: { ...config.java, ...patch } };
set({ config: next });
debouncedSave(next);
},
setAdvanced: (patch) => {
const { config } = get();
set({ config: { ...config, advanced: { ...config.advanced, ...patch } } });
const next = { ...config, advanced: { ...config.advanced, ...patch } };
set({ config: next });
debouncedSave(next);
},
setDownload: (patch) => {
const { config } = get();
set({ config: { ...config, download: { ...config.download, ...patch } } });
const next = { ...config, download: { ...config.download, ...patch } };
set({ config: next });
debouncedSave(next);
},
setNetwork: (patch) => {
const { config } = get();
set({ config: { ...config, network: { ...config.network, ...patch } } });
const next = { ...config, network: { ...config.network, ...patch } };
set({ config: next });
debouncedSave(next);
},
setOobe: (value) => {
const { config } = get();
const next = { ...config, oobe: value };
set({ config: next });
debouncedSave(next);
},
}));
+40
View File
@@ -0,0 +1,40 @@
import { create } from "zustand";
interface ConfirmDialogState {
open: boolean;
title: string;
description: string;
confirmLabel: string;
countdown: number;
showCountdown: boolean;
onConfirm: () => void;
openDialog: (opts: {
title: string;
description: string;
confirmLabel?: string;
countdown?: number;
onConfirm: () => void;
}) => void;
closeDialog: () => void;
}
export const useConfirmDialogStore = create<ConfirmDialogState>((set) => ({
open: false,
title: "",
description: "",
confirmLabel: "确认",
countdown: 0,
showCountdown: false,
onConfirm: () => {},
openDialog: (opts) =>
set({
open: true,
title: opts.title,
description: opts.description,
confirmLabel: opts.confirmLabel ?? "确认",
countdown: opts.countdown ?? 0,
showCountdown: (opts.countdown ?? 0) > 0,
onConfirm: opts.onConfirm,
}),
closeDialog: () => set({ open: false }),
}));
+13 -2
View File
@@ -9,12 +9,18 @@ export type RouteKey =
| "gallery"
| "task-queue"
| "oobe"
| "oobe/language"
| "oobe/agreement"
| "oobe/version"
| "oobe/beta-test"
| "oobe/finish"
| "oobe/about-info"
| "debug"
| "debug-splash"
| "debug-display"
| "debug-version-card"
| "debug-task";
| "debug-task"
| "debug-crash";
export type TitleBarMode = "default" | "sub" | "window" | "oobe";
@@ -41,6 +47,11 @@ export const allRoutes: RouteItem[] = [
...routes,
{ key: "task-queue", label: "任务队列", path: "/task-queue", hidden: true },
{ key: "oobe", label: "OOBE", path: "/oobe", hidden: true },
{ key: "oobe/language", label: "语言设置", path: "/oobe/language", hidden: true },
{ key: "oobe/agreement", label: "同意协议", path: "/oobe/agreement", hidden: true },
{ key: "oobe/version", label: "当前版本", path: "/oobe/version", hidden: true },
{ key: "oobe/beta-test", label: "测试协议", path: "/oobe/beta-test", hidden: true },
{ key: "oobe/finish", label: "完成", path: "/oobe/finish", hidden: true },
{ key: "oobe/about-info", label: "关于信息", path: "/oobe/about-info", hidden: true, backable: true },
{ key: "debug", label: "调试", path: "/debug", hidden: true },
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true },
@@ -52,7 +63,7 @@ export const allRoutes: RouteItem[] = [
const topLevelKeys = new Set(routes.map((r) => r.key));
function getRouteTitleBarMode(key: RouteKey): TitleBarMode {
if (key === "oobe") return "oobe";
if (key === "oobe" || key.startsWith("oobe/")) return "oobe";
return topLevelKeys.has(key) ? "default" : "sub";
}
+9
View File
@@ -11,7 +11,16 @@ interface ElectronAPI {
getTheme: () => Promise<'light' | 'dark' | 'system' | null>;
onConfigPreload: (callback: (data: { config: unknown; isFirstLaunch: boolean }) => void) => () => void;
openExternal: (url: string) => Promise<void>;
// Crash monitoring
simulateCrash: () => Promise<void>;
testCrashDialog: () => Promise<void>;
// Config reset
resetConfig: () => Promise<void>;
}
declare global {
+1 -6
View File
@@ -1,11 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_START_POP: string;
readonly VITE_START_POP_TITLE: string;
readonly VITE_START_POP_INFO: string;
readonly VITE_START_POP_BOUTTON: string;
}
interface ImportMetaEnv {}
interface ImportMeta {
readonly env: ImportMetaEnv;