diff --git a/.env.beta b/.env.beta index 8ec26a1..eb23526 100644 --- a/.env.beta +++ b/.env.beta @@ -1,7 +1,2 @@ VITE_BUILD_MODE=beta VITE_APP_ICON=/beta.png - -VITE_START_POP=true -VITE_START_POP_TITLE="你正在使用 UI 预览版本" -VITE_START_POP_INFO="此版本是专门用于进行检测是否有 UI 问题的版本,因此功能将会缺失" -VITE_START_POP_BOUTTON="我已了解" \ No newline at end of file diff --git a/.env.development b/.env.development index aed8c10..468d727 100644 --- a/.env.development +++ b/.env.development @@ -1,7 +1,2 @@ VITE_BUILD_MODE=dev VITE_APP_ICON=/dev.png - -VITE_START_POP=true -VITE_START_POP_TITLE="你正在使用 UI 预览版本" -VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失" -VITE_START_POP_BOUTTON="我已了解" \ No newline at end of file diff --git a/.env.production b/.env.production index 1be13d2..ce78263 100644 --- a/.env.production +++ b/.env.production @@ -1,7 +1,2 @@ VITE_BUILD_MODE=run VITE_APP_ICON=/run.png - -VITE_START_POP=false -VITE_START_POP_TITLE="你正在使用 UI 预览版本" -VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失" -VITE_START_POP_BOUTTON="我已了解" \ No newline at end of file diff --git a/CRASH_MONITOR_PLAN.md b/CRASH_MONITOR_PLAN.md deleted file mode 100644 index 1805e90..0000000 --- a/CRASH_MONITOR_PLAN.md +++ /dev/null @@ -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? diff --git a/Koring.yml b/Koring.yml new file mode 100644 index 0000000..055ce30 --- /dev/null +++ b/Koring.yml @@ -0,0 +1 @@ +oobe: false diff --git a/crash.html b/crash.html new file mode 100644 index 0000000..930ac6c --- /dev/null +++ b/crash.html @@ -0,0 +1,12 @@ + + + + + + Koring Launcher - Crash + + +
+ + + diff --git a/electron/config.ts b/electron/config.ts index f8195de..add8ae5 100644 --- a/electron/config.ts +++ b/electron/config.ts @@ -7,7 +7,7 @@ const { app } = electron; const CONFIG_FILE = 'Koring.yml'; const CURRENT_VERSION = 1; -function configPath(): string { +export function configPath(): string { if (app.isPackaged) { return path.join(path.dirname(app.getPath('exe')), CONFIG_FILE); } @@ -76,6 +76,7 @@ export interface NetworkConfig { export interface AppConfig { version: number; + oobe: boolean; theme: ThemeConfig; a11y: A11yConfig; background: BackgroundConfig; @@ -88,6 +89,7 @@ export interface AppConfig { const DEFAULTS: AppConfig = { version: CURRENT_VERSION, + oobe: true, theme: { darkMode: 'auto', parallax: true }, a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 }, 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; } +export function configExists(): boolean { + try { + return fs.existsSync(configPath()); + } catch { + return false; + } +} + export function loadConfig(): AppConfig { const filePath = configPath(); if (!fs.existsSync(filePath)) { diff --git a/electron/core/crash-logger.js b/electron/core/crash-logger.js new file mode 100644 index 0000000..e0a31eb --- /dev/null +++ b/electron/core/crash-logger.js @@ -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 { } +} diff --git a/electron/core/crash-logger.ts b/electron/core/crash-logger.ts new file mode 100644 index 0000000..e6d336a --- /dev/null +++ b/electron/core/crash-logger.ts @@ -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; +} + +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 {} +} diff --git a/electron/handlers/config.js b/electron/handlers/config.js index 9bdb6c5..65239e8 100644 --- a/electron/handlers/config.js +++ b/electron/handlers/config.js @@ -1,11 +1,45 @@ "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.registerConfigHandlers = registerConfigHandlers; 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"); function registerConfigHandlers() { ipcMain.handle('config:get', () => { @@ -26,4 +60,18 @@ function registerConfigHandlers() { 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) }; + } + }); } diff --git a/electron/handlers/config.ts b/electron/handlers/config.ts index 403b24f..a21a99a 100644 --- a/electron/handlers/config.ts +++ b/electron/handlers/config.ts @@ -1,6 +1,8 @@ import electron from 'electron'; -const { ipcMain } = electron; -import { loadConfig, saveConfig, type AppConfig } from '../config'; +const { ipcMain, app } = electron; +import * as fs from 'fs'; +import * as path from 'path'; +import { loadConfig, saveConfig, type AppConfig, configPath } from '../config'; export function registerConfigHandlers() { ipcMain.handle('config:get', () => { @@ -20,4 +22,18 @@ export function registerConfigHandlers() { 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) }; + } + }); } diff --git a/electron/handlers/crash-monitor.js b/electron/handlers/crash-monitor.js new file mode 100644 index 0000000..ff498a9 --- /dev/null +++ b/electron/handlers/crash-monitor.js @@ -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); +} diff --git a/electron/handlers/crash-monitor.ts b/electron/handlers/crash-monitor.ts new file mode 100644 index 0000000..cd1c283 --- /dev/null +++ b/electron/handlers/crash-monitor.ts @@ -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, + }; + 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); +} diff --git a/electron/main.ts b/electron/main.ts index cfb2d96..fd82c4e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,5 +1,6 @@ import electron from 'electron'; import path from 'path'; +import fs from 'fs'; import { registerConfigHandlers } from './handlers/config'; import { registerAuthHandlers } from './handlers/auth'; import { registerInstallHandlers } from './handlers/install'; @@ -10,17 +11,47 @@ import { registerBackgroundHandlers } from './handlers/background'; import { registerTaskHandlers } from './handlers/task'; import { registerSystemHandlers } from './handlers/system'; import { registerWindowHandlers } from './handlers/window'; +import { registerCrashHandlers, setupCrashListeners, testCrashDialog } from './handlers/crash-monitor'; +import { loadConfig, saveConfig, configExists } from './config'; const { app } = electron; 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 } = { mainWindow: null, splashWindow: null, }; +// Startup checks: .minecraft dir + config file + first launch detection +function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType } { + 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 { const iconPath = isDev ? path.join(__dirname, '../build/icon.ico') @@ -101,18 +132,27 @@ function registerAllHandlers() { registerTaskHandlers(win); registerSystemHandlers(); registerWindowHandlers(win); + registerCrashHandlers(); } app.whenReady().then(() => { registerAllHandlers(); + // Run startup checks before creating windows + const { isFirstLaunch, config } = runStartupChecks(); + // 1. Show splash immediately win.splashWindow = createSplashWindow(); // 2. Create main window in background 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 splashMinTimeDone = false; @@ -134,6 +174,9 @@ app.whenReady().then(() => { tryTransition(); }); + // Setup crash listeners on main window + setupCrashListeners(win.mainWindow); + // Minimum splash display time (1.5s) setTimeout(() => { splashMinTimeDone = true; diff --git a/electron/preload-crash.ts b/electron/preload-crash.ts new file mode 100644 index 0000000..8c96c2b --- /dev/null +++ b/electron/preload-crash.ts @@ -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), +}); diff --git a/electron/preload.ts b/electron/preload.ts index 2477892..e78b7eb 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -51,6 +51,13 @@ contextBridge.exposeInMainWorld('electronAPI', { // Theme 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 pickBackgroundImage: async (): Promise => { const result = await ipcRenderer.invoke('dialog:openFile', { @@ -73,4 +80,11 @@ contextBridge.exposeInMainWorld('electronAPI', { // Open external URL in system browser 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'), }); diff --git a/package.json b/package.json index e7517bc..7ce8e00 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "koring-launcher", "private": true, - "version": "1.0.0", + "version": "1.0.1", "description": "Koring Launcher - Minecraft launcher built with Electron + React", "author": "Shenzhen Lingke Network Technology Co., Ltd.", "license": "LL-1.0", @@ -43,6 +43,7 @@ "clsx": "^2.1.1", "js-yaml": "^4.1.0", "lucide-react": "^1.21.0", + "motion": "^12.42.2", "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f752c01..3112337 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: lucide-react: specifier: ^1.21.0 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: specifier: ^0.4.6 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==} 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: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -2644,6 +2661,26 @@ packages: engines: {node: '>=10'} 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: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5492,6 +5529,15 @@ snapshots: 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: {} fs-constants@1.0.0: {} @@ -6132,6 +6178,20 @@ snapshots: 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: {} nanoid@3.3.13: {} diff --git a/public/background-3.png b/public/background-3.png new file mode 100644 index 0000000..0457b74 Binary files /dev/null and b/public/background-3.png differ diff --git a/public/changelog-1.0.1.txt b/public/changelog-1.0.1.txt new file mode 100644 index 0000000..218ad20 --- /dev/null +++ b/public/changelog-1.0.1.txt @@ -0,0 +1,20 @@ +# v1.0.1 2607120117 beta 更新内容 + +## 新增 +- 全新启动器界面设计 +- 实例管理系统 +- 启动动画(Silk Shader) +- 自定义背景图片 +- 深色/浅色主题切换 +- 多语言支持界面 +- 崩溃监控与日志系统 +- 任务队列管理 + +## 优化 +- GPU 加速渲染 +- 配置文件持久化(YAML 格式) +- 窗口控制自定义实现 +- 首次启动引导流程 + +## 修复 +- 修复已知问题 diff --git a/public/protocol-beta.txt b/public/protocol-beta.txt new file mode 100644 index 0000000..4145455 --- /dev/null +++ b/public/protocol-beta.txt @@ -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 diff --git a/public/protocol-user.txt b/public/protocol-user.txt new file mode 100644 index 0000000..b5ad3ab --- /dev/null +++ b/public/protocol-user.txt @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 1f44169..77c59d9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( diff --git a/src/api/config.ts b/src/api/config.ts index fbfbd99..2dc8056 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -62,6 +62,7 @@ export interface NetworkConfig { export interface AppConfig { version: number; + oobe: boolean; theme: ThemeConfig; a11y: A11yConfig; background: BackgroundConfig; diff --git a/src/components/BetaWarning.tsx b/src/components/BetaWarning.tsx deleted file mode 100644 index 61cb4e1..0000000 --- a/src/components/BetaWarning.tsx +++ /dev/null @@ -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; -} diff --git a/src/components/StartupPopup.tsx b/src/components/StartupPopup.tsx deleted file mode 100644 index 5e648f9..0000000 --- a/src/components/StartupPopup.tsx +++ /dev/null @@ -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 ( - - - -
- -
- {title} - {info} -
- - setOpen(false)}> - {buttonText} - - -
-
- ); -} diff --git a/src/components/silk/Silk.tsx b/src/components/silk/Silk.tsx index 10ed645..9fa9e36 100644 --- a/src/components/silk/Silk.tsx +++ b/src/components/silk/Silk.tsx @@ -124,9 +124,10 @@ const Silk = ({ speed = 5, scale = 1, color = "#7B7481", noiseIntensity = 1.5, r return ( diff --git a/src/components/ui/apple-hello-effect.tsx b/src/components/ui/apple-hello-effect.tsx new file mode 100644 index 0000000..d4d1be5 --- /dev/null +++ b/src/components/ui/apple-hello-effect.tsx @@ -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 & { + speed?: number + onAnimationComplete?: () => void +} + +function AppleHelloEnglishEffect({ className, speed = 1, onAnimationComplete, ...props }: Props) { + const calc = (x: number) => x * speed + + return ( + + hello + + {/* h1 */} + + + {/* h2, ello */} + + + ) +} + +export { AppleHelloEnglishEffect} + diff --git a/src/components/ui/confirm-dialog.tsx b/src/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000..06d6251 --- /dev/null +++ b/src/components/ui/confirm-dialog.tsx @@ -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 ( +
+
+

{title}

+

{description}

+
+ + +
+
+
+ ); +} diff --git a/src/crash.tsx b/src/crash.tsx new file mode 100644 index 0000000..cb9cab4 --- /dev/null +++ b/src/crash.tsx @@ -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( + + + , +); diff --git a/src/layouts/RootLayout.tsx b/src/layouts/RootLayout.tsx index cf1a2b1..5c9c669 100644 --- a/src/layouts/RootLayout.tsx +++ b/src/layouts/RootLayout.tsx @@ -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 */} - - - {/* Beta warning toast */} - + {/* Global confirm dialog */} + {/* Sonner toaster */} (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 ( +
+ {/* 顶栏 — 只有关闭按钮 */} +
+
+ + Koring Launcher + +
+ +
+ +
+
+ + {/* 崩溃内容 */} +
+
+ {/* 图标 */} +
+
+ + + + + +
+
+ + {/* 标题 */} +
+

+ 程序发生崩溃 +

+

+ 我们已记录此错误,接下来您想做什么? +

+
+ + + {/* 说明文字 */} +

+ 您可能是在一些奇怪的地方触发了内容导致崩溃,我们已准备好日志,你可以直接复制将其发送给支持人员,你也可以点击下方的重启按钮再次启动,如果还是不行您可以点击强还原配置按钮进行还原。(注意,还原只会清除启动器数据,并不会影响实例,请放心使用) +

+ + {/* 按钮组 */} +
+ + + + + +
+
+
+
+ ); +} diff --git a/src/pages/debug/crash-debug.tsx b/src/pages/debug/crash-debug.tsx new file mode 100644 index 0000000..df171b4 --- /dev/null +++ b/src/pages/debug/crash-debug.tsx @@ -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 ( +
+

崩溃测试

+

测试崩溃检测、崩溃弹窗与恢复功能

+ +
+ {/* 模拟崩溃 */} +
+
+ +

模拟渲染进程崩溃

+
+

+ 调用 forcefullyCrashRenderer() 强制销毁渲染进程,触发 render-process-gone 事件,崩溃弹窗应自动弹出。 +

+ +
+ + {/* 测试崩溃弹窗 */} +
+
+ +

测试崩溃弹窗

+
+

+ 不会真正崩溃,直接弹出崩溃弹窗 UI,用于验证窗口样式、按钮功能是否正常。 +

+ +
+ + {/* 崩溃日志 */} +
+
+ +

崩溃日志

+
+

+ 读取 koring-crash.log 文件内容,可复制发送给开发人员。 +

+
+ + +
+ {log && ( +
+              {log}
+            
+ )} +
+ + {/* 强还原配置 */} +
+
+ +

强还原配置

+
+

+ 删除 Koring.yml、 + koring-auth.json 和背景缓存。 + 不会影响实例数据。 +

+ +
+ + {/* 状态 */} + {status && ( +

{status}

+ )} +
+
+ ); +} diff --git a/src/pages/debug/index.tsx b/src/pages/debug/index.tsx index 05ce642..1d9e135 100644 --- a/src/pages/debug/index.tsx +++ b/src/pages/debug/index.tsx @@ -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, diff --git a/src/pages/oobe/about-info.tsx b/src/pages/oobe/about-info.tsx index f3aa5ad..fa22ec1 100644 --- a/src/pages/oobe/about-info.tsx +++ b/src/pages/oobe/about-info.tsx @@ -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" }, diff --git a/src/pages/oobe/index.tsx b/src/pages/oobe/index.tsx index 487d464..4b4ea6c 100644 --- a/src/pages/oobe/index.tsx +++ b/src/pages/oobe/index.tsx @@ -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>(); + + 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 ( -
-
-
- -
+ + {displayed} + + + ); +} -

