版控体系重构(13 文件):
- 移除 build.mjs --personal/--enterprise 参数和 EDITION 环境变量
- 统一安装包文件名:船长-HeiXiu-{os}-{版本号}-Setup.{ext}
- 简化 package.json 构建脚本,移除 :enterprise 变体
- scripts/*.mjs 不再接受 edition 参数
- 移除 shared/constants/app.ts 的 parseEdition()
窗口标题动态版控:
- 新格式:船长-HeiXiu-{os}-{版本号}(登录前)/ ·{版控} 后缀(登录后)
- 链路:AppProvider(account_type) → useEffect → appRuntime IPC → main process
- 移除 updater.ts 更新检查 API 的 edition 查询参数
本地存储生命周期修正:
- 退出登录不再自动清空缓存(user_id 隔离,重新登录命中缓存)
- clearUserStorage 添加 isOpen() 竞态守卫
- 移除 closeDatabase() 避免退出→重新登录后数据库不可用
- 手动「清理缓存」按钮保留
文档:
- CHANGELOG.md 新增 0.0.24 条目
- TODO.md 新增已完成项
435 lines
15 KiB
TypeScript
435 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,
|
||
},
|
||
},
|
||
);
|
||
|
||
// 兼容两种 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();
|
||
});
|
||
}
|