feat: 日志模块 + netRequest 封装 + PROJECT.md 更新
日志模块(文件持久化 + 事件总线联动 + 脱敏)
新增:
- 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 字段 / 数据库字段说明
This commit is contained in:
@@ -8,6 +8,8 @@ import { getWindowIconPath } from './main/utils/logo';
|
||||
import { buildWindowTitle, parseEdition } from '../shared/constants/app';
|
||||
import { initUpdater, registerUpdateIpcHandlers } from './main/updater';
|
||||
import { setupAppMenu } from './main/menu';
|
||||
import { initLogger, flushLogger, logger } from './main/logger';
|
||||
import { registerLogIpcHandlers } from './main/log-ipc';
|
||||
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
|
||||
|
||||
createRequire(import.meta.url);
|
||||
@@ -28,6 +30,27 @@ const currentPlatform = getPlatform();
|
||||
const currentEdition = parseEdition(process.env.EDITION);
|
||||
const WINDOW_TITLE = buildWindowTitle(currentPlatform, currentEdition);
|
||||
|
||||
// ============================================================
|
||||
// 全局未捕获异常(主进程兜底记录)
|
||||
// ============================================================
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
try {
|
||||
logger.error('app', 'Uncaught exception (main process)', error);
|
||||
} catch {
|
||||
/* logger 自身异常 — 最后防线 */
|
||||
}
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
try {
|
||||
const error = reason instanceof Error ? reason : new Error(String(reason));
|
||||
logger.error('app', 'Unhandled rejection (main process)', error);
|
||||
} catch {
|
||||
/* logger 自身异常 — 最后防线 */
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 窗口引用
|
||||
// ============================================================
|
||||
@@ -112,10 +135,12 @@ app.on('activate', () => {
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// 清理工作
|
||||
flushLogger();
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
initLogger();
|
||||
registerLogIpcHandlers();
|
||||
setupAppMenu();
|
||||
app.setName(WINDOW_TITLE);
|
||||
registerUpdateIpcHandlers();
|
||||
|
||||
21
electron/main/log-ipc.ts
Normal file
21
electron/main/log-ipc.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// ============================================================
|
||||
// 日志 IPC 通道注册 — 主进程接收渲染进程日志
|
||||
// ============================================================
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc-channels';
|
||||
import { writeRendererLog } from './logger';
|
||||
import type { LogEntry } from '../../shared/types/logging';
|
||||
|
||||
/**
|
||||
* 注册日志 IPC 监听器
|
||||
* 使用 ipcMain.on(fire-and-forget)而非 handle(无需响应)
|
||||
* 在 app.whenReady() 中调用
|
||||
*/
|
||||
export function registerLogIpcHandlers(): void {
|
||||
ipcMain.on(RENDERER_TO_MAIN.LOG_MESSAGE, (_event, entry: LogEntry) => {
|
||||
// 基础校验:防御渲染进程异常数据
|
||||
if (!entry || !entry.level || !entry.category || !entry.message) return;
|
||||
writeRendererLog(entry);
|
||||
});
|
||||
}
|
||||
251
electron/main/logger.ts
Normal file
251
electron/main/logger.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// ============================================================
|
||||
// 主进程日志核心 — 文件写入 / 轮转 / 清理
|
||||
//
|
||||
// 架构:
|
||||
// - 日志写入 {userData}/logs/app-YYYY-MM-DD.log(JSON Lines 格式)
|
||||
// - 每日轮转:日期变化时自动创建新文件
|
||||
// - 保留策略:7 天前旧文件自动删除
|
||||
// - 写入队列批量冲刷(setImmediate 聚合同一 tick 的日志)
|
||||
// - 惰性初始化:app.getPath('userData') 在 ready 后才可用,
|
||||
// initLogger() 在 whenReady 中显式调用预热
|
||||
//
|
||||
// 用法(主进程):
|
||||
// import { logger, initLogger } from './main/logger';
|
||||
// logger.info('updater', '检查更新', { version: '0.1.0' });
|
||||
//
|
||||
// 渲染进程日志通过 log-ipc.ts IPC 通道写入,调用 writeRendererLog()
|
||||
// ============================================================
|
||||
|
||||
import { app } from 'electron';
|
||||
import { createWriteStream, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { WriteStream } from 'node:fs';
|
||||
import type { LogEntry, LogLevel, LogCategory, LogErrorSnapshot } from '../../shared/types/logging';
|
||||
import { sanitizeLogEntry } from '../../shared/utils/sanitize';
|
||||
|
||||
// ---------- 内部状态 ----------
|
||||
|
||||
let writeStream: WriteStream | null = null;
|
||||
let currentDate = '';
|
||||
let logDir = '';
|
||||
const writeQueue: string[] = [];
|
||||
let isWriting = false;
|
||||
const MAX_DAYS = 7;
|
||||
|
||||
// ---------- 辅助函数 ----------
|
||||
|
||||
function getDateStr(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getLogFilePath(date: string): string {
|
||||
return join(logDir, `app-${date}.log`);
|
||||
}
|
||||
|
||||
/** 确保 logs/ 目录存在 */
|
||||
function ensureDir(): void {
|
||||
if (logDir) return;
|
||||
try {
|
||||
logDir = join(app.getPath('userData'), 'logs');
|
||||
} catch {
|
||||
// app.getPath 尚不可用(app.ready 之前),静默跳过
|
||||
return;
|
||||
}
|
||||
if (!logDir) return;
|
||||
try {
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
} catch {
|
||||
/* 目录创建失败 — 后续写入静默丢弃 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 日期轮转:日切时关闭旧流 → 创建新文件 → 清理过期文件 */
|
||||
function rotateIfNeeded(): void {
|
||||
const today = getDateStr();
|
||||
if (currentDate === today && writeStream) return;
|
||||
|
||||
if (writeStream) {
|
||||
try {
|
||||
writeStream.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
writeStream = null;
|
||||
}
|
||||
|
||||
try {
|
||||
writeStream = createWriteStream(getLogFilePath(today), { flags: 'a' });
|
||||
currentDate = today;
|
||||
} catch {
|
||||
writeStream = null;
|
||||
}
|
||||
|
||||
purgeOldLogs();
|
||||
}
|
||||
|
||||
/** 清理超过 MAX_DAYS 天的旧日志文件 */
|
||||
function purgeOldLogs(): void {
|
||||
if (!logDir) return;
|
||||
const now = Date.now();
|
||||
const maxAge = MAX_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
try {
|
||||
const files = readdirSync(logDir);
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('app-') || !file.endsWith('.log')) continue;
|
||||
try {
|
||||
const stat = statSync(join(logDir, file));
|
||||
if (now - stat.mtimeMs > maxAge) {
|
||||
unlinkSync(join(logDir, file));
|
||||
}
|
||||
} catch {
|
||||
/* skip inaccessible files */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 目录读取失败 — 下次轮转再试 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量冲刷写入队列 */
|
||||
function processQueue(): void {
|
||||
if (isWriting || writeQueue.length === 0) return;
|
||||
isWriting = true;
|
||||
|
||||
ensureDir();
|
||||
if (logDir) {
|
||||
rotateIfNeeded();
|
||||
if (writeStream) {
|
||||
const batch = writeQueue.splice(0).join('');
|
||||
try {
|
||||
writeStream.write(batch);
|
||||
} catch {
|
||||
/* 写入失败 — 数据丢失,但日志不应阻断业务 */
|
||||
}
|
||||
} else {
|
||||
writeQueue.length = 0;
|
||||
}
|
||||
} else {
|
||||
// app ready 前尚无 userData 路径 — 丢弃(此时距 ready 极近)
|
||||
writeQueue.length = 0;
|
||||
}
|
||||
|
||||
isWriting = false;
|
||||
}
|
||||
|
||||
/** 错误对象 → 可序列化快照 */
|
||||
function errorToSnapshot(err: Error): LogErrorSnapshot {
|
||||
return {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
cause: (err as { cause?: unknown }).cause
|
||||
? String((err as { cause?: unknown }).cause)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建日志条目 */
|
||||
function createEntry(
|
||||
level: LogLevel,
|
||||
category: LogCategory,
|
||||
message: string,
|
||||
processType: 'main' | 'renderer',
|
||||
error?: Error,
|
||||
context?: Record<string, unknown>,
|
||||
): LogEntry {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
category,
|
||||
message,
|
||||
processType,
|
||||
};
|
||||
if (context && Object.keys(context).length > 0) {
|
||||
entry.context = context;
|
||||
}
|
||||
if (error) {
|
||||
entry.error = errorToSnapshot(error);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** 内部写入入口 */
|
||||
function write(
|
||||
level: LogLevel,
|
||||
category: LogCategory,
|
||||
message: string,
|
||||
error?: Error,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
const entry = sanitizeLogEntry(
|
||||
createEntry(level, category, message, 'main', error, context),
|
||||
);
|
||||
writeQueue.push(JSON.stringify(entry) + '\n');
|
||||
// 首个入队项触发 setImmediate 批量冲刷
|
||||
if (writeQueue.length === 1) {
|
||||
setImmediate(processQueue);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 对外 API ----------
|
||||
|
||||
/** 主进程日志器(与渲染进程 API 完全一致) */
|
||||
export const logger = {
|
||||
debug(category: LogCategory, message: string, context?: Record<string, unknown>): void {
|
||||
write('DEBUG', category, message, undefined, context);
|
||||
},
|
||||
info(category: LogCategory, message: string, context?: Record<string, unknown>): void {
|
||||
write('INFO', category, message, undefined, context);
|
||||
},
|
||||
warn(
|
||||
category: LogCategory,
|
||||
message: string,
|
||||
error?: Error,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
write('WARN', category, message, error, context);
|
||||
},
|
||||
error(
|
||||
category: LogCategory,
|
||||
message: string,
|
||||
error?: Error,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
write('ERROR', category, message, error, context);
|
||||
},
|
||||
};
|
||||
|
||||
/** 预热日志模块(在 app.whenReady() 中调用) */
|
||||
export function initLogger(): void {
|
||||
ensureDir();
|
||||
if (logDir) {
|
||||
rotateIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收渲染进程日志并写入文件
|
||||
* 由 log-ipc.ts 的 IPC 监听器调用
|
||||
*/
|
||||
export function writeRendererLog(entry: LogEntry): void {
|
||||
writeQueue.push(JSON.stringify(entry) + '\n');
|
||||
if (writeQueue.length === 1) {
|
||||
setImmediate(processQueue);
|
||||
}
|
||||
}
|
||||
|
||||
/** 冲刷所有未写入日志并关闭写入流(在 app.before-quit 中调用) */
|
||||
export function flushLogger(): void {
|
||||
if (writeQueue.length > 0) {
|
||||
processQueue();
|
||||
}
|
||||
if (writeStream) {
|
||||
try {
|
||||
writeStream.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
writeStream = null;
|
||||
}
|
||||
}
|
||||
208
electron/main/net-request.ts
Normal file
208
electron/main/net-request.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// ============================================================
|
||||
// NetRequest — Electron net.request 的 axios 风格封装
|
||||
//
|
||||
// 用法:
|
||||
// import { request, get, post } from './net-request';
|
||||
// const { data } = await get<UpdateCheckResult>('/api/v1/update/check', {
|
||||
// baseURL: 'https://www.heixiu.com',
|
||||
// params: { platform: 'win32', version: '1.0.0' },
|
||||
// });
|
||||
//
|
||||
// 设计:
|
||||
// - 底层使用 Electron net.request(不引入额外依赖)
|
||||
// - Promise 封装,支持 async/await
|
||||
// - JSON 自动解析、查询参数自动拼接
|
||||
// - 流式下载场景仍直接用 net.request(本模块不封装 stream)
|
||||
// ============================================================
|
||||
|
||||
import { net } from 'electron';
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
export interface RequestConfig {
|
||||
/** 请求方法(默认 GET) */
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
/** 基础 URL(可选,方便切换环境) */
|
||||
baseURL?: string;
|
||||
/** 查询参数(自动拼接 ?key=value&...) */
|
||||
params?: Record<string, string | number | undefined>;
|
||||
/** 请求头 */
|
||||
headers?: Record<string, string>;
|
||||
/** 请求体(对象自动 JSON.stringify,字符串原样发送) */
|
||||
data?: unknown;
|
||||
/** 响应类型(默认 json,设置 text 跳过解析) */
|
||||
responseType?: 'json' | 'text';
|
||||
/** 超时时间(毫秒,默认 30s) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface RequestResponse<T = unknown> {
|
||||
/** HTTP 状态码 */
|
||||
status: number;
|
||||
/** 状态文本(如 "OK") */
|
||||
statusText: string;
|
||||
/** 响应头 */
|
||||
headers: Record<string, string | string[]>;
|
||||
/** 响应体 */
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** 网络请求错误 */
|
||||
export class RequestError extends Error {
|
||||
status?: number;
|
||||
constructor(message: string, status?: number) {
|
||||
super(message);
|
||||
this.name = 'NetRequestError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 工具 ----------
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000;
|
||||
|
||||
/** 拼接 URL(baseURL + path + query) */
|
||||
function buildUrl(url: string, config: RequestConfig): string {
|
||||
let fullUrl = url;
|
||||
|
||||
if (config.baseURL) {
|
||||
// url 以 / 开头时为绝对路径,否则拼接
|
||||
const base = config.baseURL.replace(/\/+$/, '');
|
||||
const path = url.startsWith('/') ? url : `/${url}`;
|
||||
fullUrl = `${base}${path}`;
|
||||
}
|
||||
|
||||
if (config.params) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(config.params)) {
|
||||
if (value !== undefined) {
|
||||
search.append(key, String(value));
|
||||
}
|
||||
}
|
||||
const qs = search.toString();
|
||||
if (qs) {
|
||||
fullUrl += (fullUrl.includes('?') ? '&' : '?') + qs;
|
||||
}
|
||||
}
|
||||
|
||||
return fullUrl;
|
||||
}
|
||||
|
||||
/** 序列化请求体 */
|
||||
function serializeBody(data: unknown): { body: string; contentType: string } {
|
||||
if (typeof data === 'string') {
|
||||
return { body: data, contentType: 'text/plain' };
|
||||
}
|
||||
return { body: JSON.stringify(data), contentType: 'application/json' };
|
||||
}
|
||||
|
||||
// ---------- 核心 ----------
|
||||
|
||||
/**
|
||||
* 发起请求(axios 风格 API)
|
||||
*
|
||||
* 流式下载场景请直接使用 Electron net.request(本模块不封装)
|
||||
*/
|
||||
export async function request<T = unknown>(
|
||||
url: string,
|
||||
config: RequestConfig = {},
|
||||
): Promise<RequestResponse<T>> {
|
||||
const fullUrl = buildUrl(url, config);
|
||||
const method = (config.method || 'GET').toUpperCase();
|
||||
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = net.request({
|
||||
method,
|
||||
url: fullUrl,
|
||||
});
|
||||
|
||||
// 超时处理
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
req.abort();
|
||||
reject(new RequestError(`请求超时 (${timeout}ms)`));
|
||||
}, timeout);
|
||||
|
||||
// 请求头
|
||||
if (config.headers) {
|
||||
for (const [key, value] of Object.entries(config.headers)) {
|
||||
req.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 请求体
|
||||
if (config.data !== undefined) {
|
||||
const { body, contentType } = serializeBody(config.data);
|
||||
req.setHeader('Content-Type', contentType);
|
||||
req.write(body);
|
||||
}
|
||||
|
||||
req.on('response', (response) => {
|
||||
clearTimeout(timer);
|
||||
|
||||
const status = response.statusCode;
|
||||
const statusText = response.statusMessage ?? '';
|
||||
|
||||
// 读取响应头
|
||||
const headers: Record<string, string | string[]> = {};
|
||||
// Electron IncomingMessage.headers 是 Record<string, string[]>
|
||||
const rawHeaders = response.headers;
|
||||
if (rawHeaders) {
|
||||
for (const [key, value] of Object.entries(rawHeaders)) {
|
||||
headers[key] = value.length === 1 ? value[0] : value;
|
||||
}
|
||||
}
|
||||
|
||||
// 读取响应体
|
||||
let body = '';
|
||||
response.on('data', (chunk: Buffer) => (body += chunk.toString()));
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const asJson = config.responseType !== 'text';
|
||||
const data = asJson ? JSON.parse(body) : body;
|
||||
resolve({ status, statusText, headers, data: data as T });
|
||||
} catch {
|
||||
// JSON 解析失败 → 返回原始文本
|
||||
resolve({ status, statusText, headers, data: body as T });
|
||||
}
|
||||
});
|
||||
|
||||
response.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(new RequestError(`响应读取失败: ${err.message}`));
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) return;
|
||||
reject(new RequestError(`网络请求失败: ${err.message}`));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 默认导出:类 axios 命名空间 ----------
|
||||
|
||||
export const netRequest = {
|
||||
request,
|
||||
|
||||
get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'GET' });
|
||||
},
|
||||
|
||||
post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'POST', data });
|
||||
},
|
||||
|
||||
put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'PUT', data });
|
||||
},
|
||||
|
||||
del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
@@ -25,6 +25,8 @@ 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 通道(对渲染进程暴露,与旧版兼容)----------
|
||||
|
||||
@@ -59,7 +61,7 @@ export function initUpdater(mainWindow: BrowserWindow): void {
|
||||
updateWindow = mainWindow;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Updater] 开发模式,跳过自动检查更新。API:', getApiBaseUrl());
|
||||
logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,43 +76,30 @@ export function initUpdater(mainWindow: BrowserWindow): void {
|
||||
// ============================================================
|
||||
|
||||
async function fetchUpdateInfo(): Promise<UpdateCheckResult | null> {
|
||||
const platform = process.platform; // 'win32' | 'darwin' | 'linux'
|
||||
const edition = process.env.EDITION || 'personal';
|
||||
const apiUrl = `${getApiBaseUrl()}/api/v1/update/check?platform=${platform}&version=${APP_VERSION}&edition=${edition}`;
|
||||
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',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
console.log('[Updater] 请求版本检查 API:', apiUrl);
|
||||
// 兼容两种 API 响应格式:
|
||||
// 格式 A: { code: 0, data: UpdateCheckResult }
|
||||
// 格式 B: UpdateCheckResult 直接返回
|
||||
const result: UpdateCheckResult = (data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = net.request({ method: 'GET', url: apiUrl });
|
||||
if (!result.hasUpdate) {
|
||||
logger.info('updater', '当前已是最新版本');
|
||||
return null;
|
||||
}
|
||||
|
||||
req.on('response', (response) => {
|
||||
let body = '';
|
||||
response.on('data', (chunk) => (body += chunk.toString()));
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
|
||||
// 兼容两种 API 响应格式:
|
||||
// 格式 A: { code: 0, data: UpdateCheckResult }
|
||||
// 格式 B: UpdateCheckResult 直接返回
|
||||
const data: UpdateCheckResult = json.data ?? json;
|
||||
|
||||
if (!data.hasUpdate) {
|
||||
console.log('[Updater] 当前已是最新版本');
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
console.log('[Updater] 发现新版本:', data.latestVersion);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(new Error(`API 响应解析失败: ${(err as Error).message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => reject(new Error(`API 请求失败: ${err.message}`)));
|
||||
req.end();
|
||||
});
|
||||
logger.info('updater', '发现新版本', { latestVersion: result.latestVersion });
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -122,8 +111,7 @@ async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
|
||||
process.platform === 'win32' ? '.exe' : process.platform === 'darwin' ? '.dmg' : '.AppImage';
|
||||
const tempPath = join(tmpdir(), `HeiXiu-${info.latestVersion}-update${ext}`);
|
||||
|
||||
console.log('[Updater] 开始下载:', info.downloadUrl);
|
||||
console.log('[Updater] 临时文件:', tempPath);
|
||||
logger.info('updater', '开始下载', { downloadUrl: info.downloadUrl, tempPath });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = net.request({ method: 'GET', url: info.downloadUrl });
|
||||
@@ -174,7 +162,7 @@ async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Updater] 下载完成,SHA512 校验通过');
|
||||
logger.info('updater', '下载完成,SHA512 校验通过');
|
||||
pendingInstallerPath = tempPath;
|
||||
resolve(tempPath);
|
||||
});
|
||||
@@ -203,12 +191,12 @@ async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
|
||||
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
if (updateChecked) {
|
||||
console.log('[Updater] 已检查过更新,跳过');
|
||||
logger.debug('updater', '已检查过更新,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Updater] 开发模式,跳过更新检查');
|
||||
logger.debug('updater', '开发模式,跳过更新检查');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,7 +223,7 @@ export async function checkForUpdates(): Promise<void> {
|
||||
// 下载完成 → 通知渲染进程
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
|
||||
} catch (error) {
|
||||
console.error('[Updater] 更新流程失败:', (error as Error).message);
|
||||
logger.error('updater', '更新流程失败', error as Error, { apiUrl: getApiBaseUrl() });
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '更新检查失败',
|
||||
});
|
||||
@@ -263,7 +251,7 @@ export async function checkForUpdatesManual(): Promise<void> {
|
||||
await downloadInstaller(info);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
|
||||
} catch (error) {
|
||||
console.error('[Updater] 手动更新失败:', (error as Error).message);
|
||||
logger.error('updater', '手动更新失败', error as Error);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '检查更新失败',
|
||||
});
|
||||
@@ -272,11 +260,11 @@ export async function checkForUpdatesManual(): Promise<void> {
|
||||
|
||||
export function installUpdateNow(): void {
|
||||
if (!pendingInstallerPath) {
|
||||
console.error('[Updater] 没有待安装的更新文件');
|
||||
logger.warn('updater', '没有待安装的更新文件');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Updater] 开始安装更新:', pendingInstallerPath);
|
||||
logger.info('updater', '开始安装更新', { installerPath: pendingInstallerPath });
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: NSIS 安装器,/S 静默安装
|
||||
@@ -292,7 +280,7 @@ export function installUpdateNow(): void {
|
||||
});
|
||||
} else {
|
||||
// Linux: AppImage,chmod +x 后执行(通常需要手动替换)
|
||||
console.log('[Updater] Linux 更新请手动替换 AppImage 文件');
|
||||
logger.warn('updater', 'Linux 更新需手动替换 AppImage 文件');
|
||||
}
|
||||
|
||||
// 退出应用,让安装器接管
|
||||
|
||||
Reference in New Issue
Block a user