mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
feat(更新模块): 提升更新安装安全性并添加统一主进程日志
重构更新验证流程:下载包验证失败时保持为已下载状态并标记校验未通过,而非直接置为错误状态 添加安装校验未通过包时的确认弹窗,支持删除异常安装包 禁用应用退出时自动安装,防止静默安装未校验的包 新增统一主进程日志模块,支持渲染进程通过IPC桥接接入日志 更新已下载但校验失败的UI提示文案
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 统一日志(主进程)。
|
||||
*
|
||||
* 规则:
|
||||
* - 默认(非 debug):warn/error/info 输出到控制台,debug 不输出;
|
||||
* - 用户开启「调试模式」(config.advanced.debugMode,设置→游戏→高级):
|
||||
* debug 也输出控制台,并把全部级别写入 userData/koring.log(超过 5MB 自动轮转为 .old);
|
||||
* - 渲染进程经 `log:write`(ipcRenderer.send)汇入同一套格式/文件。
|
||||
*/
|
||||
|
||||
import electron from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const { ipcMain, app } = electron;
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
export interface Logger {
|
||||
debug: (msg: string, ...args: unknown[]) => void;
|
||||
info: (msg: string, ...args: unknown[]) => void;
|
||||
warn: (msg: string, ...args: unknown[]) => void;
|
||||
error: (msg: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
let debugModeProvider: () => boolean = () => false;
|
||||
export function setDebugModeProvider(fn: () => boolean): void {
|
||||
debugModeProvider = fn;
|
||||
}
|
||||
export function isDebugMode(): boolean {
|
||||
try {
|
||||
return debugModeProvider();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let logStream: fs.WriteStream | null = null;
|
||||
let cachedFilePath: string | null = null;
|
||||
|
||||
export function getLogFilePath(): string | null {
|
||||
if (!isDebugMode()) return null;
|
||||
if (!cachedFilePath) cachedFilePath = path.join(app.getPath('userData'), 'koring.log');
|
||||
return cachedFilePath;
|
||||
}
|
||||
|
||||
function openLogStream(): void {
|
||||
if (logStream) return;
|
||||
const filePath = getLogFilePath();
|
||||
if (!filePath) return;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).size > MAX_LOG_BYTES) {
|
||||
try {
|
||||
fs.renameSync(filePath, `${filePath}.old`);
|
||||
} catch {
|
||||
// 轮转失败不阻塞
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 目录不可用等
|
||||
}
|
||||
logStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
|
||||
logStream.on('error', () => {
|
||||
logStream = null;
|
||||
});
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
const d = new Date();
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function truncateString(value: string, max: number): string {
|
||||
if (value.length <= max) return value;
|
||||
return `${value.slice(0, max)}…(+${value.length - max}字符)`;
|
||||
}
|
||||
|
||||
function summarizeArg(value: unknown): unknown {
|
||||
if (typeof value === 'string') return truncateString(value, 300);
|
||||
if (value instanceof Error) return truncateString(value.message, 300);
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
try {
|
||||
const json = JSON.stringify(value, (_key, v) => {
|
||||
if (typeof v === 'string') return truncateString(v, 160);
|
||||
if (v instanceof Error) return truncateString(v.message, 160);
|
||||
if (Array.isArray(v) && v.length > 20) return `[Array(${v.length})]`;
|
||||
return v;
|
||||
});
|
||||
return truncateString(json ?? String(value), 400);
|
||||
} catch {
|
||||
return truncateString(String(value), 300);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function write(scope: string, level: LogLevel, args: unknown[]): void {
|
||||
const msg = args.map((a) => {
|
||||
const s = summarizeArg(a);
|
||||
return typeof s === 'string' ? s : String(s);
|
||||
}).join(' ');
|
||||
const line = `[${timestamp()}][${scope}][${level.toUpperCase()}] ${msg}`;
|
||||
|
||||
const enabled = isDebugMode();
|
||||
// debug 仅在调试模式可见;info/warn/error 始终走控制台
|
||||
const showConsole = level !== 'debug' || enabled;
|
||||
if (showConsole) {
|
||||
if (level === 'error') console.error(line);
|
||||
else if (level === 'warn') console.warn(line);
|
||||
else if (level === 'info') console.info(line);
|
||||
else console.debug(line);
|
||||
}
|
||||
if (!enabled) return;
|
||||
openLogStream();
|
||||
if (logStream) {
|
||||
logStream.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function makeLogger(scope: string): Logger {
|
||||
const bound = (level: LogLevel) => (msg: string, ...args: unknown[]) => write(scope, level, [msg, ...args]);
|
||||
return {
|
||||
debug: bound('debug'),
|
||||
info: bound('info'),
|
||||
warn: bound('warn'),
|
||||
error: bound('error'),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLogger(scope: string): Logger {
|
||||
return makeLogger(scope);
|
||||
}
|
||||
|
||||
// ---------------- IPC 日志(全局包装 ipcMain.handle) ----------------
|
||||
|
||||
function isResultLike(value: unknown): value is { success?: boolean; error?: unknown } {
|
||||
return typeof value === 'object' && value !== null && 'success' in value;
|
||||
}
|
||||
|
||||
function summarize(payload: unknown[]): unknown[] {
|
||||
return payload.map(summarizeArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装所有 ipcMain.handle:每次调用记录 channel、耗时与成败。
|
||||
* 必须在业务 handler 注册前调用(main.ts 顶层)。
|
||||
*/
|
||||
export function installIpcLogging(): void {
|
||||
const rawHandle = ipcMain.handle.bind(ipcMain);
|
||||
const log = makeLogger('ipc');
|
||||
(ipcMain as unknown as { handle: typeof ipcMain.handle }).handle = (
|
||||
channel: string,
|
||||
listener: (event: Electron.IpcMainInvokeEvent, ...args: unknown[]) => unknown,
|
||||
) => {
|
||||
const wrapped = async (event: Electron.IpcMainInvokeEvent, ...args: unknown[]): Promise<unknown> => {
|
||||
log.debug(`→ ${channel}`, summarize(args));
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await listener(event, ...args);
|
||||
const ms = Date.now() - started;
|
||||
const failed = isResultLike(result) && result.success === false;
|
||||
if (failed) {
|
||||
log.error(`✗ ${channel} (${ms}ms)`, isResultLike(result) ? (result.error ?? 'unknown error') : 'failed');
|
||||
} else {
|
||||
log.debug(`← ${channel} ok (${ms}ms)`, summarize([result]));
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
const ms = Date.now() - started;
|
||||
log.error(`! ${channel} 异常 (${ms}ms)`, err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
return rawHandle(channel, wrapped as never);
|
||||
};
|
||||
}
|
||||
|
||||
/** 渲染进程日志桥:ipcRenderer.send('log:write', { level, scope, message }) 汇入统一日志 */
|
||||
export function registerRendererLogBridge(): void {
|
||||
ipcMain.on('log:write', (_event, payload: unknown) => {
|
||||
try {
|
||||
const p = payload as { level?: string; scope?: string; message?: string } | null;
|
||||
if (!p || typeof p.message !== 'string') return;
|
||||
const level = (p.level === 'debug' || p.level === 'info' || p.level === 'warn' || p.level === 'error') ? p.level : 'info';
|
||||
const scope = typeof p.scope === 'string' && p.scope ? p.scope : 'renderer';
|
||||
write(`renderer/${scope}`, level, [p.message]);
|
||||
} catch {
|
||||
// 日志桥异常不抛给渲染进程
|
||||
}
|
||||
});
|
||||
ipcMain.handle('log:getInfo', () => ({
|
||||
filePath: isDebugMode() ? getLogFilePath() : null,
|
||||
debugMode: isDebugMode(),
|
||||
}));
|
||||
}
|
||||
@@ -62,9 +62,10 @@ export function registerUpdateHandlers() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('update:quitAndInstall', () => {
|
||||
ipcMain.handle('update:quitAndInstall', async () => {
|
||||
try {
|
||||
updateService.quitAndInstall();
|
||||
// 未核验通过时内部会先弹确认框(继续安装 / 取消并删除安装包)
|
||||
await updateService.quitAndInstall();
|
||||
return { success: true, data: null, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
|
||||
+29
-5
@@ -19,11 +19,24 @@ import { updateService } from './updater';
|
||||
import { saveConfig, configExists, getConfig, flushConfig, configPath } from './config';
|
||||
import { findCachedBackgroundRaw, optimizeBackgroundFile, recoverBackgroundFromDataUrl } from './core/background-image';
|
||||
import { registerResourceSchemePrivileges, registerResourceProtocol } from './resource-protocol';
|
||||
import { createLogger, setDebugModeProvider, installIpcLogging, registerRendererLogBridge } from './core/logger';
|
||||
|
||||
const { app } = electron;
|
||||
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
// 统一日志:debug 模式(config.advanced.debugMode)→ 控制台 + userData/koring.log;否则仅控制台
|
||||
const log = createLogger('main');
|
||||
setDebugModeProvider(() => {
|
||||
try {
|
||||
return getConfig()?.advanced?.debugMode === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// 全局包装 ipcMain.handle:所有 IPC 流程自动带 channel/耗时/成败日志(须在 handler 注册前调用)
|
||||
installIpcLogging();
|
||||
|
||||
// GPU acceleration flags
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||
app.commandLine.appendSwitch('enable-zero-copy');
|
||||
@@ -51,9 +64,9 @@ function migrateLegacyFiles(): void {
|
||||
if (fs.existsSync(dest)) return;
|
||||
try {
|
||||
fs.copyFileSync(src, dest);
|
||||
console.log(`[migrate] copied Koring.yml ${src} → ${dest}`);
|
||||
log.info(`[migrate] 已复制 Koring.yml ${src} → ${dest}`);
|
||||
} catch (e) {
|
||||
console.error(`[migrate] failed to copy Koring.yml:`, e);
|
||||
log.error('[migrate] 复制 Koring.yml 失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +112,9 @@ function migrateBackgroundDataUrlToPath(config: ReturnType<typeof getConfig>): v
|
||||
if (!result || !result.filePath) return;
|
||||
bg.image = result.filePath;
|
||||
saveConfig(config);
|
||||
console.log('[background] 迁移:dataURL → 文件路径', result.filePath);
|
||||
log.info(`[background] 迁移:dataURL → 文件路径 ${result.filePath}`);
|
||||
} catch (e) {
|
||||
console.error('[background] 背景配置迁移失败:', e);
|
||||
log.error('[background] 背景配置迁移失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,8 +220,11 @@ function registerAllHandlers() {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
log.info('应用就绪,开始初始化主进程');
|
||||
registerAllHandlers();
|
||||
registerResourceProtocol();
|
||||
registerRendererLogBridge();
|
||||
log.info('IPC handlers / 资源协议 / 渲染日志桥注册完成');
|
||||
|
||||
// Migrate legacy install-dir config back to userData before anything reads them
|
||||
migrateLegacyFiles();
|
||||
@@ -216,17 +232,20 @@ app.whenReady().then(() => {
|
||||
// Run startup checks before creating windows
|
||||
const { isFirstLaunch, config } = runStartupChecks();
|
||||
isFirstLaunchFlag = isFirstLaunch;
|
||||
log.info(`启动检查完成 isFirstLaunch=${isFirstLaunch} 配置路径=${configPath()}`);
|
||||
|
||||
// 旧配置中 background.image 若是 BASE64 dataURL → 落盘优化并改写为文件路径
|
||||
migrateBackgroundDataUrlToPath(config);
|
||||
|
||||
// 1. Show splash immediately
|
||||
win.splashWindow = createSplashWindow();
|
||||
log.info('Splash 窗口已创建');
|
||||
|
||||
// 2. Create main window in background
|
||||
// (config:preload 推送已内置于 createMainWindow 的 did-finish-load 监听,
|
||||
// 每次加载/刷新都推送 getConfig() 的最新内存配置)
|
||||
win.mainWindow = createMainWindow();
|
||||
log.info('主窗口已创建(后台加载)');
|
||||
|
||||
// 3. When main window finishes loading, wait a minimum time then transition
|
||||
let mainReady = false;
|
||||
@@ -237,10 +256,12 @@ app.whenReady().then(() => {
|
||||
if (win.mainWindow && !win.mainWindow.isDestroyed()) {
|
||||
win.mainWindow.show();
|
||||
win.mainWindow.focus();
|
||||
log.info('主窗口已显示,切换完成');
|
||||
}
|
||||
if (win.splashWindow && !win.splashWindow.isDestroyed()) {
|
||||
win.splashWindow.close();
|
||||
win.splashWindow = null;
|
||||
log.info('Splash 窗口已关闭');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -261,19 +282,22 @@ app.whenReady().then(() => {
|
||||
|
||||
// 延迟静默检查更新(避开启动加载,不抢带宽;开发模式在 updater.init 内自动跳过)
|
||||
setTimeout(() => {
|
||||
log.info('触发启动静默更新检查');
|
||||
updateService.check(false).catch((e) => {
|
||||
console.error('[updater] 启动静默检查失败:', e);
|
||||
log.error('[updater] 启动静默检查失败:', e);
|
||||
});
|
||||
}, 12000);
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
log.info('所有窗口已关闭,flush 配置并退出');
|
||||
flushConfig();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (electron.BrowserWindow.getAllWindows().length === 0) {
|
||||
log.info('activate:重建主窗口');
|
||||
win.mainWindow = createMainWindow();
|
||||
}
|
||||
});
|
||||
|
||||
+54
-13
@@ -189,7 +189,9 @@ class UpdateService {
|
||||
}
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
// 安装一律走我们受控的 quitAndInstall(含核验与确认弹窗),
|
||||
// 禁止 electron-updater 在退出时静默自动安装(否则未核验/核验失败的包可能被直接装上)
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
this.applyChannel();
|
||||
autoUpdater.logger = console;
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
@@ -220,16 +222,18 @@ class UpdateService {
|
||||
this.progress = p;
|
||||
this.emit();
|
||||
});
|
||||
// 下载完成 → 本地核验安装包(sha512 + 大小)通过后才置为"已下载可安装";
|
||||
// 核验失败 → 进入 error,拒绝安装(防下载损坏 / 篡改)
|
||||
// 下载完成 → 本地核验安装包(sha512 + 大小)。
|
||||
// · 通过:进入"已下载可安装"(verified=true)
|
||||
// · 失败:仍进入"已下载",但 verified=false + 记录原因 —— 点"安装"会弹确认框
|
||||
// (继续安装 / 取消并删除安装包),绝不静默安装未核验包
|
||||
autoUpdater.on('update-downloaded', async (info) => {
|
||||
const errMsg = await this.verifyDownloadedPackage();
|
||||
if (errMsg) {
|
||||
console.error(`[updater] 安装包核验失败: ${errMsg}`);
|
||||
this.state = 'error';
|
||||
this.error = errMsg;
|
||||
this.version = undefined;
|
||||
this.state = 'downloaded';
|
||||
this.verified = false;
|
||||
this.version = info.version;
|
||||
this.error = errMsg;
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
@@ -237,6 +241,7 @@ class UpdateService {
|
||||
this.state = 'downloaded';
|
||||
this.verified = true;
|
||||
this.version = info.version;
|
||||
this.error = undefined;
|
||||
this.emit();
|
||||
});
|
||||
autoUpdater.on('error', (err: Error) => {
|
||||
@@ -680,17 +685,42 @@ class UpdateService {
|
||||
/**
|
||||
* 退出并安装(NSIS 静默安装,安装完成自动重启)。
|
||||
* 安装状态先写入配置并立即落盘,避免退出时 debounce 未写盘。
|
||||
* 安装包未通过核验(verified=false)时先弹确认框:
|
||||
* 继续安装 / 取消并删除安装包 —— 绝不静默安装校验异常的文件。
|
||||
*/
|
||||
quitAndInstall(): void {
|
||||
async quitAndInstall(): Promise<void> {
|
||||
if (!this.ready || this.state !== 'downloaded') return;
|
||||
// 安装前必须已通过本地核验(sha512 + 大小),防损坏/篡改包被安装
|
||||
|
||||
if (!this.verified) {
|
||||
console.warn('[updater] 安装被拒:安装包未通过核验');
|
||||
this.state = 'error';
|
||||
this.error = '安装包未通过核验,已拒绝安装,请重新检查并下载更新';
|
||||
this.emit();
|
||||
return;
|
||||
const { dialog, BrowserWindow } = electron;
|
||||
const parent = BrowserWindow.getAllWindows().find((w) => w.isVisible()) ?? null;
|
||||
const opts: electron.MessageBoxOptions = {
|
||||
type: 'warning',
|
||||
title: '版本校验异常',
|
||||
message: '请注意,版本校验异常,可能是文件损坏或者被替换,因此您会看到此弹窗,您可以选择继续安装或取消并删除安装包',
|
||||
detail: this.error ? `核验详情:${this.error}` : '核验详情:sha512 校验和与发布记录不一致',
|
||||
buttons: ['继续安装', '取消并删除安装包'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
};
|
||||
const { response } = parent
|
||||
? await dialog.showMessageBox(parent, opts)
|
||||
: await dialog.showMessageBox(opts);
|
||||
if (response !== 0) {
|
||||
// 取消并删除安装包
|
||||
console.warn('[updater] 用户取消安装并删除校验异常包');
|
||||
await this.removeDownloadedPackage().catch((e) => console.warn('[updater] 删除安装包失败:', e));
|
||||
this.state = 'idle';
|
||||
this.version = undefined;
|
||||
this.error = undefined;
|
||||
this.verified = false;
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
console.warn('[updater] 用户确认继续安装(校验异常但已确认)');
|
||||
}
|
||||
|
||||
this.state = 'installing';
|
||||
this.emit();
|
||||
try {
|
||||
@@ -702,6 +732,17 @@ class UpdateService {
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
}
|
||||
|
||||
/** 删除已下载(校验失败)的安装包 */
|
||||
private async removeDownloadedPackage(): Promise<void> {
|
||||
const helper = (autoUpdater as unknown as { downloadedUpdateHelper?: { file?: string } }).downloadedUpdateHelper;
|
||||
const filePath = helper?.file;
|
||||
if (!filePath) return;
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.promises.unlink(filePath);
|
||||
console.log(`[updater] 已删除安装包: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取系统下载临时目录(用于清理提示,暂未启用) */
|
||||
getCacheDir(): string {
|
||||
return os.tmpdir();
|
||||
|
||||
@@ -197,7 +197,7 @@ export function UpdatePage() {
|
||||
: st === "downloaded"
|
||||
? status?.verified
|
||||
? "更新已下载完成(安装包已核验,点击安装)"
|
||||
: "更新已下载完成"
|
||||
: "版本校验异常:可能是文件损坏或被替换,点击安装将弹出确认框"
|
||||
: st === "installing"
|
||||
? "正在安装更新,应用即将重启..."
|
||||
: st === "error"
|
||||
|
||||
Reference in New Issue
Block a user