feat: 新增更新包本地完整性校验功能

- 新增更新包SHA512哈希和文件大小校验逻辑,仅在校验通过后才允许安装更新,防止损坏或被篡改的安装包被安装
- 更新更新页面UI,根据更新包是否已通过校验显示差异化提示文本
- 临时隐藏设置菜单中的Koring账户设置选项
- 修复发布说明生成脚本,改为按标签创建时间排序,并针对不同构建模式调整基准标签选取逻辑
This commit is contained in:
2026-09-04 20:36:00 +08:00
parent 3a760167d6
commit bcac166a47
7 changed files with 103 additions and 13 deletions
File diff suppressed because one or more lines are too long
+77 -1
View File
@@ -1,5 +1,7 @@
import { autoUpdater, CancellationToken, type ProgressInfo } from 'electron-updater'; import { autoUpdater, CancellationToken, type ProgressInfo } from 'electron-updater';
import electron from 'electron'; import electron from 'electron';
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as os from 'os'; import * as os from 'os';
import semver from 'semver'; import semver from 'semver';
import { getConfig, updateConfig, flushConfig } from './config'; import { getConfig, updateConfig, flushConfig } from './config';
@@ -33,6 +35,8 @@ export interface UpdateStatusPayload {
source?: string; source?: string;
/** 当前更新通道(woker / runner */ /** 当前更新通道(woker / runner */
channel?: string; channel?: string;
/** 安装包是否已通过本地核验(sha512 / 大小) */
verified?: boolean;
error?: string; error?: string;
} }
@@ -152,6 +156,12 @@ class UpdateService {
private suppressErrors = false; private suppressErrors = false;
/** 当前下载的取消令牌(暂停/取消时 cancel) */ /** 当前下载的取消令牌(暂停/取消时 cancel) */
private downloadToken: CancellationToken | null = null; private downloadToken: CancellationToken | null = null;
/** 目标安装包是否已通过本地核验(sha512 + 大小),核验通过前不允许安装 */
private verified = false;
/** update-available 时记录的期望 sha512(来自 latest.yml */
private expectedSha512 = '';
/** 期望文件大小(字节) */
private expectedSize = 0;
/** 当前更新通道(woker 慢走 / runner 跑步;从配置读取,可运行时切换) */ /** 当前更新通道(woker 慢走 / runner 跑步;从配置读取,可运行时切换) */
private channelKey: UpdateChannelKey = 'woker'; private channelKey: UpdateChannelKey = 'woker';
@@ -190,11 +200,19 @@ class UpdateService {
this.state = 'available'; this.state = 'available';
this.version = info.version; this.version = info.version;
this.error = undefined; 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);
console.log(`[updater] 可用更新 ${info.version},期望 sha512=${this.expectedSha512.slice(0, 12)}… size=${this.expectedSize}`);
this.emit(); this.emit();
}); });
autoUpdater.on('update-not-available', () => { autoUpdater.on('update-not-available', () => {
this.state = 'not-available'; this.state = 'not-available';
this.version = undefined; this.version = undefined;
this.verified = false;
this.emit(); this.emit();
}); });
autoUpdater.on('download-progress', (p) => { autoUpdater.on('download-progress', (p) => {
@@ -202,8 +220,22 @@ class UpdateService {
this.progress = p; this.progress = p;
this.emit(); this.emit();
}); });
autoUpdater.on('update-downloaded', (info) => { // 下载完成 → 本地核验安装包(sha512 + 大小)通过后才置为"已下载可安装";
// 核验失败 → 进入 error,拒绝安装(防下载损坏 / 篡改)
autoUpdater.on('update-downloaded', async (info) => {
const errMsg = await this.verifyDownloadedPackage();
if (errMsg) {
console.error(`[updater] 安装包核验失败: ${errMsg}`);
this.state = 'error';
this.error = errMsg;
this.version = undefined;
this.verified = false;
this.emit();
return;
}
console.log('[updater] 安装包核验通过(sha512 + 大小)');
this.state = 'downloaded'; this.state = 'downloaded';
this.verified = true;
this.version = info.version; this.version = info.version;
this.emit(); this.emit();
}); });
@@ -238,10 +270,46 @@ class UpdateService {
bytesPerSecond: this.progress?.bytesPerSecond, bytesPerSecond: this.progress?.bytesPerSecond,
source: this.source, source: this.source,
channel: this.channelKey, channel: this.channelKey,
verified: this.verified,
error: this.error, 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 { private persist(payload: UpdateStatusPayload): void {
try { try {
@@ -615,6 +683,14 @@ class UpdateService {
*/ */
quitAndInstall(): void { quitAndInstall(): void {
if (!this.ready || this.state !== 'downloaded') return; if (!this.ready || this.state !== 'downloaded') return;
// 安装前必须已通过本地核验(sha512 + 大小),防损坏/篡改包被安装
if (!this.verified) {
console.warn('[updater] 安装被拒:安装包未通过核验');
this.state = 'error';
this.error = '安装包未通过核验,已拒绝安装,请重新检查并下载更新';
this.emit();
return;
}
this.state = 'installing'; this.state = 'installing';
this.emit(); this.emit();
try { try {
+15 -5
View File
@@ -27,11 +27,21 @@ $rec = [char]0x1e # 记录分隔符(每个 commit 一条)
$sep = [char]0x1f # 字段分隔符(hash / subject / body $sep = [char]0x1f # 字段分隔符(hash / subject / body
$format = "%x1f%H%x1f%s%x1f%b%x1e" $format = "%x1f%H%x1f%s%x1f%b%x1e"
# 自上个 release tag(v*)以来的提交;无 tag 则取全部提交 # 提交范围(base tag):按 tag 创建时间从新到旧取"上一个 release"。
$lastTag = git tag --sort=-version:refname 2>$null | Where-Object { $_ -match '^v' } | Select-Object -First 1 # run(正式版)→ 上一个【正式版】tag:把上一个正式版之后所有 beta 的提交也写进正式版更新内容
if ($lastTag) { # beta → 上一个 release tag(任意通道)
Write-Host "Commits since tag: $lastTag" # 注意用 creatordate 而非 git 版本序:git 对 -beta.N / -N 混排不可靠(会把 beta.16 排到 -17 之前)。
$raw = git log --format=$format "$lastTag..HEAD" $tagsNewestFirst = git for-each-ref --sort=-creatordate --format '%(refname:short)' refs/tags 2>$null |
Where-Object { $_ -match '^v' }
$baseTag = if ($Mode -eq 'run') {
($tagsNewestFirst | Where-Object { $_ -notmatch '-beta\.' } | Select-Object -First 1)
} else {
($tagsNewestFirst | Select-Object -First 1)
}
if ($baseTag) {
Write-Host "Commits since tag: $baseTag (mode=$Mode)"
$raw = git log --format=$format "$baseTag..HEAD"
} else { } else {
Write-Host "No release tags found, listing all commits" Write-Host "No release tags found, listing all commits"
$raw = git log --format=$format "HEAD" $raw = git log --format=$format "HEAD"
+2
View File
@@ -37,6 +37,8 @@ export interface UpdateStatusPayload {
source?: string; source?: string;
/** 当前更新通道(woker / runner */ /** 当前更新通道(woker / runner */
channel?: string; channel?: string;
/** 安装包是否已通过本地核验(sha512 / 大小;核验通过前不允许安装) */
verified?: boolean;
error?: string; error?: string;
} }
+2 -2
View File
@@ -1,5 +1,4 @@
import { import {
UserCircle,
Gamepad2, Gamepad2,
Palette, Palette,
Download, Download,
@@ -37,7 +36,8 @@ function ShortcutTile({ icon, label, desc, navKey, onClick }: ShortcutItem & { o
} }
const shortcuts: ShortcutItem[] = [ const shortcuts: ShortcutItem[] = [
{ icon: <UserCircle className="w-4 h-4" />, label: "Koring 账户", desc: "同步数据、皮肤与个人配置", navKey: "account" }, // Koring 账户(暂时隐藏)
// { icon: <UserCircle className="w-4 h-4" />, label: "Koring 账户", desc: "同步数据、皮肤与个人配置", navKey: "account" },
{ icon: <Gamepad2 className="w-4 h-4" />, label: "游戏账户与档案", desc: "管理游戏内账户和档案配置", navKey: "game-account" }, { icon: <Gamepad2 className="w-4 h-4" />, label: "游戏账户与档案", desc: "管理游戏内账户和档案配置", navKey: "game-account" },
{ icon: <Palette className="w-4 h-4" />, label: "主题与背景", desc: "深色模式、背景图片与视差", navKey: "theme-bg" }, { icon: <Palette className="w-4 h-4" />, label: "主题与背景", desc: "深色模式、背景图片与视差", navKey: "theme-bg" },
{ icon: <Download className="w-4 h-4" />, label: "下载设置", desc: "下载线程数与存储路径", navKey: "download" }, { icon: <Download className="w-4 h-4" />, label: "下载设置", desc: "下载线程数与存储路径", navKey: "download" },
+3 -3
View File
@@ -1,7 +1,6 @@
import { useState, useCallback, type ReactNode } from "react"; import { useState, useCallback, type ReactNode } from "react";
import { import {
Home, Home,
UserCircle,
Info, Info,
Copyright, Copyright,
Gamepad2, Gamepad2,
@@ -23,7 +22,7 @@ import {
import { useRouteStore } from "@/stores/routeStore"; import { useRouteStore } from "@/stores/routeStore";
import { HomeSetting } from "./general/home"; import { HomeSetting } from "./general/home";
import { AccountSetting } from "./general/account"; // import { AccountSetting } from "./general/account"; // Koring 账户(暂时隐藏)
import { AboutSetting } from "./general/about"; import { AboutSetting } from "./general/about";
import { CopyrightSetting } from "./general/copyright"; import { CopyrightSetting } from "./general/copyright";
import { GameAccountSetting } from "./game/game-account"; import { GameAccountSetting } from "./game/game-account";
@@ -65,7 +64,8 @@ function buildMenuData(
title: "通用", title: "通用",
items: [ items: [
{ key: "home", label: "主页", icon: <Home className={iconCls} />, component: <HomeSetting onNavigate={switchPage} /> }, { key: "home", label: "主页", icon: <Home className={iconCls} />, component: <HomeSetting onNavigate={switchPage} /> },
{ key: "account", label: "Koring 账户", icon: <UserCircle className={iconCls} />, component: <AccountSetting /> }, // Koring 账户(暂时隐藏)
// { key: "account", label: "Koring 账户", icon: <UserCircle className={iconCls} />, component: <AccountSetting /> },
{ key: "about", label: "关于", icon: <Info className={iconCls} />, component: <AboutSetting /> }, { key: "about", label: "关于", icon: <Info className={iconCls} />, component: <AboutSetting /> },
{ key: "copyright", label: "版权", icon: <Copyright className={iconCls} />, component: <CopyrightSetting /> }, { key: "copyright", label: "版权", icon: <Copyright className={iconCls} />, component: <CopyrightSetting /> },
], ],
+3 -1
View File
@@ -195,7 +195,9 @@ export function UpdatePage() {
: st === "paused" : st === "paused"
? `下载已暂停(${pct.toFixed(0)}%` ? `下载已暂停(${pct.toFixed(0)}%`
: st === "downloaded" : st === "downloaded"
? "更新已下载完成" ? status?.verified
? "更新已下载完成(安装包已核验,点击安装)"
: "更新已下载完成"
: st === "installing" : st === "installing"
? "正在安装更新,应用即将重启..." ? "正在安装更新,应用即将重启..."
: st === "error" : st === "error"