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 同步更新架构图和流程说明
This commit is contained in:
7
electron/electron-env.d.ts
vendored
7
electron/electron-env.d.ts
vendored
@@ -22,6 +22,11 @@ declare namespace NodeJS {
|
||||
}
|
||||
|
||||
// Used in Renderer process, expose in `preload.ts`
|
||||
interface Window {
|
||||
declare interface Window {
|
||||
ipcRenderer: import('electron').IpcRenderer;
|
||||
/** safeStorage 安全加密 API(基于 OS 原生密钥链) */
|
||||
safeStorage: {
|
||||
encrypt(plaintext: string): Promise<string | null>;
|
||||
decrypt(encryptedBase64: string): Promise<string | null>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import path from 'node:path';
|
||||
import { getPlatform, isMacOS } from './main/utils/platform';
|
||||
import { getWindowIconPath } from './main/utils/logo';
|
||||
import { buildWindowTitle, parseEdition } from '../shared/constants/app';
|
||||
import { loadEnvFile } from './main/load-env';
|
||||
import { initUpdater, registerUpdateIpcHandlers } from './main/updater';
|
||||
import { setupAppMenu } from './main/menu';
|
||||
import { initLogger, flushLogger, logger } from './main/logger';
|
||||
import { registerLogIpcHandlers } from './main/log-ipc';
|
||||
import { registerSafeStorageIpcHandlers } from './main/safe-storage-ipc';
|
||||
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
|
||||
|
||||
createRequire(import.meta.url);
|
||||
@@ -17,6 +19,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
process.env.APP_ROOT = path.join(__dirname, '..');
|
||||
|
||||
// 加载 .env 文件中的环境变量到 process.env
|
||||
// 必须在任何读取 process.env.UPDATE_API_URL 的模块之前调用
|
||||
loadEnvFile();
|
||||
|
||||
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'];
|
||||
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron');
|
||||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist');
|
||||
@@ -141,6 +147,7 @@ app.on('before-quit', () => {
|
||||
app.whenReady().then(() => {
|
||||
initLogger();
|
||||
registerLogIpcHandlers();
|
||||
registerSafeStorageIpcHandlers();
|
||||
setupAppMenu();
|
||||
app.setName(WINDOW_TITLE);
|
||||
registerUpdateIpcHandlers();
|
||||
|
||||
92
electron/main/load-env.ts
Normal file
92
electron/main/load-env.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// 主进程 .env 加载器:.env.{mode} → .env.local → OS 环境变量(后者覆盖前者)
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ESM 中 __dirname 不可用,手动计算
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* 解析单个 .env 文件,将 key=value 写入 process.env。
|
||||
* 已存在的 process.env 值不会被覆盖(OS 环境变量优先)。
|
||||
* 返回成功加载的变量数。
|
||||
*/
|
||||
function parseEnvFile(filePath: string, fileName: string): number {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
let count = 0;
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 跳过空行和注释
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// 解析 key=value
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
let value = trimmed.slice(eqIdx + 1).trim();
|
||||
|
||||
// 去除引号(支持 "value" 和 'value')
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// 不覆盖已在 OS 环境变量中设置的值
|
||||
if (key && !process.env[key]) {
|
||||
process.env[key] = value;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
console.log(`[load-env] 从 ${fileName} 加载了 ${count} 个环境变量`);
|
||||
}
|
||||
return count;
|
||||
} catch (err) {
|
||||
// 文件不存在 — 静默跳过(打包后 .env.* 不在 ASAR 中,这是正常情况)
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return 0;
|
||||
}
|
||||
// 其他错误(权限、编码等)— 警告但不中断
|
||||
console.warn(`[load-env] 读取 ${fileName} 失败:`, (err as Error).message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载环境变量文件到 process.env。
|
||||
* 开发模式加载 .env.development,生产模式加载 .env.production,
|
||||
* 之后再加载 .env.local 作为覆盖层。
|
||||
*/
|
||||
export function loadEnvFile(): void {
|
||||
// 项目根目录
|
||||
// APP_ROOT 在 main.ts 中设置(dist-electron/.. = 项目根)
|
||||
// fallback:dist-electron/main/load-env.js → __dirname/../.. = 项目根
|
||||
const projectRoot = process.env.APP_ROOT || join(__dirname, '..', '..');
|
||||
|
||||
// 确定当前模式
|
||||
const mode =
|
||||
!process.env.NODE_ENV || process.env.NODE_ENV === 'development'
|
||||
? 'development'
|
||||
: 'production';
|
||||
|
||||
console.log(
|
||||
`[load-env] 模式: ${mode}, projectRoot: ${projectRoot}`,
|
||||
);
|
||||
|
||||
// ① 基础环境文件(按模式选择)
|
||||
const baseFile = `.env.${mode}`;
|
||||
parseEnvFile(join(projectRoot, baseFile), baseFile);
|
||||
|
||||
// ② 本地覆盖文件(不进 git,用于打包后连测试服务器等特殊场景)
|
||||
const localFile = '.env.local';
|
||||
parseEnvFile(join(projectRoot, localFile), localFile);
|
||||
}
|
||||
@@ -63,19 +63,21 @@ const DEFAULT_TIMEOUT = 30_000;
|
||||
|
||||
/** 拼接 URL(baseURL + path + query) */
|
||||
function buildUrl(url: string, config: RequestConfig): string {
|
||||
let fullUrl = url;
|
||||
|
||||
if (config.baseURL) {
|
||||
// url 以 / 开头时为绝对路径,否则拼接
|
||||
const base = config.baseURL.replace(/\/+$/, '');
|
||||
const path = url.startsWith('/') ? url : `/${url}`;
|
||||
fullUrl = `${base}${path}`;
|
||||
// 如果没有 baseURL 且 url 不是绝对地址,直接报错(便于定位问题)
|
||||
if (!config.baseURL && !/^https?:\/\//i.test(url)) {
|
||||
throw new RequestError(
|
||||
`URL 缺少 baseURL,无法构造绝对地址。url="${url}", config=${JSON.stringify({ ...config, params: config.params ? '[object]' : undefined })}`,
|
||||
);
|
||||
}
|
||||
|
||||
const base = (config.baseURL || '').trim().replace(/\/+$/, '');
|
||||
const path = url.startsWith('/') ? url : `/${url}`;
|
||||
let fullUrl = base ? `${base}${path}` : url;
|
||||
|
||||
if (config.params) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(config.params)) {
|
||||
if (value !== undefined) {
|
||||
if (value !== undefined && value !== null) {
|
||||
search.append(key, String(value));
|
||||
}
|
||||
}
|
||||
@@ -85,6 +87,16 @@ function buildUrl(url: string, config: RequestConfig): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 调试:在 net.request 调用之前验证 URL 合法性
|
||||
try {
|
||||
void new URL(fullUrl);
|
||||
} catch {
|
||||
throw new RequestError(
|
||||
`构造的 URL 无法通过 Node.js 校验: "${fullUrl}"`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[net-request] buildUrl:', fullUrl);
|
||||
return fullUrl;
|
||||
}
|
||||
|
||||
@@ -112,6 +124,17 @@ export async function request<T = unknown>(
|
||||
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 调试:验证 URL 是否合法
|
||||
try {
|
||||
void new URL(fullUrl);
|
||||
} catch (urlErr) {
|
||||
console.error('[net-request] 非法 URL:', JSON.stringify(fullUrl));
|
||||
console.error('[net-request] URL 字符码:', [...fullUrl].map(c => c.charCodeAt(0)).join(','));
|
||||
console.error('[net-request] config:', JSON.stringify(config));
|
||||
reject(new RequestError(`非法 URL: ${(urlErr as Error).message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const req = net.request({
|
||||
method,
|
||||
url: fullUrl,
|
||||
|
||||
81
electron/main/safe-storage-ipc.ts
Normal file
81
electron/main/safe-storage-ipc.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// ============================================================
|
||||
// safe-storage-ipc — safeStorage 加密/解密 IPC 处理器
|
||||
//
|
||||
// 在主进程注册,渲染进程通过 preload 暴露的安全方法调用。
|
||||
// 加密后的数据为 Base64 字符串,方便存储在 localStorage。
|
||||
//
|
||||
// 平台对应:
|
||||
// Windows → DPAPI
|
||||
// macOS → Keychain
|
||||
// Linux → libsecret
|
||||
//
|
||||
// 注意:
|
||||
// - safeStorage 加密绑定当前 OS 用户账户,无法跨用户/跨设备解密
|
||||
// - 用户重装 OS 后加密数据将无法恢复(需重新登录)
|
||||
// - safeStorage.isEncryptionAvailable() 在部分 Linux 发行版可能返回 false
|
||||
// ============================================================
|
||||
|
||||
import { ipcMain, safeStorage } from 'electron';
|
||||
import { BIDIRECTIONAL } from '../../shared/constants/ipc-channels';
|
||||
import { logger } from './logger';
|
||||
|
||||
/** 检查 safeStorage 是否可用,不可用时记录警告 */
|
||||
function checkAvailability(): boolean {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
logger.warn('safe-storage', 'safeStorage 加密不可用(可能缺少 libsecret 等系统密钥服务)');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 safeStorage IPC 处理器
|
||||
* 在 app.whenReady() 中调用
|
||||
*/
|
||||
export function registerSafeStorageIpcHandlers(): void {
|
||||
// ---------- 加密 ----------
|
||||
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => {
|
||||
if (typeof plaintext !== 'string' || plaintext.length === 0) {
|
||||
logger.warn('safe-storage', '加密参数无效');
|
||||
return { success: false, error: '参数无效:需要非空字符串' };
|
||||
}
|
||||
|
||||
if (!checkAvailability()) {
|
||||
return { success: false, error: 'safeStorage 不可用' };
|
||||
}
|
||||
|
||||
try {
|
||||
const encrypted = safeStorage.encryptString(plaintext);
|
||||
const base64 = encrypted.toString('base64');
|
||||
return { success: true, data: base64 };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error('safe-storage', '加密失败', err instanceof Error ? err : new Error(message));
|
||||
return { success: false, error: message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 解密 ----------
|
||||
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => {
|
||||
if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) {
|
||||
logger.warn('safe-storage', '解密参数无效');
|
||||
return { success: false, error: '参数无效:需要非空字符串' };
|
||||
}
|
||||
|
||||
if (!checkAvailability()) {
|
||||
return { success: false, error: 'safeStorage 不可用' };
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(encryptedBase64, 'base64');
|
||||
const plaintext = safeStorage.decryptString(buffer);
|
||||
return { success: true, data: plaintext };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error('safe-storage', '解密失败', err instanceof Error ? err : new Error(message));
|
||||
return { success: false, error: message };
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('safe-storage', 'safeStorage IPC 处理器已注册');
|
||||
}
|
||||
@@ -1,292 +1,419 @@
|
||||
// ============================================================
|
||||
// 自动更新模块 — 自定义 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)
|
||||
// ============================================================
|
||||
// 自动更新 — HeiXiuProvider + electron-updater 增量下载
|
||||
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
|
||||
|
||||
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 {BrowserWindow, ipcMain} from 'electron';
|
||||
import {URL} from 'node:url';
|
||||
|
||||
import type { UpdateCheckResult } from '../../shared/types';
|
||||
import { APP_VERSION } from '../../shared/constants/version';
|
||||
import { logger } from './logger';
|
||||
import { netRequest } from './net-request';
|
||||
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',
|
||||
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 {
|
||||
return process.env.UPDATE_API_URL || 'https://www.heixiu.com';
|
||||
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 updateChecked = false;
|
||||
/** 当前正在下载的临时文件路径(用于安装后清理) */
|
||||
let pendingInstallerPath: string | null = null;
|
||||
let provider: HeiXiuProvider | null = null;
|
||||
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
export function initUpdater(mainWindow: BrowserWindow): void {
|
||||
updateWindow = mainWindow;
|
||||
updateWindow = mainWindow;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
|
||||
return;
|
||||
}
|
||||
console.log('[updater] initUpdater 被调用,API URL:', getApiBaseUrl());
|
||||
|
||||
// 启动 5 秒后静默检查
|
||||
setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 5000);
|
||||
}
|
||||
// if (import.meta.env.DEV) {
|
||||
// logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// ============================================================
|
||||
// 版本检查(调用 API)
|
||||
// ============================================================
|
||||
// 创建自定义 Provider
|
||||
provider = new HeiXiuProvider({
|
||||
isUseMultipleRangeRequest: true,
|
||||
platform: process.platform as ProviderRuntimeOptions['platform'],
|
||||
executor: null as unknown as ProviderRuntimeOptions['executor'],
|
||||
});
|
||||
|
||||
async function fetchUpdateInfo(): Promise<UpdateCheckResult | null> {
|
||||
const { data } = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
|
||||
'/api/v1/update/check',
|
||||
{
|
||||
baseURL: getApiBaseUrl(),
|
||||
params: {
|
||||
platform: process.platform,
|
||||
version: APP_VERSION,
|
||||
edition: process.env.EDITION || 'personal',
|
||||
},
|
||||
},
|
||||
);
|
||||
// 创建平台 Updater
|
||||
platformUpdater = createPlatformUpdater(provider);
|
||||
|
||||
// 兼容两种 API 响应格式:
|
||||
// 格式 A: { code: 0, data: UpdateCheckResult }
|
||||
// 格式 B: UpdateCheckResult 直接返回
|
||||
const result: UpdateCheckResult = (data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
|
||||
// 将 electron-updater 事件桥接到现有 IPC 通道
|
||||
platformUpdater.on('checking-for-update', () => {
|
||||
logger.debug('updater', '正在检查更新');
|
||||
});
|
||||
|
||||
if (!result.hasUpdate) {
|
||||
logger.info('updater', '当前已是最新版本');
|
||||
return null;
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
logger.info('updater', '发现新版本', { latestVersion: result.latestVersion });
|
||||
return result;
|
||||
}
|
||||
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});
|
||||
});
|
||||
|
||||
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}`);
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('updater', '开始下载', { downloadUrl: info.downloadUrl, tempPath });
|
||||
platformUpdater.on('update-downloaded', () => {
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
|
||||
logger.info('updater', '更新已下载完成');
|
||||
});
|
||||
|
||||
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;
|
||||
platformUpdater.on('error', (error: Error) => {
|
||||
logger.error('updater', '更新流程出错', error);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: error.message || '更新失败',
|
||||
});
|
||||
});
|
||||
|
||||
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)}...`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('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();
|
||||
});
|
||||
// 启动 5 秒后静默检查
|
||||
setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 操作方法
|
||||
// ============================================================
|
||||
|
||||
let updateChecked = false;
|
||||
|
||||
/** 静默检查更新(自动下载) */
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
if (updateChecked) {
|
||||
logger.debug('updater', '已检查过更新,跳过');
|
||||
return;
|
||||
}
|
||||
if (updateChecked) {
|
||||
logger.debug('updater', '已检查过更新,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
logger.debug('updater', '开发模式,跳过更新检查');
|
||||
return;
|
||||
}
|
||||
if (!platformUpdater || !provider) return;
|
||||
|
||||
try {
|
||||
updateChecked = true;
|
||||
const info = await fetchUpdateInfo();
|
||||
try {
|
||||
updateChecked = true;
|
||||
|
||||
if (!info) {
|
||||
// 无更新,不发通知(静默)
|
||||
return;
|
||||
}
|
||||
// ① 先自行检查有无更新(不经过 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;
|
||||
}
|
||||
|
||||
// 通知渲染进程:有新版本
|
||||
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) {
|
||||
logger.error('updater', '更新流程失败', error as Error, { apiUrl: getApiBaseUrl() });
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '更新检查失败',
|
||||
});
|
||||
updateChecked = false;
|
||||
}
|
||||
// ② 有新版本 → 交给 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> {
|
||||
try {
|
||||
updateChecked = false;
|
||||
const info = await fetchUpdateInfo();
|
||||
if (!platformUpdater || !provider) return;
|
||||
|
||||
if (!info) {
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
updateChecked = false;
|
||||
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
|
||||
version: info.latestVersion,
|
||||
releaseDate: info.releaseDate,
|
||||
releaseNotes: info.changelog,
|
||||
forceUpdate: info.forceUpdate,
|
||||
});
|
||||
// ① 先自行检查有无更新
|
||||
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;
|
||||
}
|
||||
|
||||
await downloadInstaller(info);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
|
||||
} catch (error) {
|
||||
logger.error('updater', '手动更新失败', error as Error);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '检查更新失败',
|
||||
});
|
||||
}
|
||||
// ② 有新版本 → 交给 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 (!pendingInstallerPath) {
|
||||
logger.warn('updater', '没有待安装的更新文件');
|
||||
return;
|
||||
}
|
||||
if (!platformUpdater) {
|
||||
logger.warn('updater', 'Updater 未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('updater', '开始安装更新', { installerPath: 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: AppImage,chmod +x 后执行(通常需要手动替换)
|
||||
logger.warn('updater', 'Linux 更新需手动替换 AppImage 文件');
|
||||
}
|
||||
|
||||
// 退出应用,让安装器接管
|
||||
setImmediate(() => {
|
||||
app.quit();
|
||||
});
|
||||
logger.info('updater', '开始安装更新并退出应用');
|
||||
setImmediate(() => {
|
||||
platformUpdater!.quitAndInstall(false, true);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -294,11 +421,15 @@ export function installUpdateNow(): void {
|
||||
// ============================================================
|
||||
|
||||
export function registerUpdateIpcHandlers(): void {
|
||||
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
|
||||
await checkForUpdatesManual();
|
||||
});
|
||||
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
|
||||
await checkForUpdatesManual();
|
||||
});
|
||||
|
||||
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
|
||||
installUpdateNow();
|
||||
});
|
||||
ipcMain.handle(UPDATE_CHANNELS.DOWNLOAD_UPDATE, async () => {
|
||||
await downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
|
||||
installUpdateNow();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
|
||||
|
||||
// --------- Expose some API to the Renderer process ---------
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
@@ -18,7 +19,27 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.invoke(channel, ...omit);
|
||||
},
|
||||
|
||||
// You can expose other APTs you need here.
|
||||
// ...
|
||||
});
|
||||
|
||||
// --------- 安全加密 API(基于 Electron safeStorage)---------
|
||||
contextBridge.exposeInMainWorld('safeStorage', {
|
||||
/**
|
||||
* 加密明文字符串
|
||||
* @param plaintext - 待加密的明文
|
||||
* @returns Base64 编码的密文,失败返回 null
|
||||
*/
|
||||
async encrypt(plaintext: string): Promise<string | null> {
|
||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
|
||||
return result?.success ? result.data : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 解密 Base64 密文
|
||||
* @param encryptedBase64 - Base64 编码的密文
|
||||
* @returns 解密后的明文,失败返回 null
|
||||
*/
|
||||
async decrypt(encryptedBase64: string): Promise<string | null> {
|
||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
|
||||
return result?.success ? result.data : null;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user