## 构建、打包与分发系统重构 (build/) - 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本 - electron-builder 配置从 package.json 提取为 build/electron-builder.js - 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数 - OSS 路径按渠道隔离 - 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验 - 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成 - updater.ts 发送 channel/edition/deviceFingerprint - 新增 src/shared/utils/rollout.ts 灰度测试工具 - 新增 10 条 NPM 脚本 ## VCHSM 五层架构迁移 - 7 个业务模块 + ~500 处导入路径同步 ## 修复 - MediaCardContextMenu 改用共享 ContextMenu 组件 - ComboBox 尺寸参数显示统一 (size-format.ts) ## 文档 - UpdateA.md / build/README.md / README.md / .env.example 更新 Co-Authored-By: Claude <noreply@anthropic.com>
487 lines
17 KiB
TypeScript
487 lines
17 KiB
TypeScript
// 自动更新 — HeiXiuProvider + electron-updater 增量下载
|
||
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
|
||
|
||
import {app, BrowserWindow, ipcMain} from 'electron';
|
||
import {createHash} from 'node:crypto';
|
||
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';
|
||
|
||
// ---------- 构建时注入的渠道信息 ----------
|
||
|
||
/** 构建渠道(由 build.mjs 通过 VITE_BUILD_CHANNEL 注入,Vite 编译时内联) */
|
||
const BUILD_CHANNEL: string =
|
||
(typeof import.meta !== 'undefined' && (import.meta as Record<string, unknown>).env
|
||
? ((import.meta as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_CHANNEL
|
||
: undefined)
|
||
|| process.env.VITE_BUILD_CHANNEL
|
||
|| 'stable';
|
||
|
||
/** 构建版本类型(由 build.mjs 通过 VITE_BUILD_EDITION 注入) */
|
||
const BUILD_EDITION: string =
|
||
(typeof import.meta !== 'undefined' && (import.meta as Record<string, unknown>).env
|
||
? ((import.meta as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_EDITION
|
||
: undefined)
|
||
|| process.env.VITE_BUILD_EDITION
|
||
|| 'personal';
|
||
|
||
// ---------- 设备指纹(稳定的机器标识,用于灰度分组)----------
|
||
|
||
let _deviceFingerprint: string | null = null;
|
||
|
||
/** 获取设备指纹(基于机器特定信息的 SHA-256 前 16 位) */
|
||
function getDeviceFingerprint(): string {
|
||
if (_deviceFingerprint) return _deviceFingerprint;
|
||
|
||
try {
|
||
const parts = [
|
||
process.platform,
|
||
process.arch,
|
||
app.getPath('home'),
|
||
];
|
||
_deviceFingerprint = createHash('sha256')
|
||
.update(parts.join('|'))
|
||
.digest('hex')
|
||
.slice(0, 16);
|
||
} catch {
|
||
_deviceFingerprint = 'unknown-device';
|
||
}
|
||
|
||
return _deviceFingerprint;
|
||
}
|
||
|
||
// ---------- 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,
|
||
channel: BUILD_CHANNEL,
|
||
edition: BUILD_EDITION,
|
||
deviceFingerprint: getDeviceFingerprint(),
|
||
},
|
||
},
|
||
);
|
||
|
||
// 兼容两种 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 被调用');
|
||
console.log(`[updater] API URL: ${getApiBaseUrl()}`);
|
||
console.log(`[updater] 渠道: ${BUILD_CHANNEL}`);
|
||
console.log(`[updater] 版本类型: ${BUILD_EDITION}`);
|
||
console.log(`[updater] 当前版本: ${APP_VERSION}`);
|
||
console.log(`[updater] 设备指纹: ${getDeviceFingerprint()}`);
|
||
|
||
// 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();
|
||
});
|
||
}
|