Files
ele-HeiXiu/electron/main/updater.ts
YoungestSongMo 6204c14b88 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 同步更新架构图和流程说明
2026-06-04 18:31:52 +08:00

436 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 自动更新 — HeiXiuProvider + electron-updater 增量下载
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
import {BrowserWindow, ipcMain} from 'electron';
import {URL} from 'node:url';
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',
DOWNLOAD_UPDATE: 'download-update',
} as const;
// ---------- 配置 ----------
function getApiBaseUrl(): string {
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 provider: HeiXiuProvider | null = null;
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
// ============================================================
// 初始化
// ============================================================
export function initUpdater(mainWindow: BrowserWindow): void {
updateWindow = mainWindow;
console.log('[updater] initUpdater 被调用API URL:', getApiBaseUrl());
// if (import.meta.env.DEV) {
// logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
// return;
// }
// 创建自定义 Provider
provider = new HeiXiuProvider({
isUseMultipleRangeRequest: true,
platform: process.platform as ProviderRuntimeOptions['platform'],
executor: null as unknown as ProviderRuntimeOptions['executor'],
});
// 创建平台 Updater
platformUpdater = createPlatformUpdater(provider);
// 将 electron-updater 事件桥接到现有 IPC 通道
platformUpdater.on('checking-for-update', () => {
logger.debug('updater', '正在检查更新');
});
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');
}
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});
});
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,
});
});
platformUpdater.on('update-downloaded', () => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
logger.info('updater', '更新已下载完成');
});
platformUpdater.on('error', (error: Error) => {
logger.error('updater', '更新流程出错', error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: error.message || '更新失败',
});
});
// 启动 5 秒后静默检查
setTimeout(() => {
checkForUpdates();
}, 5000);
}
// ============================================================
// 操作方法
// ============================================================
let updateChecked = false;
/** 静默检查更新(自动下载) */
export async function checkForUpdates(): Promise<void> {
if (updateChecked) {
logger.debug('updater', '已检查过更新,跳过');
return;
}
if (!platformUpdater || !provider) return;
try {
updateChecked = true;
// ① 先自行检查有无更新(不经过 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;
}
// ② 有新版本 → 交给 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> {
if (!platformUpdater || !provider) return;
try {
updateChecked = false;
// ① 先自行检查有无更新
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;
}
// ② 有新版本 → 交给 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 (!platformUpdater) {
logger.warn('updater', 'Updater 未初始化');
return;
}
logger.info('updater', '开始安装更新并退出应用');
setImmediate(() => {
platformUpdater!.quitAndInstall(false, true);
});
}
// ============================================================
// IPC 注册
// ============================================================
export function registerUpdateIpcHandlers(): void {
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
await checkForUpdatesManual();
});
ipcMain.handle(UPDATE_CHANNELS.DOWNLOAD_UPDATE, async () => {
await downloadUpdate();
});
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
installUpdateNow();
});
}