feat: 重构更新流程 — 无更新不抛异常、用户决定下载、更新详情展示(md文件中的)

【核心重构】
  - getLatestVersion() 不再 throw Error,无更新时返回 { version: APP_VERSION }
  - checkForUpdates() 先自行预检 → 无更新直发 IPC,有更新才交 electron-updater
  - 移除 NO_UPDATE_MESSAGE 常量及所有字符串匹配拦截代码
  - HeiXiuProvider 增加 500ms TTL 缓存,避免预检+electron-updater 重复请求 API

  【用户体验】
  - 检查到新版本后不再自动下载,展示更新内容供用户决定
  - 新增"下载更新"按钮(与"安装更新"分离为两步操作)
  - 设置页新增"查看详情"弹窗,Markdown 格式化渲染更新日志
  - available / downloading / downloaded 三个状态 UI 独立展示

  【Bug 修复】
  - 测试服务器版本排序:字符串序 → 语义版本序(_version_key),0.0.10 正确 > 0.0.9
  - 版本比较:!= → <(_version_key),防止高版本误判为有更新
  - get_best_release fallback 同样改为语义版本排序
  - Windows cmd set 命令尾部空格 → Invalid URL(.trim() 修复)
  - electron-updater 'error' 事件中拦截 NO_UPDATE_MESSAGE → UPDATE_NOT_AVAILABLE

  【文档更新】
  - CHANGELOG.md、PROJECT.md、UpdateA.md 同步更新架构图和流程说明
This commit is contained in:
2026-06-04 18:31:52 +08:00
parent b55c2fdd2e
commit 6204c14b88
37 changed files with 1908 additions and 648 deletions

92
electron/main/load-env.ts Normal file
View File

@@ -0,0 +1,92 @@
// 主进程 .env 加载器:.env.{mode} → .env.local → OS 环境变量(后者覆盖前者)
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM 中 __dirname 不可用,手动计算
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* 解析单个 .env 文件,将 key=value 写入 process.env。
* 已存在的 process.env 值不会被覆盖OS 环境变量优先)。
* 返回成功加载的变量数。
*/
function parseEnvFile(filePath: string, fileName: string): number {
try {
const content = readFileSync(filePath, 'utf-8');
let count = 0;
for (const line of content.split('\n')) {
const trimmed = line.trim();
// 跳过空行和注释
if (!trimmed || trimmed.startsWith('#')) continue;
// 解析 key=value
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
// 去除引号(支持 "value" 和 'value'
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
// 不覆盖已在 OS 环境变量中设置的值
if (key && !process.env[key]) {
process.env[key] = value;
count++;
}
}
if (count > 0) {
console.log(`[load-env] 从 ${fileName} 加载了 ${count} 个环境变量`);
}
return count;
} catch (err) {
// 文件不存在 — 静默跳过(打包后 .env.* 不在 ASAR 中,这是正常情况)
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return 0;
}
// 其他错误(权限、编码等)— 警告但不中断
console.warn(`[load-env] 读取 ${fileName} 失败:`, (err as Error).message);
return 0;
}
}
/**
* 加载环境变量文件到 process.env。
* 开发模式加载 .env.development生产模式加载 .env.production
* 之后再加载 .env.local 作为覆盖层。
*/
export function loadEnvFile(): void {
// 项目根目录
// APP_ROOT 在 main.ts 中设置dist-electron/.. = 项目根)
// fallbackdist-electron/main/load-env.js → __dirname/../.. = 项目根
const projectRoot = process.env.APP_ROOT || join(__dirname, '..', '..');
// 确定当前模式
const mode =
!process.env.NODE_ENV || process.env.NODE_ENV === 'development'
? 'development'
: 'production';
console.log(
`[load-env] 模式: ${mode}, projectRoot: ${projectRoot}`,
);
// ① 基础环境文件(按模式选择)
const baseFile = `.env.${mode}`;
parseEnvFile(join(projectRoot, baseFile), baseFile);
// ② 本地覆盖文件(不进 git用于打包后连测试服务器等特殊场景
const localFile = '.env.local';
parseEnvFile(join(projectRoot, localFile), localFile);
}

View File

