Files
dream_pep 37a5bb7538 refactor(updater, ci): 重构版本处理逻辑,适配新的版本格式
调整GitHub Release工作流的beta版本号格式,将`beta.$buildId`改为`$buildId.beta`,对齐新的版本命名规范
新增统一的版本标签解析与过滤逻辑,支持识别多种版本格式并屏蔽旧版beta候选
重写版本比较函数,修复旧版本比较的逻辑问题
重构更新检查与通道配置流程,不再依赖electron-updater的内置频道规则
优化发布说明获取的回退逻辑,统一使用generic feed结合多镜像源提升更新稳定性
2026-09-05 19:08:23 +08:00

788 lines
31 KiB
TypeScript
Raw Permalink 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.
import { autoUpdater, CancellationToken, type ProgressInfo } from 'electron-updater';
import electron from 'electron';
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as os from 'os';
import semver from 'semver';
import { getConfig, updateConfig, flushConfig } from './config';
import { createLogger } from './core/logger';
const { app } = electron;
const log = createLogger('updater');
export type UpdateState =
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'paused'
| 'downloaded'
| 'installing'
| 'error';
export interface UpdateStatusPayload {
state: UpdateState;
/** 是否为手动触发(手动触发时前端不弹提示) */
manual: boolean;
/** 目标版本号 */
version?: string;
/** 当前安装版本 */
currentVersion?: string;
percent?: number;
transferred?: number;
total?: number;
bytesPerSecond?: number;
/** 当前使用的更新源(github=官方 / 加速源域名) */
source?: string;
/** 当前更新通道(woker / runner */
channel?: string;
/** 安装包是否已通过本地核验(sha512 / 大小) */
verified?: boolean;
error?: string;
}
/** 更新通道 key(可扩展:新增通道只需在 UPDATE_CHANNELS 注册) */
export type UpdateChannelKey = 'woker' | 'runner';
export interface UpdateChannelDef {
key: UpdateChannelKey;
/** 显示名 */
label: string;
/** 说明 */
desc: string;
/** 是否接收预览版(runner 可收 betawoker 只收正式版) */
allowPrerelease: boolean;
}
/**
* 更新通道注册表(后期扩展新通道:在此追加一项即可,UI 通过 update:getChannels 动态渲染)。
* - woker(慢走模式,默认):仅检查/获取正式版(稳定)更新
* - runner(跑步模式):可获取预览版(测试版)更新
*/
const UPDATE_CHANNELS: UpdateChannelDef[] = [
{ key: 'woker', label: '慢走模式', desc: '仅获取正式版更新(稳定)', allowPrerelease: false },
{ key: 'runner', label: '跑步模式', desc: '可获取预览版(测试版)更新', allowPrerelease: true },
];
function getChannelDef(key: string): UpdateChannelDef {
return UPDATE_CHANNELS.find((c) => c.key === key) ?? UPDATE_CHANNELS[0];
}
export interface ReleaseNotesResult {
/** release tag,如 v1.2.0-2608271921 */
tag: string;
/** 版本号(去 v 前缀) */
version: string;
/** 发布说明原始 Markdown */
notes: string;
/** 读取来源:github / 加速源域名 */
source: string;
/** 是否为最新版本的说明(当前版本无发布说明时回退) */
isLatest: boolean;
}
const OWNER = 'dream-pep';
const REPO = 'koring-launcher';
const DISCOVER_TIMEOUT_MS = 15000;
/**
* 内置加速源(ghproxy 类,代理完整 GitHub URLlatest.yml 的相对路径可解析)。
* 实测(2026-08-30):gh.ddlc.top / gh-proxy.com / ghfast.top 行为正确(按原状转发,404 即 404);
* ghps.cc 已被移除——它对任何请求都返回 200 + HTML 跳转拦截页,会污染 latest.yml / release-notes.md。
* 第三方服务稳定性有限,可用环境变量 UPDATE_MIRRORS 覆盖(逗号分隔),
* 后续建议换成自建 OSS/CDN 镜像(generic provider 直接指向镜像根目录)。
*/
const DEFAULT_MIRRORS: string[] = ['https://gh.ddlc.top', 'https://gh-proxy.com', 'https://ghfast.top'];
function getMirrors(): string[] {
const env = process.env.UPDATE_MIRRORS;
if (env) {
return env.split(',').map((s) => s.trim()).filter(Boolean);
}
return DEFAULT_MIRRORS;
}
/** 版本格式分类 */
type TagKind = 'stable' | 'beta' | 'oldbeta' | 'plain';
interface ParsedTag {
base: number[];
/** 构建号(渠道标记不参与排序) */
num: number;
kind: TagKind;
}
/**
* 项目版本格式(2026- 起):
* - 正式版(run): {base}-{N} 如 1.2.6-25
* - 测试版(beta): {base}-{N}.beta 如 1.2.6-16.beta(编号在前、beta 在后)
* - 旧测试版(废弃):{base}-beta.{N} 如 1.2.6-beta.16 —— 检测时【屏蔽】不作为候选
* - 旧正式版(更早):{base} 如 1.2.5(无构建号,同 base 最旧)
*/
function parseTag(tag: string): ParsedTag {
const s = tag.replace(/^v/i, '');
const dash = s.indexOf('-');
const baseStr = dash === -1 ? s : s.slice(0, dash);
const tail = dash === -1 ? '' : s.slice(dash + 1);
const nums = baseStr.split('.').map((n) => parseInt(n, 10) || 0);
while (nums.length < 3) nums.push(0);
if (tail === '') return { base: nums, num: -1, kind: 'plain' };
const mStable = /^(\d+)$/.exec(tail);
if (mStable) return { base: nums, num: parseInt(mStable[1], 10), kind: 'stable' };
const mBeta = /^(\d+)\.beta$/i.exec(tail);
if (mBeta) return { base: nums, num: parseInt(mBeta[1], 10), kind: 'beta' };
const mOldBeta = /^beta\.(\d+)$/i.exec(tail);
if (mOldBeta) return { base: nums, num: parseInt(mOldBeta[1], 10), kind: 'oldbeta' };
// 无法识别 → 视为未知旧格式(候选阶段一并屏蔽),构建号取 0
return { base: nums, num: 0, kind: 'oldbeta' };
}
/** 该 tag 是否为"屏蔽的旧格式"(旧 beta-beta.N 或无法识别的尾巴) */
function isMaskedLegacyTag(tag: string): boolean {
return parseTag(tag).kind === 'oldbeta';
}
/** 候选是否允许当前通道使用(woker=仅正式;runner=正式+新版测试版;旧格式一律屏蔽) */
function isCandidateAllowed(tag: string, allowPrerelease: boolean): boolean {
if (isMaskedLegacyTag(tag)) return false;
const kind = parseTag(tag).kind;
return allowPrerelease || kind === 'stable' || kind === 'plain';
}
/**
* 项目版本序:比较两个版本 tag,返回 a - b。
* base 数值优先;base 相同比构建号(数字部分)——beta/正式只是通道标记,不参与新旧排序。
* 旧格式(-beta.N)按构建号参与排序(使旧版安装也能升级到新格式/更高构建号),
* 但不会作为候选被选中(见 isCandidateAllowed)。
*/
function compareVersionTags(a: string, b: string): number {
const pa = parseTag(a);
const pb = parseTag(b);
for (let i = 0; i < 3; i++) {
if (pa.base[i] !== pb.base[i]) return pa.base[i] - pb.base[i];
}
return pa.num - pb.num;
}
/**
* 更新服务:electron-updaterGitHub provider+ 加速源兜底。
* 状态:idle → checking → available → downloading ⇄ paused → downloaded → installing → quitAndInstall
* 下载控制:CancellationToken 实现暂停(中断)/继续/取消。
* 进度持久化:每次状态/进度变化写入 Koring.yml 的 update 段(下载与安装进度落盘)。
*/
class UpdateService {
private state: UpdateState = 'idle';
private manual = false;
private version: string | undefined;
private currentVersion = '';
private progress: ProgressInfo | null = null;
private source = 'github';
private error: string | undefined;
private listener: ((payload: UpdateStatusPayload) => void) | null = null;
private ready = false;
/** 检查过程中屏蔽 error 事件(避免 GitHub 失败被当成最终错误) */
private suppressErrors = false;
/** 当前下载的取消令牌(暂停/取消时 cancel) */
private downloadToken: CancellationToken | null = null;
/** 目标安装包是否已通过本地核验(sha512 + 大小),核验通过前不允许安装 */
private verified = false;
/** update-available 时记录的期望 sha512(来自 latest.yml */
private expectedSha512 = '';
/** 期望文件大小(字节) */
private expectedSize = 0;
/** 当前更新通道(woker 慢走 / runner 跑步;从配置读取,可运行时切换) */
private channelKey: UpdateChannelKey = 'woker';
init(listener: (payload: UpdateStatusPayload) => void): void {
this.listener = listener;
this.currentVersion = app.getVersion();
if (!app.isPackaged) {
log.info('[updater] 开发模式:跳过自动更新');
this.emit();
return;
}
// 恢复持久化的更新通道
const persistedChannel = getConfig().update?.channel;
if (persistedChannel && UPDATE_CHANNELS.some((c) => c.key === persistedChannel)) {
this.channelKey = persistedChannel as UpdateChannelKey;
}
// 应用能启动即说明上次安装已完成/已结束,清理持久化的进行中状态
const persisted = getConfig().update;
if (persisted && persisted.state && persisted.state !== 'idle') {
log.info(`[updater] 上次更新状态 ${persisted.state} (v${persisted.version}),已重置`);
this.persistIdleConfig();
}
autoUpdater.autoDownload = false;
// 安装一律走我们受控的 quitAndInstall(含核验与确认弹窗),
// 禁止 electron-updater 在退出时静默自动安装(否则未核验/核验失败的包可能被直接装上)
autoUpdater.autoInstallOnAppQuit = false;
this.applyChannel();
autoUpdater.logger = console;
autoUpdater.on('checking-for-update', () => {
this.state = 'checking';
this.emit();
});
autoUpdater.on('update-available', (info) => {
this.state = 'available';
this.version = info.version;
this.error = undefined;
this.verified = false;
// 记录期望安装包校验值(来自 latest.yml 的 files[0]
const anyInfo = info as unknown as { files?: Array<{ sha512?: string; size?: number }> };
const first = Array.isArray(anyInfo?.files) ? anyInfo.files[0] : null;
this.expectedSha512 = String(first?.sha512 ?? '');
this.expectedSize = Number(first?.size ?? 0);
log.info(`[updater] 可用更新 ${info.version},期望 sha512=${this.expectedSha512.slice(0, 12)}… size=${this.expectedSize}`);
this.emit();
});
autoUpdater.on('update-not-available', () => {
this.state = 'not-available';
this.version = undefined;
this.verified = false;
this.emit();
});
autoUpdater.on('download-progress', (p) => {
this.state = 'downloading';
this.progress = p;
this.emit();
});
// 下载完成 → 本地核验安装包(sha512 + 大小)。
// · 通过:进入"已下载可安装"verified=true
// · 失败:仍进入"已下载",但 verified=false + 记录原因 —— 点"安装"会弹确认框
// (继续安装 / 取消并删除安装包),绝不静默安装未核验包
autoUpdater.on('update-downloaded', async (info) => {
const errMsg = await this.verifyDownloadedPackage();
if (errMsg) {
log.error(`[updater] 安装包核验失败: ${errMsg}`);
this.state = 'downloaded';
this.verified = false;
this.version = info.version;
this.error = errMsg;
this.emit();
return;
}
log.info('[updater] 安装包核验通过(sha512 + 大小)');
this.state = 'downloaded';
this.verified = true;
this.version = info.version;
this.error = undefined;
this.emit();
});
autoUpdater.on('error', (err: Error) => {
const message = String(err?.message ?? err);
log.warn(`[updater] electron-updater error: ${message}`);
if (this.suppressErrors) return; // 兜底循环内,忽略
if (this.downloadToken?.cancelled) return; // 主动暂停/取消,忽略
this.state = 'error';
this.error = message;
this.emit();
});
this.ready = true;
}
private emit(): void {
const payload = this.buildPayload();
this.listener?.(payload);
this.persist(payload);
}
private buildPayload(): UpdateStatusPayload {
return {
state: this.state,
manual: this.manual,
version: this.version,
currentVersion: this.currentVersion,
percent: this.progress?.percent,
transferred: this.progress?.transferred,
total: this.progress?.total,
bytesPerSecond: this.progress?.bytesPerSecond,
source: this.source,
channel: this.channelKey,
verified: this.verified,
error: this.error,
};
}
/**
* 本地核验已下载的安装包(在安装前执行):
* 1) 文件存在性;
* 2) 大小与 latest.yml 记录一致(有期望值时);
* 3) sha512 与 latest.yml 记录一致(逐块流式计算,防篡改/下载损坏)。
* 返回 null = 通过;返回字符串 = 失败原因(调用方进入 error,拒绝安装)。
*/
private async verifyDownloadedPackage(): Promise<string | null> {
const helper = (autoUpdater as unknown as { downloadedUpdateHelper?: { file?: string } }).downloadedUpdateHelper;
const filePath = helper?.file;
if (!filePath) return '未找到已下载的安装包';
try {
const stat = await fs.promises.stat(filePath);
if (this.expectedSize > 0 && stat.size !== this.expectedSize) {
return `安装包大小不符(期望 ${this.expectedSize} 字节,实际 ${stat.size} 字节)`;
}
if (this.expectedSha512) {
const hash = crypto.createHash('sha512');
await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve());
stream.on('error', reject);
});
const actual = hash.digest('hex').toLowerCase();
if (actual !== this.expectedSha512.toLowerCase()) {
return '安装包校验和不符(sha512 不匹配),文件可能已损坏或被篡改';
}
}
return null;
} catch (e) {
return `安装包核验失败:${String((e as Error)?.message ?? e)}`;
}
}
/** 将当前状态与下载进度写入配置(下载/安装进度落盘) */
private persist(payload: UpdateStatusPayload): void {
try {
updateConfig({
update: {
state: payload.state,
version: payload.version ?? '',
percent: payload.percent ?? 0,
transferred: payload.transferred ?? 0,
total: payload.total ?? 0,
source: payload.source ?? 'github',
channel: this.channelKey,
error: payload.error ?? '',
},
});
} catch (e) {
log.warn('[updater] 更新进度写入配置失败:', e);
}
}
private persistIdleConfig(): void {
try {
updateConfig({
update: { state: 'idle', version: '', percent: 0, transferred: 0, total: 0, source: 'github', channel: this.channelKey, error: '' },
});
} catch {
/* ignore */
}
}
/**
* 按当前通道应用 electron-updater 参数。
* 新版本方案({base}-{N} 正式 / {base}-{N}.beta 测试)下,版本识别不再依赖
* electron-updater 的 GitHub 频道循环(-N.beta 的 prerelease[0] 是数字,会被当自定义频道),
* 候选由本项目已实现自行选定(check → fetchCandidates/pickCandidate → generic feed)。
* 这里统一 channel='latest'generic feed 取 latest.yml / latest-linux.yml
* 且 setter 自动开启 allowDowngrade,供 semver 门接受"编号更大但 semver 偏旧"的候选)。
*/
private applyChannel(): void {
const def = getChannelDef(this.channelKey);
autoUpdater.allowPrerelease = def.allowPrerelease;
autoUpdater.channel = 'latest';
log.info(`[updater] 更新通道: ${def.label}${def.key}allowPrerelease=${def.allowPrerelease}`);
}
/** 通道定义列表(UI 动态渲染;可扩展) */
getChannels(): UpdateChannelDef[] {
return UPDATE_CHANNELS;
}
/** 切换更新通道(校验 + 持久化 + 立即生效,下次检查生效) */
setChannel(key: string): UpdateStatusPayload {
if (!UPDATE_CHANNELS.some((c) => c.key === key)) {
log.warn(`[updater] 未知更新通道: ${key}`);
return this.buildPayload();
}
if (this.channelKey === key) return this.buildPayload();
this.channelKey = key as UpdateChannelKey;
this.applyChannel();
try {
updateConfig({ update: { channel: key } });
} catch (e) {
log.warn('[updater] 通道写入配置失败:', e);
}
this.emit();
return this.buildPayload();
}
getState(): UpdateStatusPayload {
return this.buildPayload();
}
/**
* 测试用:覆盖当前识别到的版本号(影响 update:getState 与后续更新检查的比对)。
* 传入非法版本时忽略并返回当前状态。
*/
setTestVersion(version: string): UpdateStatusPayload {
const v = semver.valid(version.trim());
if (!v) {
log.warn(`[updater] 无效测试版本号: ${version}`);
return this.buildPayload();
}
this.currentVersion = v;
try {
// currentVersion 在类型声明中为 readonly,但运行时可直接赋值(测试工具用)
(autoUpdater as unknown as { currentVersion: unknown }).currentVersion = semver.parse(v);
} catch (e) {
log.warn('[updater] 设置 autoUpdater.currentVersion 失败:', e);
}
log.info(`[updater] 测试版本号 → ${v}`);
this.emit();
return this.buildPayload();
}
/**
* 项目版本序判定:candidate 是否为 current 的「新版本」。
* 见 compareVersionTags 的语义(base 相同 → 比构建号;beta/正式只是通道标记,不参与新旧)。
*/
private isNewerCandidate(current: string, candidate: string): boolean {
return compareVersionTags(candidate, current) > 0;
}
/**
* 复核 electron-updater 的"新版本"判定并修正状态:
* electron-updater 用纯 semverAppUpdater.isUpdateAvailable → semver.gt),
* 而 semver 规定同 base 下字母标识 > 数字标识 → v1.2.5-17 会把 v1.2.5-beta.16
* 误判为新版本。按项目版本序复核:候选版本并非更新 → 状态回退为 not-available。
*/
private correctAvailability(): void {
if (this.state !== 'available' || !this.version) return;
if (this.isNewerCandidate(this.currentVersion, this.version)) return;
log.warn(`[updater] ${this.version} 不是 ${this.currentVersion} 的新版本(项目版本序,忽略 beta 通道标记),回退为无更新`);
this.state = 'not-available';
this.version = undefined;
this.error = undefined;
this.emit();
}
/** 版本比对(semver 规则,支持 v 前缀与 prerelease */
compareVersions(a: string, b: string): { a: string; b: string; result: string; detail: string } {
const va = semver.valid(a.trim());
const vb = semver.valid(b.trim());
if (!va || !vb) {
return {
a: a.trim(),
b: b.trim(),
result: 'invalid',
detail: `无效版本:${!va ? `「${a.trim()}」` : ''}${!va && !vb ? ' / ' : ''}${!vb ? `「${b.trim()}」` : ''}`,
};
}
const c = semver.compare(va, vb);
return {
a: va,
b: vb,
result: c > 0 ? 'a>b' : c < 0 ? 'a<b' : 'a==b',
detail: `${va} ${c > 0 ? '>' : c < 0 ? '<' : '=='} ${vb}`,
};
}
/**
* 检查更新(版本方案:{base}-{N} 正式 / {base}-{N}.beta 测试 / 旧 -beta.{N} 屏蔽):
* 1. 按来源依次(GitHub 官方 → 各加速源)抓取 release 候选;
* 2. 按通道(woker=仅正式 / runner=正式+新测试版)过滤并剔除旧格式;
* 3. 按项目版本序挑出"比当前新"的最新 tag → 指向该 tag 的 generic feed 走 electron-updater
* (下载/校验/安装链路复用);版本序复核通过才判 available。
* 不依赖 electron-updater 的 GitHub 频道循环(新格式 -N.beta 会被其当作自定义频道)。
*/
async check(manual = false): Promise<UpdateStatusPayload> {
if (!this.ready) return this.buildPayload();
if (['downloading', 'paused', 'downloaded', 'installing'].includes(this.state)) {
return this.buildPayload(); // 已有更新在进行中/已完成
}
this.manual = manual;
this.suppressErrors = true;
const allowPrerelease = getChannelDef(this.channelKey).allowPrerelease;
this.state = 'checking';
this.emit();
const sources: { label: string; base: string }[] = [
{ label: 'github', base: '' },
...getMirrors().map((m) => ({ label: m, base: `${m}/` })),
];
for (const { label, base } of sources) {
try {
const candidates = await this.fetchCandidates(base);
const tag = this.pickCandidate(candidates, allowPrerelease);
if (!tag) {
log.info(`[updater] ${label}: 无符合条件的更新候选(通道/格式/版本序)`);
continue; // 该源没有更新,尝试下一个源
}
const feedUrl = `${base}https://github.com/${OWNER}/${REPO}/releases/download/${tag}/`;
log.info(`[updater] 检查源 ${label},命中 ${tag} (feed: ${feedUrl})`);
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl });
// generic feed 取 latest.yml / latest-linux.ymlchannel='latest' 同时开启 allowDowngrade
// 使 semver 门接受"编号更大但 semver 判定偏旧"的候选(如旧 -beta.N 当前 → 新格式)
autoUpdater.allowPrerelease = allowPrerelease;
autoUpdater.channel = 'latest';
this.source = label;
this.state = 'checking';
this.emit();
await autoUpdater.checkForUpdates();
// 项目版本序复核:候选确为更新才保留 available
this.correctAvailability();
const result = this.buildPayload();
if (result.state !== 'not-available') {
this.suppressErrors = false;
return result;
}
log.warn(`[updater] ${label} 反馈无可用更新,尝试下一个源`);
} catch (err) {
log.warn(`[updater] 更新源 ${label} 检查失败: ${String((err as Error)?.message ?? err)}`);
}
}
this.suppressErrors = false;
this.state = 'error';
this.error = '无法连接更新服务器(GitHub 与所有加速源均不可用),请稍后重试';
this.emit();
return this.buildPayload();
}
/**
* 抓取某来源的 release tag 候选列表:
* base='' 为 GitHub 官方直连;否则 base=`${mirror}/`(加速源前缀,原样拼 https://)。
* 组合:API 列表(含全部 release+ /releases/latest 页面 HTML(兜底)。
*/
private async fetchCandidates(base: string): Promise<string[]> {
const candidates: string[] = [];
const web = `${base}https://github.com/${OWNER}/${REPO}`;
const api = `${base}https://api.github.com/repos/${OWNER}/${REPO}`;
// 方式 1/releases/latest 页面(最新非 prerelease release 的 tagHTML 正则兜底)
try {
const res = await fetch(`${web}/releases/latest`, {
headers: { 'User-Agent': 'koring-launcher-updater' },
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
});
if (res.ok) {
const html = await res.text();
const m = html.match(/\/releases\/tag\/(v[^"<]+)/);
if (m?.[1]) candidates.push(m[1]);
}
} catch {
/* 尝试下一种方式 */
}
// 方式 2GitHub API release 列表
try {
const res = await fetch(`${api}/releases?per_page=40`, {
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'koring-launcher-updater' },
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
});
if (res.ok) {
const releases: { draft?: boolean; tag_name?: string }[] = await res.json();
for (const r of releases ?? []) {
if (r.draft || !r.tag_name) continue;
candidates.push(r.tag_name);
}
}
} catch {
/* ignore */
}
return candidates;
}
/** 从候选里挑出允许当前通道、且按项目版本序比当前更新的最新 tag;没有返回 null */
private pickCandidate(candidates: string[], allowPrerelease: boolean): string | null {
const allowed = candidates
.filter((c) => isCandidateAllowed(c, allowPrerelease))
.filter((c) => compareVersionTags(c, this.currentVersion) > 0);
if (allowed.length === 0) return null;
allowed.sort(compareVersionTags);
return allowed[allowed.length - 1];
}
/** 不限当前版本:返回某来源允许通道/格式的最新 tag(发布说明回退用);没有返回 null */
private async fetchBestCandidate(base: string, allowPrerelease: boolean): Promise<string | null> {
const candidates = await this.fetchCandidates(base);
const allowed = candidates.filter((c) => isCandidateAllowed(c, allowPrerelease));
if (allowed.length === 0) return null;
allowed.sort(compareVersionTags);
return allowed[allowed.length - 1];
}
/**
* 获取指定版本(默认当前安装版本)的发布说明(release-notes.md 附件,原始 Markdown)。
* GitHub 直连优先,失败后依次尝试加速源;当前版本没有发布说明时回退到最新版本。
*/
async getReleaseNotes(requestedTag?: string): Promise<ReleaseNotesResult | null> {
const tag = (requestedTag?.trim() || `v${app.getVersion()}`).replace(/^v(?=\d)/, 'v');
const found = await this.fetchNotesForTag(tag);
if (found) return found;
// 回退:最新版本(发现 tag 后取发布说明)
for (const mirror of getMirrors()) {
const latestTag = await this.fetchBestCandidate(`${mirror}/`, true);
if (latestTag && latestTag !== tag) {
const foundLatest = await this.fetchNotesForTag(latestTag);
if (foundLatest) {
return { ...foundLatest, isLatest: true };
}
}
}
return null;
}
private async fetchNotesForTag(tag: string): Promise<ReleaseNotesResult | null> {
const bases: { source: string; base: string }[] = [
{ source: 'github', base: `https://github.com/${OWNER}/${REPO}` },
...getMirrors().map((m) => ({ source: m, base: `${m}/https://github.com/${OWNER}/${REPO}` })),
];
for (const { source, base } of bases) {
try {
const url = `${base}/releases/download/${tag}/release-notes.md`;
const res = await fetch(url, {
headers: { 'User-Agent': 'koring-launcher-updater' },
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
});
if (res.ok) {
// 部分加速源对任意 URL 返回 200 + HTML 跳转/拦截页,必须校验内容
const contentType = res.headers.get('content-type') ?? '';
if (/^text\/html/i.test(contentType)) continue;
const notes = await res.text();
if (!notes.trim()) continue;
if (/^\s*<!doctype html/i.test(notes) || /^\s*<html[\s>]/i.test(notes)) continue;
log.info(`[updater] 发布说明来源: ${source} (${tag})`);
return { tag, version: tag.replace(/^v/, ''), notes, source, isLatest: false };
}
} catch {
/* 尝试下一个源 */
}
}
return null;
}
/**
* 下载更新(触发下载,进度经 update:status 上报并写入配置)。
* available → 开始下载;paused → 继续下载。
*/
async download(): Promise<void> {
if (!this.ready) return;
if (this.state === 'downloading' || this.state === 'downloaded' || this.state === 'installing') return;
// 非"可用/已暂停"状态直接忽略(防陈旧 updateInfoAndProvider 被误用)
if (this.state !== 'available' && this.state !== 'paused') return;
// 复核目标版本确为当前版本的新版本(项目版本序),否则回退为无更新
if (this.state === 'available' && this.version && !this.isNewerCandidate(this.currentVersion, this.version)) {
log.warn(`[updater] 下载被拒:${this.version} 不是 ${this.currentVersion} 的新版本`);
this.state = 'not-available';
this.version = undefined;
this.emit();
return;
}
if (this.state === 'paused') {
// 继续下载(可能从断点续传,也可能重新开始,取决于 electron-updater 缓存)
log.info('[updater] 继续下载');
}
this.state = 'downloading';
this.progress = null;
this.emit();
const token = new CancellationToken();
this.downloadToken = token;
try {
await autoUpdater.downloadUpdate(token);
} catch (err) {
if (token.cancelled) return; // 主动暂停/取消,不是错误
this.state = 'error';
this.error = String((err as Error)?.message ?? err);
this.emit();
}
}
/** 暂停下载(中断当前请求,保留进度;再次下载即继续) */
pause(): void {
if (!this.ready || this.state !== 'downloading') return;
this.state = 'paused';
this.emit();
this.downloadToken?.cancel();
}
/** 取消下载:中断并清除进度,回到 available(可重新下载) */
cancel(): void {
if (!this.ready) return;
this.downloadToken?.cancel();
this.downloadToken = null;
if (this.state === 'downloading' || this.state === 'paused') {
this.state = 'available';
this.progress = null;
this.emit();
}
}
/**
* 退出并安装(NSIS 静默安装,安装完成自动重启)。
* 安装状态先写入配置并立即落盘,避免退出时 debounce 未写盘。
* 安装包未通过核验(verified=false)时先弹确认框:
* 继续安装 / 取消并删除安装包 —— 绝不静默安装校验异常的文件。
*/
async quitAndInstall(): Promise<void> {
if (!this.ready || this.state !== 'downloaded') return;
if (!this.verified) {
const { dialog, BrowserWindow } = electron;
const parent = BrowserWindow.getAllWindows().find((w) => w.isVisible()) ?? null;
const opts: electron.MessageBoxOptions = {
type: 'warning',
title: '版本校验异常',
message: '请注意,版本校验异常,可能是文件损坏或者被替换,因此您会看到此弹窗,您可以选择继续安装或取消并删除安装包',
detail: this.error ? `核验详情:${this.error}` : '核验详情:sha512 校验和与发布记录不一致',
buttons: ['继续安装', '取消并删除安装包'],
defaultId: 1,
cancelId: 1,
noLink: true,
};
const { response } = parent
? await dialog.showMessageBox(parent, opts)
: await dialog.showMessageBox(opts);
if (response !== 0) {
// 取消并删除安装包
log.warn('[updater] 用户取消安装并删除校验异常包');
await this.removeDownloadedPackage().catch((e) => log.warn('[updater] 删除安装包失败:', e));
this.state = 'idle';
this.version = undefined;
this.error = undefined;
this.verified = false;
this.emit();
return;
}
log.warn('[updater] 用户确认继续安装(校验异常但已确认)');
}
this.state = 'installing';
this.emit();
try {
flushConfig();
} catch {
/* ignore */
}
// 静默安装(/S)+ 安装完成后自动重启(--force-run
autoUpdater.quitAndInstall(true, true);
}
/** 删除已下载(校验失败)的安装包 */
private async removeDownloadedPackage(): Promise<void> {
const helper = (autoUpdater as unknown as { downloadedUpdateHelper?: { file?: string } }).downloadedUpdateHelper;
const filePath = helper?.file;
if (!filePath) return;
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
log.info(`[updater] 已删除安装包: ${filePath}`);
}
}
/** 获取系统下载临时目录(用于清理提示,暂未启用) */
getCacheDir(): string {
return os.tmpdir();
}
}
export const updateService = new UpdateService();