Files
ele-HeiXiu/electron/main/updater.ts
YoungestSongMo 767bb8b297 feat: 初始化船长·HeiXiu 桌面应用项目(错误是svg的问题)
Electron + React 19 + TypeScript + Ant Design 6 + Tailwind CSS 4

  【核心架构】
  - Electron 主进程(窗口管理、平台检测、图标适配)
  - Vite 构建链(双产物 dist/ + dist-electron/)
  - 共享层 shared/(类型、常量、工具函数)
  - IPC 通信框架 + 安全守卫

  【主题系统】
  - 蓝紫渐变主题(亮色/暗色双模式)
  - Ant Design ConfigProvider 适配(lightAlgorithm / darkAlgorithm)
  - Tailwind CSS 4 @theme 指令 + CSS 变量
  - 设计令牌三处同步(tokens.ts / globals.css / antd-theme.ts)

  【导航栏】
  - 自定义 H5 导航栏替代系统菜单
  - 个人版/企业版双布局
  - 未登录拦截 + 事件总线驱动登录弹窗

  【登录注册】
  - Modal 弹层(Portal)+ Tabs 切换
  - 登录/注册表单动态字段渲染
  - 记住密码 / 自动登录 / 用户协议

  【更新系统】
  - 自定义 API 版本检查(替代 electron-updater generic provider)
  - 手动下载 + SHA512 校验 + spawn NSIS 安装
  - 渲染进程更新状态机 Hook(useUpdater)
  - 检查更新按钮 + 下载进度展示

  【构建工具链】
  - build.mjs 构建总管(--win/--mac/--linux --personal/--enterprise --build/--clean/--sign/--upload/--publish)
  - scripts/ 自动化脚本(clean / generate-update-info / upload-oss / publish-release)
  - electron-builder NSIS 安装器(可选路径、多版本打包)
  - update-info.json 自动生成(fileSize/sha512/changelog/releaseDate)

  【代码质量】
  - ESLint + TypeScript strict + Prettier
  - husky + lint-staged(提交前自动检查)
  - commitlint(规范提交信息)
  - Playwright E2E + Vitest 单元测试框架
  - rollup-plugin-visualizer 打包体积分析

  【文档】
  - README.md(快速开始 + 目录结构 + 命令速查)
  - UpdateA.md(发布手册:API/签名/发版流程/数据库)
  - CHANGELOG.md(更新日志)
  - .env.example(构建环境变量模板)
2026-06-03 15:00:51 +08:00

317 lines
10 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.
// ============================================================
// 自动更新模块 — 自定义 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
// ============================================================
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 type { UpdateCheckResult } from '../../shared/types';
import { APP_VERSION } from '../../shared/constants/version';
// ---------- 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',
} as const;
// ---------- 配置 ----------
function getApiBaseUrl(): string {
return process.env.UPDATE_API_URL || 'https://www.heixiu.com';
}
// ---------- 状态 ----------
let updateWindow: BrowserWindow | null = null;
let updateChecked = false;
/** 当前正在下载的临时文件路径(用于安装后清理) */
let pendingInstallerPath: string | null = null;
// ============================================================
// 初始化
// ============================================================
export function initUpdater(mainWindow: BrowserWindow): void {
updateWindow = mainWindow;
if (import.meta.env.DEV) {
console.log('[Updater] 开发模式跳过自动检查更新。API:', getApiBaseUrl());
return;
}
// 启动 5 秒后静默检查
setTimeout(() => {
checkForUpdates();
}, 5000);
}
// ============================================================
// 版本检查(调用 API
// ============================================================
async function fetchUpdateInfo(): Promise<UpdateCheckResult | null> {
const platform = process.platform; // 'win32' | 'darwin' | 'linux'
const edition = process.env.EDITION || 'personal';
const apiUrl = `${getApiBaseUrl()}/api/v1/update/check?platform=${platform}&version=${APP_VERSION}&edition=${edition}`;
console.log('[Updater] 请求版本检查 API:', apiUrl);
return new Promise((resolve, reject) => {
const req = net.request({ method: 'GET', url: apiUrl });
req.on('response', (response) => {
let body = '';
response.on('data', (chunk) => (body += chunk.toString()));
response.on('end', () => {
try {
const json = JSON.parse(body);
// 兼容两种 API 响应格式:
// 格式 A: { code: 0, data: UpdateCheckResult }
// 格式 B: UpdateCheckResult 直接返回
const data: UpdateCheckResult = json.data ?? json;
if (!data.hasUpdate) {
console.log('[Updater] 当前已是最新版本');
return resolve(null);
}
console.log('[Updater] 发现新版本:', data.latestVersion);
resolve(data);
} catch (err) {
reject(new Error(`API 响应解析失败: ${(err as Error).message}`));
}
});
});
req.on('error', (err) => reject(new Error(`API 请求失败: ${err.message}`)));
req.end();
});
}
// ============================================================
// 下载安装包
// ============================================================
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}`);
console.log('[Updater] 开始下载:', info.downloadUrl);
console.log('[Updater] 临时文件:', tempPath);
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;
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)}...`,
),
);
}
console.log('[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();
});
}
// ============================================================
// 操作方法
// ============================================================
export async function checkForUpdates(): Promise<void> {
if (updateChecked) {
console.log('[Updater] 已检查过更新,跳过');
return;
}
if (import.meta.env.DEV) {
console.log('[Updater] 开发模式,跳过更新检查');
return;
}
try {
updateChecked = true;
const info = await fetchUpdateInfo();
if (!info) {
// 无更新,不发通知(静默)
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) {
console.error('[Updater] 更新流程失败:', (error as Error).message);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '更新检查失败',
});
updateChecked = false;
}
}
export async function checkForUpdatesManual(): Promise<void> {
try {
updateChecked = false;
const info = await fetchUpdateInfo();
if (!info) {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
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) {
console.error('[Updater] 手动更新失败:', (error as Error).message);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '检查更新失败',
});
}
}
export function installUpdateNow(): void {
if (!pendingInstallerPath) {
console.error('[Updater] 没有待安装的更新文件');
return;
}
console.log('[Updater] 开始安装更新:', 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 后执行(通常需要手动替换)
console.log('[Updater] Linux 更新请手动替换 AppImage 文件');
}
// 退出应用,让安装器接管
setImmediate(() => {
app.quit();
});
}
// ============================================================
// IPC 注册
// ============================================================
export function registerUpdateIpcHandlers(): void {
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
await checkForUpdatesManual();
});
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
installUpdateNow();
});
}