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
-5
View File
@@ -1,7 +1,2 @@
VITE_BUILD_MODE=beta VITE_BUILD_MODE=beta
VITE_APP_ICON=/beta.png VITE_APP_ICON=/beta.png
VITE_START_POP=true
VITE_START_POP_TITLE="你正在使用 UI 预览版本"
VITE_START_POP_INFO="此版本是专门用于进行检测是否有 UI 问题的版本,因此功能将会缺失"
VITE_START_POP_BOUTTON="我已了解"
-5
View File
@@ -1,7 +1,2 @@
VITE_BUILD_MODE=dev VITE_BUILD_MODE=dev
VITE_APP_ICON=/dev.png VITE_APP_ICON=/dev.png
VITE_START_POP=true
VITE_START_POP_TITLE="你正在使用 UI 预览版本"
VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失"
VITE_START_POP_BOUTTON="我已了解"
-5
View File
@@ -1,7 +1,2 @@
VITE_BUILD_MODE=run VITE_BUILD_MODE=run
VITE_APP_ICON=/run.png VITE_APP_ICON=/run.png
VITE_START_POP=false
VITE_START_POP_TITLE="你正在使用 UI 预览版本"
VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失"
VITE_START_POP_BOUTTON="我已了解"
-144
View File
@@ -1,144 +0,0 @@
# Crash Monitor Implementation Plan
## 1. Architecture Overview
```
┌─────────────────────────────────────────────────────────┐
│ Main Process │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Crash Logger (file-based, survives crashes) │ │
│ │ - ipcMain.on('renderer-error') → write to file │ │
│ │ - process.on('uncaughtException') → write to file │ │
│ │ - app.on('render-process-gone') → write to file │ │
│ │ - app.on('child-process-gone') → write to file │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ UtilityProcess (monitor) │ ← isolated process │
│ │ - Watches crash log file │ survives window │
│ │ - Sends crash events via │ crashes │
│ │ MessagePort to main │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ Crash Monitor Window │ ← separate window │
│ │ - Shows crash dialogs │ custom UI │
│ │ - Log viewer window │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↕ IPC (contextBridge)
┌─────────────────────────────────────────────────────────┐
│ Renderer (Main Window) │
│ - window.onerror → ipcRenderer.send('renderer-error') │
│ - unhandledrejection → ipcRenderer.send(...) │
│ - webContents 'render-process-gone' → recover │
└─────────────────────────────────────────────────────────┘
```
## 2. Implementation Steps
### Step 1: Create Crash Logger Module
**File:** `electron/core/crash-logger.ts`
- Initialize crash log path (`koring-crash.log` next to executable)
- Write crash events synchronously to survive crashes
- Buffer recent events for breadcrumb trail
- Log rotation (keep last 1000 lines)
### Step 2: Create Crash Monitor Window
**File:** `electron/handlers/crash-monitor.ts`
- Separate BrowserWindow (hidden by default)
- Listens for crash events from main process
- Shows custom crash dialog UI
- Can be opened from developer tools
### Step 3: Create Log Viewer Window
**File:** `electron/handlers/log-viewer.ts`
- Separate BrowserWindow for viewing logs
- Real-time log streaming via IPC
- Filter by log level (error, warn, info)
- Export logs functionality
### Step 4: Create Crash Dialog UI
**File:** `src/components/crash/CrashDialog.tsx`
- Custom styled crash dialog
- Shows error details, stack trace
- Options: Restart, View Logs, Close
- Uses existing UI components (shadcn/ui)
### Step 5: Create Log Viewer UI
**File:** `src/components/crash/LogViewer.tsx`
- Log list with syntax highlighting
- Search/filter functionality
- Real-time updates
### Step 6: Update Main Process
**File:** `electron/main.ts`
- Initialize crash logger early
- Set up crash event handlers
- Create crash monitor window
### Step 7: Update Preload Script
**File:** `electron/preload.ts`
- Add renderer error capture
- Expose crash-related IPC methods
## 3. Key Features
1. **Crash Detection:**
- Renderer crashes (`render-process-gone`)
- Main process errors (`uncaughtException`, `unhandledRejection`)
- GPU/utility crashes (`child-process-gone`)
- Unresponsive detection (`unresponsive` event)
2. **Crash Dialog:**
- Custom styled UI matching launcher theme
- Error message and stack trace display
- One-click restart option
- View logs option
3. **Log Viewer:**
- Can be opened from developer tools
- Real-time log streaming
- Search and filter capabilities
- Export functionality
4. **Process Isolation:**
- Crash monitor runs in separate window
- Main window crashes don't affect monitor
- Monitor can restart main window
## 4. Files to Create/Modify
**New Files:**
- `electron/core/crash-logger.ts`
- `electron/handlers/crash-monitor.ts`
- `electron/handlers/log-viewer.ts`
- `src/components/crash/CrashDialog.tsx`
- `src/components/crash/LogViewer.tsx`
- `src/pages/crash/index.tsx`
**Modified Files:**
- `electron/main.ts` - Add crash monitoring initialization
- `electron/preload.ts` - Add error capture and IPC methods
- `src/types/electron.d.ts` - Add new IPC method types
## 5. UI Design
The crash dialog and log viewer will use the existing design system:
- Glass effects with backdrop-filter
- Dark theme compatible
- Consistent spacing and typography
- Uses shadcn/ui components where appropriate
## 6. Testing Plan
1. Test crash detection by triggering intentional errors
2. Verify crash dialog appears correctly
3. Test log viewer functionality
4. Verify main window can be restarted from crash dialog
5. Test log export functionality
## 7. Questions for User
1. What specific crash scenarios should we prioritize?
2. Do you want the log viewer always accessible or only in dev mode?
3. Should crash reports be sent to a server or just stored locally?
4. Any specific UI preferences for the crash dialog beyond the existing design system?
+1
View File
@@ -0,0 +1 @@
oobe: false
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Koring Launcher - Crash</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/crash.tsx"></script>
</body>
</html>
+11 -1
View File
@@ -7,7 +7,7 @@ const { app } = electron;
const CONFIG_FILE = 'Koring.yml'; const CONFIG_FILE = 'Koring.yml';
const CURRENT_VERSION = 1; const CURRENT_VERSION = 1;
function configPath(): string { export function configPath(): string {
if (app.isPackaged) { if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), CONFIG_FILE); return path.join(path.dirname(app.getPath('exe')), CONFIG_FILE);
} }
@@ -76,6 +76,7 @@ export interface NetworkConfig {
export interface AppConfig { export interface AppConfig {
version: number; version: number;
oobe: boolean;
theme: ThemeConfig; theme: ThemeConfig;
a11y: A11yConfig; a11y: A11yConfig;
background: BackgroundConfig; background: BackgroundConfig;
@@ -88,6 +89,7 @@ export interface AppConfig {
const DEFAULTS: AppConfig = { const DEFAULTS: AppConfig = {
version: CURRENT_VERSION, version: CURRENT_VERSION,
oobe: true,
theme: { darkMode: 'auto', parallax: true }, theme: { darkMode: 'auto', parallax: true },
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 }, a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
background: { bgType: 'image', image: '/background.png', blur: 0, opacity: 100 }, background: { bgType: 'image', image: '/background.png', blur: 0, opacity: 100 },
@@ -129,6 +131,14 @@ function diffValue(full: unknown, defaultVal: unknown): unknown {
return Object.keys(result).length === 0 ? undefined : result; return Object.keys(result).length === 0 ? undefined : result;
} }
export function configExists(): boolean {
try {
return fs.existsSync(configPath());
} catch {
return false;
}
}
export function loadConfig(): AppConfig { export function loadConfig(): AppConfig {
const filePath = configPath(); const filePath = configPath();
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
+93
View File
@@ -0,0 +1,93 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.writeCrashLog = writeCrashLog;
exports.readCrashLog = readCrashLog;
exports.clearCrashLog = clearCrashLog;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const electron_1 = __importDefault(require("electron"));
const { app } = electron_1.default;
const LOG_FILE = 'koring-crash.log';
const MAX_LINES = 1000;
function logPath() {
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), LOG_FILE);
}
return path.join(__dirname, '..', LOG_FILE);
}
function trimFile(filePath) {
try {
if (!fs.existsSync(filePath))
return;
const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
if (lines.length > MAX_LINES) {
fs.writeFileSync(filePath, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8');
}
}
catch { }
}
function writeCrashLog(entry) {
const filePath = logPath();
try {
const line = JSON.stringify(entry) + '\n';
fs.appendFileSync(filePath, line, 'utf-8');
trimFile(filePath);
}
catch { }
}
function readCrashLog() {
const filePath = logPath();
try {
if (!fs.existsSync(filePath))
return '';
return fs.readFileSync(filePath, 'utf-8');
}
catch {
return '';
}
}
function clearCrashLog() {
const filePath = logPath();
try {
if (fs.existsSync(filePath)) {
fs.writeFileSync(filePath, '', 'utf-8');
}
}
catch { }
}
+60
View File
@@ -0,0 +1,60 @@
import * as fs from 'fs';
import * as path from 'path';
import electron from 'electron';
const { app } = electron;
const LOG_FILE = 'koring-crash.log';
const MAX_LINES = 1000;
function logPath(): string {
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), LOG_FILE);
}
return path.join(__dirname, '..', LOG_FILE);
}
export interface CrashEntry {
timestamp: string;
type: 'renderer-gone' | 'unresponsive' | 'uncaught-exception' | 'unhandled-rejection' | 'child-process-gone' | 'test';
message: string;
stack?: string;
details?: Record<string, unknown>;
}
function trimFile(filePath: string): void {
try {
if (!fs.existsSync(filePath)) return;
const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
if (lines.length > MAX_LINES) {
fs.writeFileSync(filePath, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8');
}
} catch {}
}
export function writeCrashLog(entry: CrashEntry): void {
const filePath = logPath();
try {
const line = JSON.stringify(entry) + '\n';
fs.appendFileSync(filePath, line, 'utf-8');
trimFile(filePath);
} catch {}
}
export function readCrashLog(): string {
const filePath = logPath();
try {
if (!fs.existsSync(filePath)) return '';
return fs.readFileSync(filePath, 'utf-8');
} catch {
return '';
}
}
export function clearCrashLog(): void {
const filePath = logPath();
try {
if (fs.existsSync(filePath)) {
fs.writeFileSync(filePath, '', 'utf-8');
}
} catch {}
}
+49 -1
View File
@@ -1,11 +1,45 @@
"use strict"; "use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) { var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod }; return (mod && mod.__esModule) ? mod : { "default": mod };
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.registerConfigHandlers = registerConfigHandlers; exports.registerConfigHandlers = registerConfigHandlers;
const electron_1 = __importDefault(require("electron")); const electron_1 = __importDefault(require("electron"));
const { ipcMain } = electron_1.default; const { ipcMain, app } = electron_1.default;
const fs = __importStar(require("fs"));
const config_1 = require("../config"); const config_1 = require("../config");
function registerConfigHandlers() { function registerConfigHandlers() {
ipcMain.handle('config:get', () => { ipcMain.handle('config:get', () => {
@@ -26,4 +60,18 @@ function registerConfigHandlers() {
return { success: false, data: null, error: String(e) }; return { success: false, data: null, error: String(e) };
} }
}); });
ipcMain.handle('config:reset', () => {
try {
const filePath = (0, config_1.configPath)();
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
app.relaunch();
app.exit(0);
return { success: true, data: null, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
} }
+18 -2
View File
@@ -1,6 +1,8 @@
import electron from 'electron'; import electron from 'electron';
const { ipcMain } = electron; const { ipcMain, app } = electron;
import { loadConfig, saveConfig, type AppConfig } from '../config'; import * as fs from 'fs';
import * as path from 'path';
import { loadConfig, saveConfig, type AppConfig, configPath } from '../config';
export function registerConfigHandlers() { export function registerConfigHandlers() {
ipcMain.handle('config:get', () => { ipcMain.handle('config:get', () => {
@@ -20,4 +22,18 @@ export function registerConfigHandlers() {
return { success: false, data: null, error: String(e) }; return { success: false, data: null, error: String(e) };
} }
}); });
ipcMain.handle('config:reset', () => {
try {
const filePath = configPath();
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
app.relaunch();
app.exit(0);
return { success: true, data: null, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
} }
+206
View File
@@ -0,0 +1,206 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.setupCrashListeners = setupCrashListeners;
exports.registerCrashHandlers = registerCrashHandlers;
exports.testCrashDialog = testCrashDialog;
const electron_1 = __importDefault(require("electron"));
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const crash_logger_1 = require("../core/crash-logger");
const { app, ipcMain, BrowserWindow } = electron_1.default;
const isDev = !app.isPackaged;
let crashWin = null;
function getIconPath() {
return path_1.default.join(__dirname, '../../build/icon.ico');
}
function createCrashWindow() {
const iconPath = getIconPath();
const win = new BrowserWindow({
width: 600,
height: 460,
minWidth: 500,
maxWidth: 700,
minHeight: 460,
maxHeight: 460,
frame: false,
transparent: false,
resizable: false,
show: false,
icon: iconPath,
webPreferences: {
preload: path_1.default.join(__dirname, '../preload-crash.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
},
});
if (isDev) {
win.loadURL('http://localhost:1420/crash.html');
}
else {
win.loadFile(path_1.default.join(__dirname, '../../dist/crash.html'));
}
return win;
}
function sendToCrashWindow(data) {
if (!crashWin || crashWin.isDestroyed()) {
crashWin = createCrashWindow();
}
const send = () => {
crashWin?.show();
crashWin?.focus();
crashWin?.webContents.send('crash:show', {
type: data.type,
message: data.message,
timestamp: data.timestamp,
});
};
if (crashWin.webContents.isLoading()) {
crashWin.webContents.once('did-finish-load', send);
}
else {
send();
}
}
function setupCrashListeners(mainWindow) {
mainWindow.webContents.on('render-process-gone', (_event, details) => {
const entry = {
timestamp: new Date().toISOString(),
type: 'renderer-gone',
message: `渲染进程崩溃: ${details.reason} (退出码: ${details.exitCode})`,
details: details,
};
(0, crash_logger_1.writeCrashLog)(entry);
sendToCrashWindow(entry);
});
mainWindow.on('unresponsive', () => {
const entry = {
timestamp: new Date().toISOString(),
type: 'unresponsive',
message: '渲染进程无响应',
};
(0, crash_logger_1.writeCrashLog)(entry);
sendToCrashWindow(entry);
});
// Inject devtools crash tools when devtools opens
if (isDev) {
mainWindow.webContents.on('devtools-opened', () => {
const js = `
(function() {
if (window.__crashToolsLoaded) return;
window.__crashToolsLoaded = true;
console.log('%c[崩溃工具] 已加载', 'color: #f59e0b; font-weight: bold; font-size: 14px;');
console.log('%c可用命令:', 'color: #3b82f6; font-weight: bold;');
console.log('%c crash.simulate() %c— 模拟渲染进程崩溃', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.testDialog() %c— 测试崩溃弹窗', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.readLog() %c— 读取崩溃日志', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.factoryReset()%c— 强还原配置', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.restart() %c— 重启应用', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('');
window.crash = {
simulate: function() { window.electronAPI?.simulateCrash(); },
testDialog: function() { window.electronAPI?.testCrashDialog(); },
readLog: function() { return window.electronAPI?.invoke('crash:readLog'); },
factoryReset: function() { return window.electronAPI?.invoke('crash:factoryReset'); },
restart: function() { return window.electronAPI?.invoke('crash:restart'); },
};
})();
`;
mainWindow.webContents.executeJavaScript(js);
});
}
}
function registerCrashHandlers() {
// Close crash window
ipcMain.handle('crash:closeWindow', () => {
if (crashWin && !crashWin.isDestroyed()) {
crashWin.close();
crashWin = null;
}
});
// Read crash log
ipcMain.handle('crash:readLog', () => {
return (0, crash_logger_1.readCrashLog)();
});
// Simulate crash — forcefully crash the renderer (devtools only)
ipcMain.handle('crash:simulate', (_event) => {
const senderWebContents = electron_1.default.webContents.getAllWebContents().find((wc) => wc.id === _event.sender.id);
if (senderWebContents) {
senderWebContents.forcefullyCrashRenderer();
}
});
// Test crash dialog — show crash window without actually crashing
ipcMain.handle('crash:testDialog', () => {
testCrashDialog();
});
// Factory reset
ipcMain.handle('crash:factoryReset', () => {
const dataPath = app.isPackaged
? path_1.default.dirname(app.getPath('exe'))
: path_1.default.join(__dirname, '../..');
// Delete config
try {
const configPath = path_1.default.join(dataPath, 'Koring.yml');
if (fs_1.default.existsSync(configPath))
fs_1.default.unlinkSync(configPath);
}
catch { }
// Delete auth
try {
const authPath = path_1.default.join(dataPath, 'koring-auth.json');
if (fs_1.default.existsSync(authPath))
fs_1.default.unlinkSync(authPath);
}
catch { }
// Delete background cache in userData
try {
const bgPath = path_1.default.join(app.getPath('userData'), 'background.png');
if (fs_1.default.existsSync(bgPath))
fs_1.default.unlinkSync(bgPath);
}
catch { }
// Delete crash log
(0, crash_logger_1.clearCrashLog)();
return { success: true };
});
// Restart app
ipcMain.handle('crash:restart', () => {
app.relaunch();
app.exit(0);
});
// Main process error handlers
process.on('uncaughtException', (error) => {
const entry = {
timestamp: new Date().toISOString(),
type: 'uncaught-exception',
message: error.message,
stack: error.stack,
};
(0, crash_logger_1.writeCrashLog)(entry);
sendToCrashWindow(entry);
});
process.on('unhandledRejection', (reason) => {
const entry = {
timestamp: new Date().toISOString(),
type: 'unhandled-rejection',
message: String(reason),
stack: reason instanceof Error ? reason.stack : undefined,
};
(0, crash_logger_1.writeCrashLog)(entry);
sendToCrashWindow(entry);
});
}
// Called from devtools to test crash dialog
function testCrashDialog() {
const entry = {
timestamp: new Date().toISOString(),
type: 'test',
message: '这是一个测试崩溃弹窗',
};
sendToCrashWindow(entry);
}
+220
View File
@@ -0,0 +1,220 @@
import electron from 'electron';
import path from 'path';
import fs from 'fs';
import { writeCrashLog, readCrashLog, clearCrashLog, type CrashEntry } from '../core/crash-logger';
const { app, ipcMain, BrowserWindow } = electron;
const isDev = !app.isPackaged;
let crashWin: electron.BrowserWindow | null = null;
function getIconPath(): string {
return path.join(__dirname, '../../build/icon.ico');
}
function createCrashWindow(): electron.BrowserWindow {
const iconPath = getIconPath();
const win = new BrowserWindow({
width: 600,
height: 460,
minWidth: 500,
maxWidth: 700,
minHeight: 460,
maxHeight: 460,
frame: false,
transparent: false,
resizable: false,
show: false,
icon: iconPath,
webPreferences: {
preload: path.join(__dirname, '../preload-crash.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
},
});
if (isDev) {
win.loadURL('http://localhost:1420/crash.html');
} else {
win.loadFile(path.join(__dirname, '../../dist/crash.html'));
}
return win;
}
function sendToCrashWindow(data: CrashEntry) {
if (!crashWin || crashWin.isDestroyed()) {
crashWin = createCrashWindow();
}
const send = () => {
crashWin?.show();
crashWin?.focus();
crashWin?.webContents.send('crash:show', {
type: data.type,
message: data.message,
timestamp: data.timestamp,
});
};
if (crashWin.webContents.isLoading()) {
crashWin.webContents.once('did-finish-load', send);
} else {
send();
}
}
export function setupCrashListeners(mainWindow: electron.BrowserWindow) {
mainWindow.webContents.on('render-process-gone', (_event: any, details: any) => {
const entry: CrashEntry = {
timestamp: new Date().toISOString(),
type: 'renderer-gone',
message: `渲染进程崩溃: ${details.reason} (退出码: ${details.exitCode})`,
details: details as unknown as Record<string, unknown>,
};
writeCrashLog(entry);
sendToCrashWindow(entry);
});
mainWindow.on('unresponsive', () => {
const entry: CrashEntry = {
timestamp: new Date().toISOString(),
type: 'unresponsive',
message: '渲染进程无响应',
};
writeCrashLog(entry);
sendToCrashWindow(entry);
});
// Inject devtools crash tools when devtools opens
if (isDev) {
mainWindow.webContents.on('devtools-opened', () => {
const js = `
(function() {
if (window.__crashToolsLoaded) return;
window.__crashToolsLoaded = true;
console.log('%c[崩溃工具] 已加载', 'color: #f59e0b; font-weight: bold; font-size: 14px;');
console.log('%c可用命令:', 'color: #3b82f6; font-weight: bold;');
console.log('%c crash.simulate() %c— 模拟渲染进程崩溃', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.testDialog() %c— 测试崩溃弹窗', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.readLog() %c— 读取崩溃日志', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.factoryReset()%c— 强还原配置', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.restart() %c— 重启应用', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('');
window.crash = {
simulate: function() { window.electronAPI?.simulateCrash(); },
testDialog: function() { window.electronAPI?.testCrashDialog(); },
readLog: function() { return window.electronAPI?.invoke('crash:readLog'); },
factoryReset: function() { return window.electronAPI?.invoke('crash:factoryReset'); },
restart: function() { return window.electronAPI?.invoke('crash:restart'); },
};
})();
`;
mainWindow.webContents.executeJavaScript(js);
});
}
}
export function registerCrashHandlers() {
// Close crash window
ipcMain.handle('crash:closeWindow', () => {
if (crashWin && !crashWin.isDestroyed()) {
crashWin.close();
crashWin = null;
}
});
// Read crash log
ipcMain.handle('crash:readLog', () => {
return readCrashLog();
});
// Simulate crash — forcefully crash the renderer (devtools only)
ipcMain.handle('crash:simulate', (_event) => {
const senderWebContents = electron.webContents.getAllWebContents().find(
(wc) => wc.id === _event.sender.id
);
if (senderWebContents) {
senderWebContents.forcefullyCrashRenderer();
}
});
// Test crash dialog — show crash window without actually crashing
ipcMain.handle('crash:testDialog', () => {
testCrashDialog();
});
// Factory reset
ipcMain.handle('crash:factoryReset', () => {
const dataPath = app.isPackaged
? path.dirname(app.getPath('exe'))
: path.join(__dirname, '../..');
// Delete config
try {
const configPath = path.join(dataPath, 'Koring.yml');
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
} catch {}
// Delete auth
try {
const authPath = path.join(dataPath, 'koring-auth.json');
if (fs.existsSync(authPath)) fs.unlinkSync(authPath);
} catch {}
// Delete background cache in userData
try {
const bgPath = path.join(app.getPath('userData'), 'background.png');
if (fs.existsSync(bgPath)) fs.unlinkSync(bgPath);
} catch {}
// Delete crash log
clearCrashLog();
return { success: true };
});
// Restart app
ipcMain.handle('crash:restart', () => {
app.relaunch();
app.exit(0);
});
// Main process error handlers
process.on('uncaughtException', (error) => {
const entry: CrashEntry = {
timestamp: new Date().toISOString(),
type: 'uncaught-exception',
message: error.message,
stack: error.stack,
};
writeCrashLog(entry);
sendToCrashWindow(entry);
});
process.on('unhandledRejection', (reason) => {
const entry: CrashEntry = {
timestamp: new Date().toISOString(),
type: 'unhandled-rejection',
message: String(reason),
stack: reason instanceof Error ? reason.stack : undefined,
};
writeCrashLog(entry);
sendToCrashWindow(entry);
});
}
// Called from devtools to test crash dialog
export function testCrashDialog() {
const entry: CrashEntry = {
timestamp: new Date().toISOString(),
type: 'test',
message: '这是一个测试崩溃弹窗',
};
sendToCrashWindow(entry);
}
+45 -2
View File
@@ -1,5 +1,6 @@
import electron from 'electron'; import electron from 'electron';
import path from 'path'; import path from 'path';
import fs from 'fs';
import { registerConfigHandlers } from './handlers/config'; import { registerConfigHandlers } from './handlers/config';
import { registerAuthHandlers } from './handlers/auth'; import { registerAuthHandlers } from './handlers/auth';
import { registerInstallHandlers } from './handlers/install'; import { registerInstallHandlers } from './handlers/install';
@@ -10,17 +11,47 @@ import { registerBackgroundHandlers } from './handlers/background';
import { registerTaskHandlers } from './handlers/task'; import { registerTaskHandlers } from './handlers/task';
import { registerSystemHandlers } from './handlers/system'; import { registerSystemHandlers } from './handlers/system';
import { registerWindowHandlers } from './handlers/window'; import { registerWindowHandlers } from './handlers/window';
import { registerCrashHandlers, setupCrashListeners, testCrashDialog } from './handlers/crash-monitor';
import { loadConfig, saveConfig, configExists } from './config';
const { app } = electron; const { app } = electron;
const isDev = !app.isPackaged; const isDev = !app.isPackaged;
// Mutable ref — handlers always read from this // GPU acceleration flags
app.commandLine.appendSwitch('enable-gpu-rasterization');
app.commandLine.appendSwitch('enable-zero-copy');
const win: { mainWindow: electron.BrowserWindow | null; splashWindow: electron.BrowserWindow | null } = { const win: { mainWindow: electron.BrowserWindow | null; splashWindow: electron.BrowserWindow | null } = {
mainWindow: null, mainWindow: null,
splashWindow: null, splashWindow: null,
}; };
// Startup checks: .minecraft dir + config file + first launch detection
function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof loadConfig> } {
const dataPath = app.isPackaged
? path.dirname(app.getPath('exe'))
: path.join(__dirname, '..');
// 1. Ensure .minecraft directory exists
const minecraftDir = path.join(dataPath, '.minecraft');
if (!fs.existsSync(minecraftDir)) {
fs.mkdirSync(minecraftDir, { recursive: true });
}
// 2. Check if config exists, if not create with defaults
const hasConfig = configExists();
const isFirstLaunch = !hasConfig;
// 3. Load (or create) config
const config = loadConfig();
if (isFirstLaunch) {
saveConfig(config);
}
return { isFirstLaunch, config };
}
function createSplashWindow(): electron.BrowserWindow { function createSplashWindow(): electron.BrowserWindow {
const iconPath = isDev const iconPath = isDev
? path.join(__dirname, '../build/icon.ico') ? path.join(__dirname, '../build/icon.ico')
@@ -101,18 +132,27 @@ function registerAllHandlers() {
registerTaskHandlers(win); registerTaskHandlers(win);
registerSystemHandlers(); registerSystemHandlers();
registerWindowHandlers(win); registerWindowHandlers(win);
registerCrashHandlers();
} }
app.whenReady().then(() => { app.whenReady().then(() => {
registerAllHandlers(); registerAllHandlers();
// Run startup checks before creating windows
const { isFirstLaunch, config } = runStartupChecks();
// 1. Show splash immediately // 1. Show splash immediately
win.splashWindow = createSplashWindow(); win.splashWindow = createSplashWindow();
// 2. Create main window in background // 2. Create main window in background
win.mainWindow = createMainWindow(); win.mainWindow = createMainWindow();
// 3. When main window finishes loading, wait a minimum time then transition // 3. Preload config into renderer before it renders
win.mainWindow.webContents.on('did-finish-load', () => {
win.mainWindow?.webContents.send('config:preload', { config, isFirstLaunch });
});
// 4. When main window finishes loading, wait a minimum time then transition
let mainReady = false; let mainReady = false;
let splashMinTimeDone = false; let splashMinTimeDone = false;
@@ -134,6 +174,9 @@ app.whenReady().then(() => {
tryTransition(); tryTransition();
}); });
// Setup crash listeners on main window
setupCrashListeners(win.mainWindow);
// Minimum splash display time (1.5s) // Minimum splash display time (1.5s)
setTimeout(() => { setTimeout(() => {
splashMinTimeDone = true; splashMinTimeDone = true;
+29
View File
@@ -0,0 +1,29 @@
import electron from 'electron';
const { contextBridge, ipcRenderer } = electron;
contextBridge.exposeInMainWorld('electronAPI', {
invoke: (channel: string, ...args: unknown[]) =>
ipcRenderer.invoke(channel, ...args),
on: (channel: string, callback: (...args: unknown[]) => void) => {
const handler = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args);
ipcRenderer.on(channel, handler);
return () => ipcRenderer.removeListener(channel, handler);
},
send: (channel: string, ...args: unknown[]) =>
ipcRenderer.send(channel, ...args),
minimize: () => ipcRenderer.invoke('window:minimize'),
maximize: () => ipcRenderer.invoke('window:maximize'),
close: () => ipcRenderer.invoke('crash:closeWindow'),
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
onResized: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on('window:resized', handler);
return () => ipcRenderer.removeListener('window:resized', handler);
},
getTheme: () => ipcRenderer.invoke('window:getTheme'),
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
});
+14
View File
@@ -51,6 +51,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Theme // Theme
getTheme: () => ipcRenderer.invoke('window:getTheme'), getTheme: () => ipcRenderer.invoke('window:getTheme'),
// Config preloading
onConfigPreload: (callback: (data: { config: unknown; isFirstLaunch: boolean }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { config: unknown; isFirstLaunch: boolean }) => callback(data);
ipcRenderer.on('config:preload', handler);
return () => ipcRenderer.removeListener('config:preload', handler);
},
// Background image — pick file, copy to userData, return base64 data URL // Background image — pick file, copy to userData, return base64 data URL
pickBackgroundImage: async (): Promise<string | null> => { pickBackgroundImage: async (): Promise<string | null> => {
const result = await ipcRenderer.invoke('dialog:openFile', { const result = await ipcRenderer.invoke('dialog:openFile', {
@@ -73,4 +80,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Open external URL in system browser // Open external URL in system browser
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
// Crash monitoring — devtools only
simulateCrash: () => ipcRenderer.invoke('crash:simulate'),
testCrashDialog: () => ipcRenderer.invoke('crash:testDialog'),
// Config reset
resetConfig: () => ipcRenderer.invoke('config:reset'),
}); });
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "koring-launcher", "name": "koring-launcher",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.1",
"description": "Koring Launcher - Minecraft launcher built with Electron + React", "description": "Koring Launcher - Minecraft launcher built with Electron + React",
"author": "Shenzhen Lingke Network Technology Co., Ltd.", "author": "Shenzhen Lingke Network Technology Co., Ltd.",
"license": "LL-1.0", "license": "LL-1.0",
@@ -43,6 +43,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"motion": "^12.42.2",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
+60
View File
@@ -41,6 +41,9 @@ importers:
lucide-react: lucide-react:
specifier: ^1.21.0 specifier: ^1.21.0
version: 1.21.0(react@19.2.7) version: 1.21.0(react@19.2.7)
motion:
specifier: ^12.42.2
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -1937,6 +1940,20 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
framer-motion@12.42.2:
resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@2.0.0: fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2644,6 +2661,26 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
motion-dom@12.42.2:
resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==}
motion-utils@12.39.0:
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
motion@12.42.2:
resolution: {integrity: sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -5492,6 +5529,15 @@ snapshots:
forwarded@0.2.0: {} forwarded@0.2.0: {}
framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
motion-dom: 12.42.2
motion-utils: 12.39.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
fresh@2.0.0: {} fresh@2.0.0: {}
fs-constants@1.0.0: {} fs-constants@1.0.0: {}
@@ -6132,6 +6178,20 @@ snapshots:
mkdirp@1.0.4: {} mkdirp@1.0.4: {}
motion-dom@12.42.2:
dependencies:
motion-utils: 12.39.0
motion-utils@12.39.0: {}
motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
framer-motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
tslib: 2.8.1
optionalDependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
ms@2.1.3: {} ms@2.1.3: {}
nanoid@3.3.13: {} nanoid@3.3.13: {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

+20
View File
@@ -0,0 +1,20 @@
# v1.0.1 2607120117 beta 更新内容
## 新增
- 全新启动器界面设计
- 实例管理系统
- 启动动画(Silk Shader
- 自定义背景图片
- 深色/浅色主题切换
- 多语言支持界面
- 崩溃监控与日志系统
- 任务队列管理
## 优化
- GPU 加速渲染
- 配置文件持久化(YAML 格式)
- 窗口控制自定义实现
- 首次启动引导流程
## 修复
- 修复已知问题
+30
View File
@@ -0,0 +1,30 @@
Koring APP Beta 测试协议
更新日期:2026年6月23日
感谢您参与 Koring Launcher Beta 测试!在参与测试前,请您仔细阅读本协议。
一、测试目的
1.1 Beta 测试旨在收集用户反馈,发现并修复软件中的问题,提升产品质量。
1.2 Beta 版本可能包含尚未完善的的功能和未修复的 Bug。
二、测试风险
2.1 Beta 版本不稳定,可能导致数据丢失或游戏异常。
2.2 您应自行备份重要数据,我们不对 Beta 测试期间的数据丢失负责。
2.3 Beta 版本可能与某些模组或配置不兼容。
三、反馈义务
3.1 您同意在使用 Beta 版本期间,如发现问题及时向我们反馈。
3.2 您的反馈将帮助我们改进产品,我们可能会在产品中引用您的反馈内容。
四、保密义务
4.1 您不得公开传播 Beta 版本的截图、视频或未公开的功能信息。
4.2 未经授权,您不得将 Beta 版本分享给他人。
五、协议终止
5.1 我们有权随时终止 Beta 测试项目。
5.2 您可以随时退出 Beta 测试计划。
六、联系方式
如您对本协议有任何疑问,请联系我们:
邮箱:koring-app-beta@lenjing.email
+35
View File
@@ -0,0 +1,35 @@
Koring Team 产品用户协议
更新日期:2026年7月4日
生效日期:2026年6月1日
欢迎您使用 Koring Launcher!在使用我们的产品和服务之前,请您仔细阅读本协议。
一、服务条款的接受
1.1 本协议是您与深圳领科网络科技有限公司(以下简称"我们")之间关于使用 Koring Launcher 软件及相关服务所订立的协议。
1.2 您一旦注册、登录、使用本软件及相关服务,即视为您已充分理解并同意本协议的全部内容。
二、服务内容
2.1 Koring Launcher 是一款 Minecraft 启动器,为您提供游戏版本管理、模组管理、实例管理等功能。
2.2 我们保留随时变更、暂停或终止部分或全部服务的权利。
三、用户行为规范
3.1 您在使用本软件时,应遵守中华人民共和国相关法律法规。
3.2 您不得利用本软件从事任何违法违规活动。
3.3 您应妥善保管您的账户信息,因您的原因导致的账户安全问题,我们不承担责任。
四、知识产权
4.1 本软件的所有知识产权归深圳领科网络科技有限公司所有。
4.2 未经授权,您不得复制、修改、传播本软件的任何部分。
五、免责声明
5.1 本软件按"现状"提供服务,我们不对其适用性、准确性做出任何明示或暗示的保证。
5.2 对于因不可抗力或非我们可控的原因导致的服务中断或数据丢失,我们不承担责任。
六、协议修改
6.1 我们有权根据需要不时修改本协议,并在本软件中公布。
6.2 修改后的协议一经公布即替代原来的协议。您应定期查阅本协议。
七、联系方式
如您对本协议有任何疑问,请联系我们:
邮箱:support-koring-app@lenjing.email
+23 -1
View File
@@ -7,6 +7,7 @@ import { syncThemeFromConfig } from "./stores/themeStore";
import { syncA11yFromConfig } from "./stores/a11yStore"; import { syncA11yFromConfig } from "./stores/a11yStore";
import { syncBackgroundFromConfig } from "./stores/backgroundStore"; import { syncBackgroundFromConfig } from "./stores/backgroundStore";
import { useAuthStore } from "./stores/authStore"; import { useAuthStore } from "./stores/authStore";
import type { AppConfig } from "./api/config";
import { Home } from "./pages/home"; import { Home } from "./pages/home";
import { Store } from "./pages/store"; import { Store } from "./pages/store";
import { Today } from "./pages/today"; import { Today } from "./pages/today";
@@ -19,7 +20,13 @@ import { SplashDebug } from "./pages/debug/splash-debug";
import { DisplayDebug } from "./pages/debug/display-debug"; import { DisplayDebug } from "./pages/debug/display-debug";
import { VersionCardDebug } from "./pages/debug/version-card-debug"; import { VersionCardDebug } from "./pages/debug/version-card-debug";
import { TaskDebug } from "./pages/debug/task-debug"; import { TaskDebug } from "./pages/debug/task-debug";
import { CrashDebug } from "./pages/debug/crash-debug";
import { Oobe } from "./pages/oobe"; 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"; import { OobeAboutInfo } from "./pages/oobe/about-info";
const pageMap = { const pageMap = {
@@ -31,12 +38,18 @@ const pageMap = {
gallery: Gallery, gallery: Gallery,
"task-queue": TaskQueue, "task-queue": TaskQueue,
oobe: Oobe, oobe: Oobe,
"oobe/language": OobeLanguage,
"oobe/agreement": OobeAgreement,
"oobe/version": OobeVersion,
"oobe/beta-test": OobeBetaTest,
"oobe/finish": OobeFinish,
"oobe/about-info": OobeAboutInfo, "oobe/about-info": OobeAboutInfo,
debug: Debug, debug: Debug,
"debug-splash": SplashDebug, "debug-splash": SplashDebug,
"debug-display": DisplayDebug, "debug-display": DisplayDebug,
"debug-version-card": VersionCardDebug, "debug-version-card": VersionCardDebug,
"debug-task": TaskDebug, "debug-task": TaskDebug,
"debug-crash": CrashDebug,
} as const; } as const;
function App() { function App() {
@@ -45,12 +58,21 @@ function App() {
const Page = pageMap[current]; const Page = pageMap[current];
useEffect(() => { 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(); syncThemeFromConfig();
syncA11yFromConfig(); syncA11yFromConfig();
syncBackgroundFromConfig(); syncBackgroundFromConfig();
useAuthStore.getState().initFromRegistry(); 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 ( return (
+1
View File
@@ -62,6 +62,7 @@ export interface NetworkConfig {
export interface AppConfig { export interface AppConfig {
version: number; version: number;
oobe: boolean;
theme: ThemeConfig; theme: ThemeConfig;
a11y: A11yConfig; a11y: A11yConfig;
background: BackgroundConfig; 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 ( return (
<Canvas <Canvas
dpr={[1, 2]} dpr={[1, 1.5]}
frameloop="demand"
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", background: "black" }} 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} /> <SilkPlane ref={meshRef} uniforms={uniforms} />
</Canvas> </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 { type ReactNode } from "react";
import { BackgroundLayer } from "@/components/background/BackgroundLayer"; import { BackgroundLayer } from "@/components/background/BackgroundLayer";
import { SystemLayer } from "@/components/system/SystemLayer"; import { SystemLayer } from "@/components/system/SystemLayer";
import { StartupPopup } from "@/components/StartupPopup"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { BetaWarning } from "@/components/BetaWarning";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { useA11yStore } from "@/stores/a11yStore"; import { useA11yStore } from "@/stores/a11yStore";
import clsx from "clsx"; import clsx from "clsx";
@@ -52,11 +51,8 @@ export function RootLayout({
showClose={showClose} showClose={showClose}
/> />
{/* Startup popup — only when VITE_START_POP=true */} {/* Global confirm dialog */}
<StartupPopup /> <ConfirmDialog />
{/* Beta warning toast */}
<BetaWarning />
{/* Sonner toaster */} {/* Sonner toaster */}
<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 { 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 = [ 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, key: "debug-splash" as const,
icon: Monitor, icon: Monitor,
+2 -1
View File
@@ -9,6 +9,7 @@ import {
Clock, Clock,
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { VERSION } from "@/lib/version";
interface InfoItem { interface InfoItem {
icon: typeof Package; icon: typeof Package;
@@ -35,7 +36,7 @@ export function OobeAboutInfo() {
const items: InfoItem[] = systemInfo && localeInfo 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: 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: 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" }, { 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 { useRouteStore } from "@/stores/routeStore";
import { Rocket, ChevronRight } from "lucide-react"; import { useEffect, useState, useRef } from "react";
export function Oobe() { const words = ["Hello.", "你好。", "こんにちは。", "안녕하세요。", "Bonjour."];
const goBack = useRouteStore((s) => s.goBack);
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 ( return (
<div className="h-full flex flex-col items-center justify-center px-8"> <span className="inline-block min-w-[200px] text-center">
<div className="flex flex-col items-center text-center max-w-md"> {displayed}
<div className="p-4 rounded-2xl bg-primary/10 mb-6"> <span className="inline-block w-[2px] h-[1em] bg-foreground/60 ml-0.5 align-middle animate-pulse" />
<Rocket className="w-10 h-10 text-primary" /> </span>
</div> );
}
<h1 className="text-2xl font-bold text-foreground mb-2"> export function Oobe() {
使 Koring Launcher const navigate = useRouteStore((s) => s.navigate);
</h1>
<p className="text-sm text-muted-foreground mb-8 leading-relaxed">
OOBE
</p>
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 <button
onClick={goBack} onClick={() => navigate("oobe/language")}
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" 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> </button>
</div> </div>
</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 { VersionCard } from "@/components/VersionCard";
import { BUILD_MODE } from "@/lib/mode"; 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 }) { function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>; 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"; const OFFICIAL_URL = "https://koring.app";
export function AboutSetting() { export function AboutSetting() {
const openDialog = useConfirmDialogStore((s) => s.openDialog);
const openLink = (url: string) => { const openLink = (url: string) => {
window.electronAPI?.openExternal(url); window.electronAPI?.openExternal(url);
}; };
const handleResetClick = () => {
openDialog({
title: "您确定要还原所有配置吗?",
description: "您还原后,您的实例将会保留,但是所有个性化配置将全部丢失,并且需要重新进行激活",
confirmLabel: "确认还原",
countdown: 5,
onConfirm: () => window.electronAPI?.resetConfig(),
});
};
return ( return (
<div> <div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2> <h2 className="text-xl font-bold text-foreground mb-1"></h2>
@@ -92,6 +105,22 @@ export function AboutSetting() {
</GlassCard> </GlassCard>
</div> </div>
</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>
</div> </div>
); );
+58 -27
View File
@@ -14,22 +14,23 @@ import {
} from "@/api/config"; } from "@/api/config";
import { DEFAULT_BG } from "@/lib/mode"; import { DEFAULT_BG } from "@/lib/mode";
// TODO: re-enable when ready let saveTimer: ReturnType<typeof setTimeout> | null = null;
// let saveTimer: ReturnType<typeof setTimeout> | null = null; function debouncedSave(config: AppConfig) {
// function debouncedSave(config: AppConfig) { if (saveTimer) clearTimeout(saveTimer);
// if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => {
// saveTimer = setTimeout(() => { saveConfig(config).catch((e) => {
// saveConfig(config).catch((e) => { console.error("[config] save failed:", e);
// console.error("[config] save failed:", e); });
// }); }, 300);
// }, 300); }
// }
interface ConfigState { interface ConfigState {
config: AppConfig; config: AppConfig;
loaded: boolean; loaded: boolean;
isFirstLaunch: boolean;
init: () => Promise<void>; init: () => Promise<void>;
applyPreloaded: (config: AppConfig, isFirstLaunch: boolean) => void;
setTheme: (patch: Partial<ThemeConfig>) => void; setTheme: (patch: Partial<ThemeConfig>) => void;
setA11y: (patch: Partial<A11yConfig>) => void; setA11y: (patch: Partial<A11yConfig>) => void;
setBackground: (patch: Partial<BackgroundConfig>) => void; setBackground: (patch: Partial<BackgroundConfig>) => void;
@@ -38,10 +39,12 @@ interface ConfigState {
setAdvanced: (patch: Partial<AdvancedConfig>) => void; setAdvanced: (patch: Partial<AdvancedConfig>) => void;
setDownload: (patch: Partial<DownloadConfig>) => void; setDownload: (patch: Partial<DownloadConfig>) => void;
setNetwork: (patch: Partial<NetworkConfig>) => void; setNetwork: (patch: Partial<NetworkConfig>) => void;
setOobe: (value: boolean) => void;
} }
const DEFAULT_CONFIG: AppConfig = { const DEFAULT_CONFIG: AppConfig = {
version: 1, version: 1,
oobe: true,
theme: { darkMode: "auto", parallax: true }, theme: { darkMode: "auto", parallax: true },
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 }, a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
background: { bgType: "image", image: DEFAULT_BG, blur: 0, opacity: 100 }, 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) => ({ export const useConfigStore = create<ConfigState>((set, get) => ({
config: DEFAULT_CONFIG, config: DEFAULT_CONFIG,
loaded: false, loaded: false,
isFirstLaunch: false,
applyPreloaded: (config, isFirstLaunch) => {
set({ config, isFirstLaunch, loaded: true });
},
init: async () => { init: async () => {
// TODO: re-enable when ready load from Rust/Koring.yml // If already preloaded, skip IPC call
// try { if (get().loaded) return;
// const config = await getConfig(); try {
// set({ config, loaded: true }); const config = await getConfig();
// } catch (e) { set({ config, loaded: true });
// console.error("[config] init failed, using defaults:", e); } catch (e) {
// set({ loaded: true }); console.error("[config] init failed, using defaults:", e);
// } set({ loaded: true });
set({ loaded: true }); }
}, },
setTheme: (patch) => { setTheme: (patch) => {
const { config } = get(); const { config } = get();
set({ config: { ...config, theme: { ...config.theme, ...patch } } }); const next = { ...config, theme: { ...config.theme, ...patch } };
set({ config: next });
debouncedSave(next);
}, },
setA11y: (patch) => { setA11y: (patch) => {
const { config } = get(); const { config } = get();
set({ config: { ...config, a11y: { ...config.a11y, ...patch } } }); const next = { ...config, a11y: { ...config.a11y, ...patch } };
set({ config: next });
debouncedSave(next);
}, },
setBackground: (patch) => { setBackground: (patch) => {
const { config } = get(); const { config } = get();
set({ config: { ...config, background: { ...config.background, ...patch } } }); const next = { ...config, background: { ...config.background, ...patch } };
set({ config: next });
debouncedSave(next);
}, },
setGame: (patch) => { setGame: (patch) => {
const { config } = get(); const { config } = get();
set({ config: { ...config, game: { ...config.game, ...patch } } }); const next = { ...config, game: { ...config.game, ...patch } };
set({ config: next });
debouncedSave(next);
}, },
setJava: (patch) => { setJava: (patch) => {
const { config } = get(); const { config } = get();
set({ config: { ...config, java: { ...config.java, ...patch } } }); const next = { ...config, java: { ...config.java, ...patch } };
set({ config: next });
debouncedSave(next);
}, },
setAdvanced: (patch) => { setAdvanced: (patch) => {
const { config } = get(); const { config } = get();
set({ config: { ...config, advanced: { ...config.advanced, ...patch } } }); const next = { ...config, advanced: { ...config.advanced, ...patch } };
set({ config: next });
debouncedSave(next);
}, },
setDownload: (patch) => { setDownload: (patch) => {
const { config } = get(); const { config } = get();
set({ config: { ...config, download: { ...config.download, ...patch } } }); const next = { ...config, download: { ...config.download, ...patch } };
set({ config: next });
debouncedSave(next);
}, },
setNetwork: (patch) => { setNetwork: (patch) => {
const { config } = get(); 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" | "gallery"
| "task-queue" | "task-queue"
| "oobe" | "oobe"
| "oobe/language"
| "oobe/agreement"
| "oobe/version"
| "oobe/beta-test"
| "oobe/finish"
| "oobe/about-info" | "oobe/about-info"
| "debug" | "debug"
| "debug-splash" | "debug-splash"
| "debug-display" | "debug-display"
| "debug-version-card" | "debug-version-card"
| "debug-task"; | "debug-task"
| "debug-crash";
export type TitleBarMode = "default" | "sub" | "window" | "oobe"; export type TitleBarMode = "default" | "sub" | "window" | "oobe";
@@ -41,6 +47,11 @@ export const allRoutes: RouteItem[] = [
...routes, ...routes,
{ key: "task-queue", label: "任务队列", path: "/task-queue", hidden: true }, { key: "task-queue", label: "任务队列", path: "/task-queue", hidden: true },
{ key: "oobe", label: "OOBE", path: "/oobe", 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: "oobe/about-info", label: "关于信息", path: "/oobe/about-info", hidden: true, backable: true },
{ key: "debug", label: "调试", path: "/debug", hidden: true }, { key: "debug", label: "调试", path: "/debug", hidden: true },
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", 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)); const topLevelKeys = new Set(routes.map((r) => r.key));
function getRouteTitleBarMode(key: RouteKey): TitleBarMode { function getRouteTitleBarMode(key: RouteKey): TitleBarMode {
if (key === "oobe") return "oobe"; if (key === "oobe" || key.startsWith("oobe/")) return "oobe";
return topLevelKeys.has(key) ? "default" : "sub"; return topLevelKeys.has(key) ? "default" : "sub";
} }
+9
View File
@@ -11,7 +11,16 @@ interface ElectronAPI {
getTheme: () => Promise<'light' | 'dark' | 'system' | null>; getTheme: () => Promise<'light' | 'dark' | 'system' | null>;
onConfigPreload: (callback: (data: { config: unknown; isFirstLaunch: boolean }) => void) => () => void;
openExternal: (url: string) => Promise<void>; openExternal: (url: string) => Promise<void>;
// Crash monitoring
simulateCrash: () => Promise<void>;
testCrashDialog: () => Promise<void>;
// Config reset
resetConfig: () => Promise<void>;
} }
declare global { declare global {
+1 -6
View File
@@ -1,11 +1,6 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { 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 ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv; readonly env: ImportMetaEnv;
+1
View File
@@ -20,6 +20,7 @@ export default defineConfig(async () => ({
input: { input: {
main: path.resolve(__dirname, "index.html"), main: path.resolve(__dirname, "index.html"),
splash: path.resolve(__dirname, "splash.html"), splash: path.resolve(__dirname, "splash.html"),
crash: path.resolve(__dirname, "crash.html"),
}, },
}, },
}, },