【核心重构】
- 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 同步更新架构图和流程说明
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
// 自动更新 — 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();
|
||
});
|
||
}
|