日志模块(文件持久化 + 事件总线联动 + 脱敏)
新增:
- shared/types/logging.ts — LogLevel / LogCategory / LogEntry 类型
- electron/main/logger.ts — 主进程日志核心(JSON Lines / 每日轮转 / 7天清理)
- electron/main/log-ipc.ts — 渲染进程日志 IPC 通道接收
- src/utils/logger.ts — 渲染进程日志器(IPC 转发 / DEV 控制台 / 事件总线钩子)
- shared/utils/sanitize.ts — 脱敏工具(ID哈希 / 邮箱 / 手机 / 名称 / Token)
修改:
- electron/main.ts — initLogger + flushLogger + uncaughtException
- src/main.tsx — renderer 全局错误捕获
- src/utils/event-bus.ts — setEventLogListener 钩子,emit 自动审计
- src/services/request.ts — 拦截器结构化错误分级记录
- electron/main/updater.ts — 14处 console → logger 迁移
- src/components/AppProvider.tsx — 登录/登出/自动登录 auth 事件日志
- shared/constants/ipc-channels.ts — 新增 LOG_MESSAGE 通道
netRequest 封装(Electron net.request 的 axios 风格 API)
新增:
- electron/main/net-request.ts — netRequest.get/post/put/del 命名空间
修改:
- electron/main/updater.ts — fetchUpdateInfo 改用 netRequest(22→12行)
文档更新
- PROJECT.md — 目录结构 / 状态管理 / 日志系统 / IPC 章节同步现状
- TODO.md — 增量更新改造 + 日志模块待办
- UpdateA.md — API blockmap 字段 / 数据库字段说明
305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
// ============================================================
|
||
// 自动更新模块 — 自定义 API 版本检查 + OSS 文件下载
|
||
//
|
||
// 架构:
|
||
// 客户端 → GET /api/v1/update/check → ECS (API) → 返回 JSON
|
||
// 客户端 → GET <downloadUrl> → OSS → 下载安装包
|
||
// 下载完成 → SHA512 校验 → spawn 安装器 → app.quit()
|
||
//
|
||
// 与旧版 (electron-updater generic provider) 的区别:
|
||
// - 版本检查走自定义 API,后端可做灰度/强更/统计
|
||
// - 不再依赖 latest.yml 静态文件
|
||
// - 安装包仍托管于 OSS,下载地址由 API 动态返回
|
||
//
|
||
// 环境变量:
|
||
// UPDATE_API_URL — 版本检查 API 基地址(默认 https://www.heixiu.com)
|
||
// ============================================================
|
||
|
||
import { BrowserWindow, net, app, ipcMain } from 'electron';
|
||
import { createWriteStream } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { createHash } from 'node:crypto';
|
||
import { spawn } from 'node:child_process';
|
||
import { unlink } from 'node:fs/promises';
|
||
|
||
import type { UpdateCheckResult } from '../../shared/types';
|
||
import { APP_VERSION } from '../../shared/constants/version';
|
||
import { logger } from './logger';
|
||
import { netRequest } from './net-request';
|
||
|
||
// ---------- IPC 通道(对渲染进程暴露,与旧版兼容)----------
|
||
|
||
export const UPDATE_CHANNELS = {
|
||
UPDATE_AVAILABLE: 'update-available',
|
||
UPDATE_PROGRESS: 'update-progress',
|
||
UPDATE_DOWNLOADED: 'update-downloaded',
|
||
UPDATE_ERROR: 'update-error',
|
||
UPDATE_NOT_AVAILABLE: 'update-not-available',
|
||
INSTALL_UPDATE: 'install-update',
|
||
CHECK_FOR_UPDATE: 'check-for-update',
|
||
} as const;
|
||
|
||
// ---------- 配置 ----------
|
||
|
||
function getApiBaseUrl(): string {
|
||
return process.env.UPDATE_API_URL || 'https://www.heixiu.com';
|
||
}
|
||
|
||
// ---------- 状态 ----------
|
||
|
||
let updateWindow: BrowserWindow | null = null;
|
||
let updateChecked = false;
|
||
/** 当前正在下载的临时文件路径(用于安装后清理) */
|
||
let pendingInstallerPath: string | null = null;
|
||
|
||
// ============================================================
|
||
// 初始化
|
||
// ============================================================
|
||
|
||
export function initUpdater(mainWindow: BrowserWindow): void {
|
||
updateWindow = mainWindow;
|
||
|
||
if (import.meta.env.DEV) {
|
||
logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
|
||
return;
|
||
}
|
||
|
||
// 启动 5 秒后静默检查
|
||
setTimeout(() => {
|
||
checkForUpdates();
|
||
}, 5000);
|
||
}
|
||
|
||
// ============================================================
|
||
// 版本检查(调用 API)
|
||
// ============================================================
|
||
|
||
async function fetchUpdateInfo(): Promise<UpdateCheckResult | null> {
|
||
const { data } = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
|
||
'/api/v1/update/check',
|
||
{
|
||
baseURL: getApiBaseUrl(),
|
||
params: {
|
||
platform: process.platform,
|
||
version: APP_VERSION,
|
||
edition: process.env.EDITION || 'personal',
|
||
},
|
||
},
|
||
);
|
||
|
||
// 兼容两种 API 响应格式:
|
||
// 格式 A: { code: 0, data: UpdateCheckResult }
|
||
// 格式 B: UpdateCheckResult 直接返回
|
||
const result: UpdateCheckResult = (data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
|
||
|
||
if (!result.hasUpdate) {
|
||
logger.info('updater', '当前已是最新版本');
|
||
return null;
|
||
}
|
||
|
||
logger.info('updater', '发现新版本', { latestVersion: result.latestVersion });
|
||
return result;
|
||
}
|
||
|
||
// ============================================================
|
||
// 下载安装包
|
||
// ============================================================
|
||
|
||
async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
|
||
const ext =
|
||
process.platform === 'win32' ? '.exe' : process.platform === 'darwin' ? '.dmg' : '.AppImage';
|
||
const tempPath = join(tmpdir(), `HeiXiu-${info.latestVersion}-update${ext}`);
|
||
|
||
logger.info('updater', '开始下载', { downloadUrl: info.downloadUrl, tempPath });
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const req = net.request({ method: 'GET', url: info.downloadUrl });
|
||
const fileStream = createWriteStream(tempPath);
|
||
const hash = createHash('sha512');
|
||
const totalSize = info.fileSize || 0;
|
||
let downloadedSize = 0;
|
||
let lastReportTime = 0;
|
||
|
||
req.on('response', (response) => {
|
||
const statusCode = response.statusCode;
|
||
if (statusCode !== 200) {
|
||
fileStream.close();
|
||
unlink(tempPath).catch(() => {});
|
||
return reject(new Error(`下载失败,HTTP ${statusCode}`));
|
||
}
|
||
|
||
response.on('data', (chunk: Buffer) => {
|
||
fileStream.write(chunk);
|
||
hash.update(chunk);
|
||
downloadedSize += chunk.length;
|
||
|
||
// 限频:最多每 250ms 推送一次进度
|
||
const now = Date.now();
|
||
if (totalSize > 0 && now - lastReportTime >= 250) {
|
||
lastReportTime = now;
|
||
const percent = Math.round((downloadedSize / totalSize) * 100);
|
||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, {
|
||
percent,
|
||
bytesPerSecond: 0, // 简化处理,不计算实时速度
|
||
transferred: downloadedSize,
|
||
total: totalSize,
|
||
});
|
||
}
|
||
});
|
||
|
||
response.on('end', () => {
|
||
fileStream.end();
|
||
fileStream.on('finish', () => {
|
||
// SHA512 校验
|
||
const actualSha512 = hash.digest('base64');
|
||
if (info.sha512 && actualSha512 !== info.sha512) {
|
||
unlink(tempPath).catch(() => {});
|
||
return reject(
|
||
new Error(
|
||
`SHA512 校验失败\n期望: ${info.sha512.slice(0, 16)}...\n实际: ${actualSha512.slice(0, 16)}...`,
|
||
),
|
||
);
|
||
}
|
||
|
||
logger.info('updater', '下载完成,SHA512 校验通过');
|
||
pendingInstallerPath = tempPath;
|
||
resolve(tempPath);
|
||
});
|
||
});
|
||
|
||
response.on('error', (err) => {
|
||
fileStream.close();
|
||
unlink(tempPath).catch(() => {});
|
||
reject(new Error(`下载中断: ${err.message}`));
|
||
});
|
||
});
|
||
|
||
req.on('error', (err) => {
|
||
fileStream.close();
|
||
unlink(tempPath).catch(() => {});
|
||
reject(new Error(`下载请求失败: ${err.message}`));
|
||
});
|
||
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 操作方法
|
||
// ============================================================
|
||
|
||
export async function checkForUpdates(): Promise<void> {
|
||
if (updateChecked) {
|
||
logger.debug('updater', '已检查过更新,跳过');
|
||
return;
|
||
}
|
||
|
||
if (import.meta.env.DEV) {
|
||
logger.debug('updater', '开发模式,跳过更新检查');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
updateChecked = true;
|
||
const info = await fetchUpdateInfo();
|
||
|
||
if (!info) {
|
||
// 无更新,不发通知(静默)
|
||
return;
|
||
}
|
||
|
||
// 通知渲染进程:有新版本
|
||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
|
||
version: info.latestVersion,
|
||
releaseDate: info.releaseDate,
|
||
releaseNotes: info.changelog,
|
||
forceUpdate: info.forceUpdate,
|
||
});
|
||
|
||
// 自动开始下载
|
||
await downloadInstaller(info);
|
||
|
||
// 下载完成 → 通知渲染进程
|
||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
|
||
} catch (error) {
|
||
logger.error('updater', '更新流程失败', error as Error, { apiUrl: getApiBaseUrl() });
|
||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||
message: (error as Error).message || '更新检查失败',
|
||
});
|
||
updateChecked = false;
|
||
}
|
||
}
|
||
|
||
export async function checkForUpdatesManual(): Promise<void> {
|
||
try {
|
||
updateChecked = false;
|
||
const info = await fetchUpdateInfo();
|
||
|
||
if (!info) {
|
||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
|
||
return;
|
||
}
|
||
|
||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
|
||
version: info.latestVersion,
|
||
releaseDate: info.releaseDate,
|
||
releaseNotes: info.changelog,
|
||
forceUpdate: info.forceUpdate,
|
||
});
|
||
|
||
await downloadInstaller(info);
|
||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
|
||
} catch (error) {
|
||
logger.error('updater', '手动更新失败', error as Error);
|
||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||
message: (error as Error).message || '检查更新失败',
|
||
});
|
||
}
|
||
}
|
||
|
||
export function installUpdateNow(): void {
|
||
if (!pendingInstallerPath) {
|
||
logger.warn('updater', '没有待安装的更新文件');
|
||
return;
|
||
}
|
||
|
||
logger.info('updater', '开始安装更新', { installerPath: pendingInstallerPath });
|
||
|
||
if (process.platform === 'win32') {
|
||
// Windows: NSIS 安装器,/S 静默安装
|
||
spawn(pendingInstallerPath, ['/S'], {
|
||
detached: true,
|
||
stdio: 'ignore',
|
||
});
|
||
} else if (process.platform === 'darwin') {
|
||
// macOS: 挂载 DMG
|
||
spawn('hdiutil', ['attach', pendingInstallerPath], {
|
||
detached: true,
|
||
stdio: 'ignore',
|
||
});
|
||
} else {
|
||
// Linux: AppImage,chmod +x 后执行(通常需要手动替换)
|
||
logger.warn('updater', 'Linux 更新需手动替换 AppImage 文件');
|
||
}
|
||
|
||
// 退出应用,让安装器接管
|
||
setImmediate(() => {
|
||
app.quit();
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// IPC 注册
|
||
// ============================================================
|
||
|
||
export function registerUpdateIpcHandlers(): void {
|
||
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
|
||
await checkForUpdatesManual();
|
||
});
|
||
|
||
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
|
||
installUpdateNow();
|
||
});
|
||
}
|