@@ -63,19 +63,21 @@ const DEFAULT_TIMEOUT = 30_000;
/** 拼接 URLbaseURL + 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}`;
// 如果没有 baseURL 且 url 不是绝对地址,直接报错(便于定位问题)
if (!config.baseURL && !/^https?:\/\//i.test(url)) {
throw new RequestError(
`URL 缺少 baseURL无法构造绝对地址。url="${url}", config=${JSON.stringify({ ...config, params: config.params ? '[object]' : undefined })}`,
);
}
const base = (config.baseURL || '').trim().replace(/\/+$/, '');
const path = url.startsWith('/') ? url : `/${url}`;
let fullUrl = base ? `${base}${path}` : url;
if (config.params) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(config.params)) {
if (value !== undefined) {
if (value !== undefined && value !== null) {
search.append(key, String(value));
}
}
@@ -85,6 +87,16 @@ function buildUrl(url: string, config: RequestConfig): string {
}
}
// 调试:在 net.request 调用之前验证 URL 合法性
try {
void new URL(fullUrl);
} catch {
throw new RequestError(
`构造的 URL 无法通过 Node.js 校验: "${fullUrl}"`,
);
}
console.log('[net-request] buildUrl:', fullUrl);
return fullUrl;
}
@@ -112,6 +124,17 @@ export async function request<T = unknown>(
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
return new Promise((resolve, reject) => {
// 调试:验证 URL 是否合法
try {
void new URL(fullUrl);
} catch (urlErr) {
console.error('[net-request] 非法 URL:', JSON.stringify(fullUrl));
console.error('[net-request] URL 字符码:', [...fullUrl].map(c => c.charCodeAt(0)).join(','));
console.error('[net-request] config:', JSON.stringify(config));
reject(new RequestError(`非法 URL: ${(urlErr as Error).message}`));
return;
}
const req = net.request({
method,
url: fullUrl,

View File

@@ -0,0 +1,81 @@
// ============================================================
// safe-storage-ipc — safeStorage 加密/解密 IPC 处理器
//
// 在主进程注册,渲染进程通过 preload 暴露的安全方法调用。
// 加密后的数据为 Base64 字符串,方便存储在 localStorage。
//
// 平台对应:
// Windows → DPAPI
// macOS → Keychain
// Linux → libsecret
//
// 注意:
// - safeStorage 加密绑定当前 OS 用户账户,无法跨用户/跨设备解密
// - 用户重装 OS 后加密数据将无法恢复(需重新登录)
// - safeStorage.isEncryptionAvailable() 在部分 Linux 发行版可能返回 false
// ============================================================
import { ipcMain, safeStorage } from 'electron';
import { BIDIRECTIONAL } from '../../shared/constants/ipc-channels';
import { logger } from './logger';
/** 检查 safeStorage 是否可用,不可用时记录警告 */
function checkAvailability(): boolean {
if (!safeStorage.isEncryptionAvailable()) {
logger.warn('safe-storage', 'safeStorage 加密不可用(可能缺少 libsecret 等系统密钥服务)');
return false;
}
return true;
}
/**
* 注册 safeStorage IPC 处理器
* 在 app.whenReady() 中调用
*/
export function registerSafeStorageIpcHandlers(): void {
// ---------- 加密 ----------
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => {
if (typeof plaintext !== 'string' || plaintext.length === 0) {
logger.warn('safe-storage', '加密参数无效');
return { success: false, error: '参数无效:需要非空字符串' };
}
if (!checkAvailability()) {
return { success: false, error: 'safeStorage 不可用' };
}
try {
const encrypted = safeStorage.encryptString(plaintext);
const base64 = encrypted.toString('base64');
return { success: true, data: base64 };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('safe-storage', '加密失败', err instanceof Error ? err : new Error(message));
return { success: false, error: message };
}
});
// ---------- 解密 ----------
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => {
if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) {
logger.warn('safe-storage', '解密参数无效');
return { success: false, error: '参数无效:需要非空字符串' };
}
if (!checkAvailability()) {
return { success: false, error: 'safeStorage 不可用' };
}
try {
const buffer = Buffer.from(encryptedBase64, 'base64');
const plaintext = safeStorage.decryptString(buffer);
return { success: true, data: plaintext };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('safe-storage', '解密失败', err instanceof Error ? err : new Error(message));
return { success: false, error: message };
}
});
logger.info('safe-storage', 'safeStorage IPC 处理器已注册');
}

View File

