Files
koring-launcher/electron/updater.ts
T
dream_pep 8fc6fcaf05 feat(update): 实现更新通道管理系统与更新调试页面
本次提交完成以下变更:
1. 实现完整的更新通道管理功能,支持慢走/跑步两种更新模式,可在设置页切换且配置持久化生效
2. 新增更新调试页面,集成检查更新、版本比对、设置测试版本等调试工具
3. 新增快速打开Chromium DevTools的按钮,方便开发调试
4. 完善自动更新后端逻辑,新增版本比对、测试版本覆盖、通道动态获取等IPC接口
5. 调整GitHub Release构建脚本,修复prerelease版本的命名与发布问题
6. 添加semver依赖与类型定义,完善全量类型声明
7. 更新自动更新文档,补充更新通道相关的设计说明
8. 微调默认配置,添加主题暗黑模式的初始配置
2026-08-31 02:20:25 +08:00

570 lines
20 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.
import { autoUpdater, CancellationToken, type ProgressInfo } from 'electron-updater';
import electron from 'electron';
import * as os from 'os';
import semver from 'semver';
import { getConfig, updateConfig, flushConfig } from './config';
const { app } = electron;
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;
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;
}
/**
* 比较两个版本 tag(如 v1.2.1-5 / v1.2.1 / v1.2.0-2608271921),返回 a - b。
* 项目版本为 {base}-{buildId}base 按 X.Y.Z 数值比较,buildId 按数值比较(而非字符串)。
* 无 buildId 的稳定版视为大于同 base 的任意 prerelease(与 semver 一致)。
*/
function compareVersionTags(a: string, b: string): number {
const parse = (t: string): { base: number[]; build: number } => {
const s = t.replace(/^v/i, '');
const [base, build = ''] = s.split('-');
const nums = base.split('.').map((n) => parseInt(n, 10) || 0);
while (nums.length < 3) nums.push(0);
return { base: nums, build: build === '' ? Number.MAX_SAFE_INTEGER : parseInt(build, 10) || 0 };
};
const pa = parse(a);
const pb = parse(b);
for (let i = 0; i < 3; i++) {
if (pa.base[i] !== pb.base[i]) return pa.base[i] - pb.base[i];
}
return pa.build - pb.build;
}
/**
* 更新服务: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;
/** 当前更新通道(woker 慢走 / runner 跑步;从配置读取,可运行时切换) */
private channelKey: UpdateChannelKey = 'woker';
init(listener: (payload: UpdateStatusPayload) => void): void {
this.listener = listener;
this.currentVersion = app.getVersion();
if (!app.isPackaged) {
console.log('[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') {
console.log(`[updater] 上次更新状态 ${persisted.state} (v${persisted.version}),已重置`);
this.persistIdleConfig();
}
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
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.emit();
});
autoUpdater.on('update-not-available', () => {
this.state = 'not-available';
this.version = undefined;
this.emit();
});
autoUpdater.on('download-progress', (p) => {
this.state = 'downloading';
this.progress = p;
this.emit();
});
autoUpdater.on('update-downloaded', (info) => {
this.state = 'downloaded';
this.version = info.version;
this.emit();
});
autoUpdater.on('error', (err: Error) => {
const message = String(err?.message ?? err);
console.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,
error: this.error,
};
}
/** 将当前状态与下载进度写入配置(下载/安装进度落盘) */
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) {
console.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 的 allowPrereleasewoker=只收正式版 / runner=可收预览版) */
private applyChannel(): void {
const def = getChannelDef(this.channelKey);
// runner(跑步) 强制开启 allowPrerelease → GitHub provider 走 Atom feed 频道逻辑可收 beta
// woker(慢走) 关闭 → 走 releases/latest 只认稳定版,不被预览版污染。
autoUpdater.allowPrerelease = def.allowPrerelease;
console.log(`[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)) {
console.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) {
console.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) {
console.warn(`[updater] 无效测试版本号: ${version}`);
return this.buildPayload();
}
this.currentVersion = v;
try {
// currentVersion 在类型声明中为 readonly,但运行时可直接赋值(测试工具用)
(autoUpdater as unknown as { currentVersion: unknown }).currentVersion = semver.parse(v);
} catch (e) {
console.warn('[updater] 设置 autoUpdater.currentVersion 失败:', e);
}
console.log(`[updater] 测试版本号 → ${v}`);
this.emit();
return this.buildPayload();
}
/** 版本比对(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}`,
};
}
/**
* 检查更新:GitHub 官方优先,失败后依次尝试加速源。
*/
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;
// 1) GitHub 官方(app-update.yml 内置 github provider
this.source = 'github';
this.state = 'checking';
this.emit();
try {
await autoUpdater.checkForUpdates();
this.suppressErrors = false;
return this.buildPayload();
} catch (err) {
console.warn(`[updater] GitHub 官方更新源不可用: ${String((err as Error)?.message ?? err)}`);
}
// 2) 加速源兜底:镜像页面发现最新 tag → generic feed → 检查
for (const mirror of getMirrors()) {
try {
const tag = await this.discoverLatestTag(mirror);
if (!tag) {
console.warn(`[updater] ${mirror} 无法发现最新版本,跳过`);
continue;
}
const feedUrl = `${mirror}/https://github.com/${OWNER}/${REPO}/releases/download/${tag}/`;
console.log(`[updater] 切换加速源: ${mirror} (feed: ${feedUrl})`);
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl });
this.source = mirror;
this.state = 'checking';
this.emit();
await autoUpdater.checkForUpdates();
this.suppressErrors = false;
// 镜像若反馈无更新(可能发现的是旧 tag / latest.yml 不匹配),
// 不要就此返回 not-available,继续尝试下一个源
const mirrorResult = this.buildPayload();
if (mirrorResult.state !== 'not-available') return mirrorResult;
console.warn(`[updater] ${mirror} 反馈无可用更新,尝试下一个源`);
} catch (err) {
console.warn(`[updater] 加速源 ${mirror} 检查失败: ${String((err as Error)?.message ?? err)}`);
}
}
this.suppressErrors = false;
this.state = 'error';
this.error = '无法连接更新服务器(GitHub 与所有加速源均不可用),请稍后重试';
this.emit();
return this.buildPayload();
}
/**
* 通过加速源发现最新 release tag
* 1) /releases/latest 页面 HTML 中提取 tag(多数下载型加速源可代理该页面;
* ⚠️ 该页面只指向最新「非 prerelease」release,本项目所有版本都是 prerelease 形式,结果可能偏旧)
* 2) GitHub API(经加速源代理,列表含 prerelease),作为备选
* 最终取两种方式候选集中版本最大者(buildId 数值比较),避免旧 tag 覆盖新 prerelease。
*/
private async discoverLatestTag(mirror: string): Promise<string | null> {
const candidates: string[] = [];
// 方式 1HTML 页面
try {
const pageUrl = `${mirror}/https://github.com/${OWNER}/${REPO}/releases/latest`;
const res = await fetch(pageUrl, {
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(经加速源代理)
try {
const apiUrl = `${mirror}/https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=20`;
const res = await fetch(apiUrl, {
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) candidates.push(r.tag_name);
}
}
} catch {
/* ignore */
}
if (candidates.length === 0) return null;
candidates.sort(compareVersionTags);
const latest = candidates[candidates.length - 1];
if (candidates.length > 1) {
console.log(`[updater] ${mirror} 候选版本: ${candidates.join(', ')} → 取 ${latest}`);
}
return latest;
}
/**
* 获取指定版本(默认当前安装版本)的发布说明(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;
// 回退:最新版本(通过 /releases/latest 页面发现 tag
for (const mirror of getMirrors()) {
const latestTag = await this.discoverLatestTag(mirror);
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;
console.log(`[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;
if (this.state === 'paused') {
// 继续下载(可能从断点续传,也可能重新开始,取决于 electron-updater 缓存)
console.log('[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 未写盘。
*/
quitAndInstall(): void {
if (!this.ready || this.state !== 'downloaded') return;
this.state = 'installing';
this.emit();
try {
flushConfig();
} catch {
/* ignore */
}
autoUpdater.quitAndInstall();
}
/** 获取系统下载临时目录(用于清理提示,暂未启用) */
getCacheDir(): string {
return os.tmpdir();
}
}
export const updateService = new UpdateService();