- 欢迎使用 Koring Launcher -

-

- 这是 OOBE(开箱体验)页面。在这里可以引导用户完成初始设置。 -

+export function Oobe() { + const navigate = useRouteStore((s) => s.navigate); + return ( +
+ {/* 中心动画文字 */} +
+ +
+ + {/* 下方按钮 */} +
diff --git a/src/pages/oobe/layout.tsx b/src/pages/oobe/layout.tsx new file mode 100644 index 0000000..7f06e9e --- /dev/null +++ b/src/pages/oobe/layout.tsx @@ -0,0 +1,13 @@ +import { type ReactNode } from "react"; + +interface OobeLayoutProps { + children: ReactNode; +} + +export function OobeLayout({ children }: OobeLayoutProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/pages/oobe/next-button.tsx b/src/pages/oobe/next-button.tsx new file mode 100644 index 0000000..9a9eabb --- /dev/null +++ b/src/pages/oobe/next-button.tsx @@ -0,0 +1,18 @@ +interface NextButtonProps { + onClick: () => void; + disabled?: boolean; +} + +export function NextButton({ onClick, disabled = false }: NextButtonProps) { + return ( +
+ +
+ ); +} diff --git a/src/pages/oobe/step-agreement.tsx b/src/pages/oobe/step-agreement.tsx new file mode 100644 index 0000000..73351ee --- /dev/null +++ b/src/pages/oobe/step-agreement.tsx @@ -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 ( + +
+ {/* 标题 */} +
+

Koring Team 产品用户协议

+

您需要同意才可以继续

+
+ + {/* 协议内容 */} +
+
+            {text}
+          
+
+ + {/* 勾选框 */} + +
+ + navigate("oobe/version")} disabled={!checked} /> +
+ ); +} diff --git a/src/pages/oobe/step-beta-test.tsx b/src/pages/oobe/step-beta-test.tsx new file mode 100644 index 0000000..8ae87ce --- /dev/null +++ b/src/pages/oobe/step-beta-test.tsx @@ -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 ( + +
+ + 正在确认版本信息,并激活... +
+
+ ); + } + + return ( + +
+ {/* 标题 */} +
+

Koring APP Beta 测试协议

+

您需要同意才可以继续

+
+ + {/* 协议内容 */} +
+
+            {text}
+          
+
+ + {/* 勾选框 */} + +
+ + navigate("oobe/finish")} disabled={!checked} /> +
+ ); +} diff --git a/src/pages/oobe/step-finish.tsx b/src/pages/oobe/step-finish.tsx new file mode 100644 index 0000000..849556a --- /dev/null +++ b/src/pages/oobe/step-finish.tsx @@ -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 ( + +
+ +
+ +
+ +
+
+ ); +} diff --git a/src/pages/oobe/step-language.tsx b/src/pages/oobe/step-language.tsx new file mode 100644 index 0000000..4232ffe --- /dev/null +++ b/src/pages/oobe/step-language.tsx @@ -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 ( + +
+ {languages.map((lang) => ( + + ))} +
+ + navigate("oobe/agreement")} /> +
+ ); +} diff --git a/src/pages/oobe/step-version.tsx b/src/pages/oobe/step-version.tsx new file mode 100644 index 0000000..417e6f0 --- /dev/null +++ b/src/pages/oobe/step-version.tsx @@ -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 ( + +
+ {/* 标题 */} +

核对您的版本信息

+ + {/* 版本号 + Badge */} +
+ + v{VERSION} + + {badgeLabel && ( + + {badgeLabel} + + )} +
+ + {/* 测试版警告 */} + {isTestBuild && ( +

+ 您正在使用测试版本,它并不稳定,不建议用于正式游戏体验,具体内容请以发行版本为准。 +

+ )} + + {/* 更新内容 */} +
+
+            {changelog}
+          
+
+
+ + navigate(nextRoute)} /> +
+ ); +} diff --git a/src/pages/setting/general/about.tsx b/src/pages/setting/general/about.tsx index f3b4bf3..6fa845f 100644 --- a/src/pages/setting/general/about.tsx +++ b/src/pages/setting/general/about.tsx @@ -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
{children}
; @@ -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 (

关于

@@ -92,6 +105,22 @@ export function AboutSetting() {
+ + {/* 危险操作 */} +
+

危险操作

+ + + + + +
); diff --git a/src/stores/configStore.ts b/src/stores/configStore.ts index f10ddfb..84ae845 100644 --- a/src/stores/configStore.ts +++ b/src/stores/configStore.ts @@ -14,22 +14,23 @@ import { } from "@/api/config"; import { DEFAULT_BG } from "@/lib/mode"; -// TODO: re-enable when ready -// let saveTimer: ReturnType | 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 | 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; + applyPreloaded: (config: AppConfig, isFirstLaunch: boolean) => void; setTheme: (patch: Partial) => void; setA11y: (patch: Partial) => void; setBackground: (patch: Partial) => void; @@ -38,10 +39,12 @@ interface ConfigState { setAdvanced: (patch: Partial) => void; setDownload: (patch: Partial) => void; setNetwork: (patch: Partial) => 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((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); }, })); diff --git a/src/stores/confirmDialogStore.ts b/src/stores/confirmDialogStore.ts new file mode 100644 index 0000000..65a1e7f --- /dev/null +++ b/src/stores/confirmDialogStore.ts @@ -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((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 }), +})); diff --git a/src/stores/routeStore.ts b/src/stores/routeStore.ts index eea9d75..ae99d8c 100644 --- a/src/stores/routeStore.ts +++ b/src/stores/routeStore.ts @@ -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"; } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 890a10f..0b2a868 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -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; + + // Crash monitoring + simulateCrash: () => Promise; + testCrashDialog: () => Promise; + + // Config reset + resetConfig: () => Promise; } declare global { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 9943448..2964b0b 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,11 +1,6 @@ /// -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; diff --git a/vite.config.ts b/vite.config.ts index 7734e71..848daa4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,6 +20,7 @@ export default defineConfig(async () => ({ input: { main: path.resolve(__dirname, "index.html"), splash: path.resolve(__dirname, "splash.html"), + crash: path.resolve(__dirname, "crash.html"), }, }, },