@@ -1,292 +1,419 @@
// ============================================================
// 自动更新模块 — 自定义 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
// ============================================================
// 自动更新 — HeiXiuProvider + electron-updater 增量下载
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
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 {BrowserWindow, ipcMain} from 'electron';
import {URL} from 'node:url';
import type { UpdateCheckResult } from '../../shared/types';
import { APP_VERSION } from '../../shared/constants/version';
import { logger } from './logger';
import { netRequest } from './net-request';
import {
NsisUpdater,
MacUpdater,
AppImageUpdater,
Provider,
} from 'electron-updater';
import type {ProviderRuntimeOptions} from 'electron-updater/out/providers/Provider';
import type {UpdateInfo, UpdateFileInfo} from 'builder-util-runtime';
import type {ResolvedUpdateFileInfo} from 'electron-updater/out/types';
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',
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',
DOWNLOAD_UPDATE: 'download-update',
} as const;
// ---------- 配置 ----------
function getApiBaseUrl(): string {
return process.env.UPDATE_API_URL || 'https://www.heixiu.com';
const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
return url;
}
// ============================================================
// 自定义 Provider
// ============================================================
class HeiXiuProvider extends Provider<UpdateInfo> {
private apiBaseUrl: string;
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
constructor(runtimeOptions: ProviderRuntimeOptions) {
super(runtimeOptions);
this.apiBaseUrl = getApiBaseUrl();
}
/**
* 调用自有版本检查 API返回 electron-updater 格式的 UpdateInfo。
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
* 无更新时返回当前版本号APP_VERSION
*/
async getLatestVersion(): Promise<UpdateInfo> {
// 短时缓存500ms预检和 electron-updater 内部调用共享同一结果
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
return this._cachedResult.data;
}
const data = await this._doGetLatestVersion();
this._cachedResult = { data, ts: Date.now() };
return data;
}
private async _doGetLatestVersion(): Promise<UpdateInfo> {
const {data} = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
'/api/v1/update/check',
{
baseURL: this.apiBaseUrl,
params: {
platform: process.platform,
version: APP_VERSION,
edition: process.env.EDITION || 'personal',
},
},
);
// 兼容两种 API 响应格式
const result: UpdateCheckResult =
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
// 无更新 → 返回当前版本号
if (!result.hasUpdate) {
return {
version: APP_VERSION,
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
};
}
const file: UpdateFileInfo = {
url: result.downloadUrl,
sha512: result.sha512,
size: result.fileSize,
};
// 后端 API 返回 blockMapSize 时启用增量更新
if (result.blockMapSize) {
file.blockMapSize = result.blockMapSize;
}
logger.info('updater', 'Provider 获取到新版本', {
latestVersion: result.latestVersion,
hasBlockmap: file.blockMapSize != null,
});
return {
version: result.latestVersion,
files: [file],
path: result.downloadUrl,
sha512: result.sha512,
releaseDate: result.releaseDate,
releaseNotes: result.changelog,
};
}
/**
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
* electron-updater 在下载前调用此方法。
*/
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
return updateInfo.files.map((f) => {
// 使用 downloadUrl 作为 base URL 来解析文件路径
const baseUrl = new URL(updateInfo.path);
const fileUrl = new URL(f.url, baseUrl.origin);
return {
url: fileUrl,
info: f,
};
});
}
}
// ============================================================
// 平台特定 Updater注入自定义 Provider
// ============================================================
/**
* 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
* 其他行为NSIS 安装、签名校验等)完全由父类处理。
*/
class HeiXiuNsisUpdater extends NsisUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
this.autoInstallOnAppQuit = false;
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
}
/** @internal — 注入自定义 Provider */
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
class HeiXiuMacUpdater extends MacUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
class HeiXiuAppImageUpdater extends AppImageUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(null);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
/** 工厂:按平台创建对应的 Updater 实例 */
function createPlatformUpdater(provider: HeiXiuProvider) {
switch (process.platform) {
case 'win32':
return new HeiXiuNsisUpdater(provider);
case 'darwin':
return new HeiXiuMacUpdater(provider);
default:
return new HeiXiuAppImageUpdater(provider);
}
}
// ---------- 状态 ----------
let updateWindow: BrowserWindow | null = null;
let updateChecked = false;
/** 当前正在下载的临时文件路径(用于安装后清理) */
let pendingInstallerPath: string | null = null;
let provider: HeiXiuProvider | null = null;
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
// ============================================================
// 初始化
// ============================================================
export function initUpdater(mainWindow: BrowserWindow): void {
updateWindow = mainWindow;
updateWindow = mainWindow;
if (import.meta.env.DEV) {
logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
return;
}
console.log('[updater] initUpdater 被调用API URL:', getApiBaseUrl());
// 启动 5 秒后静默检查
setTimeout(() => {
checkForUpdates();
}, 5000);
}
// if (import.meta.env.DEV) {
// logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
// return;
// }
// ============================================================
// 版本检查(调用 API
// ============================================================
// 创建自定义 Provider
provider = new HeiXiuProvider({
isUseMultipleRangeRequest: true,
platform: process.platform as ProviderRuntimeOptions['platform'],
executor: null as unknown as ProviderRuntimeOptions['executor'],
});
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',
},
},
);
// 创建平台 Updater
platformUpdater = createPlatformUpdater(provider);
// 兼容两种 API 响应格式:
// 格式 A: { code: 0, data: UpdateCheckResult }
// 格式 B: UpdateCheckResult 直接返回
const result: UpdateCheckResult = (data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
// 将 electron-updater 事件桥接到现有 IPC 通道
platformUpdater.on('checking-for-update', () => {
logger.debug('updater', '正在检查更新');
});
if (!result.hasUpdate) {
logger.info('updater', '当前已是最新版本');
return null;
}
platformUpdater.on('update-available', (info: UpdateInfo) => {
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null统一转为 string
let notes = '';
if (typeof info.releaseNotes === 'string') {
notes = info.releaseNotes;
} else if (Array.isArray(info.releaseNotes)) {
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
}
logger.info('updater', '发现新版本', { latestVersion: result.latestVersion });
return result;
}
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
version: info.version,
releaseDate: info.releaseDate,
releaseNotes: notes,
forceUpdate: false,
});
logger.info('updater', '发现新版本', {
version: info.version,
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
});
});
// ============================================================
// 下载安装包
// ============================================================
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.info('updater', '当前已是最新版本', {currentVersion: info.version});
});
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}`);
platformUpdater.on('download-progress', (progress: {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number
}) => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, {
percent: Math.round(progress.percent),
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
});
});
logger.info('updater', '开始下载', { downloadUrl: info.downloadUrl, tempPath });
platformUpdater.on('update-downloaded', () => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
logger.info('updater', '更新已下载完成');
});
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;
platformUpdater.on('error', (error: Error) => {
logger.error('updater', '更新流程出错', error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: error.message || '更新失败',
});
});
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();
});
// 启动 5 秒后静默检查
setTimeout(() => {
checkForUpdates();
}, 5000);
}
// ============================================================
// 操作方法
// ============================================================
let updateChecked = false;
/** 静默检查更新(自动下载) */
export async function checkForUpdates(): Promise<void> {
if (updateChecked) {
logger.debug('updater', '已检查过更新,跳过');
return;
}
if (updateChecked) {
logger.debug('updater', '已检查过更新,跳过');
return;
}
if (import.meta.env.DEV) {
logger.debug('updater', '开发模式,跳过更新检查');
return;
}
if (!platformUpdater || !provider) return;
try {
updateChecked = true;
const info = await fetchUpdateInfo();
try {
updateChecked = true;
if (!info) {
// 无更新,不发通知(静默)
return;
}
// ① 先自行检查有无更新(不经过 electron-updater
const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI不触发 electron-updater 的任何事件
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '当前已是最新版本', {version: APP_VERSION});
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;
}
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
await platformUpdater.checkForUpdates();
} catch (error) {
updateChecked = false;
logger.error('updater', '更新流程失败', error as Error, {apiUrl: getApiBaseUrl()});
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '更新检查失败',
});
}
}
/** 手动检查更新(用户点击触发) */
export async function checkForUpdatesManual(): Promise<void> {
try {
updateChecked = false;
const info = await fetchUpdateInfo();
if (!platformUpdater || !provider) return;
if (!info) {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
return;
}
try {
updateChecked = false;
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
version: info.latestVersion,
releaseDate: info.releaseDate,
releaseNotes: info.changelog,
forceUpdate: info.forceUpdate,
});
// ① 先自行检查有无更新
const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '手动检查:当前已是最新版本', {version: APP_VERSION});
return;
}
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 || '检查更新失败',
});
}
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
await platformUpdater.checkForUpdates();
} catch (error) {
logger.error('updater', '手动更新失败', error as Error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '检查更新失败',
});
}
}
/** 用户确认后开始下载更新 */
export async function downloadUpdate(): Promise<void> {
if (!platformUpdater) {
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
return;
}
try {
await platformUpdater.downloadUpdate();
} 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;
}
if (!platformUpdater) {
logger.warn('updater', '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: AppImagechmod +x 后执行(通常需要手动替换)
logger.warn('updater', 'Linux 更新需手动替换 AppImage 文件');
}
// 退出应用,让安装器接管
setImmediate(() => {
app.quit();
});
logger.info('updater', '开始安装更新并退出应用');
setImmediate(() => {
platformUpdater!.quitAndInstall(false, true);
});
}
// ============================================================
@@ -294,11 +421,15 @@ export function installUpdateNow(): void {
// ============================================================
export function registerUpdateIpcHandlers(): void {
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
await checkForUpdatesManual();
});
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
await checkForUpdatesManual();
});
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
installUpdateNow();
});
ipcMain.handle(UPDATE_CHANNELS.DOWNLOAD_UPDATE, async () => {
await downloadUpdate();
});
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
installUpdateNow();
});
}