571 lines
24 KiB
JavaScript
571 lines
24 KiB
JavaScript
/**
|
||
* APS 桌面端离线升级模块(纯 Node,无 Electron 依赖,可在 node --test 下直接测试)。
|
||
*
|
||
* 升级流程(applyUpdate):
|
||
* ① 校验升级包(manifest 版本号 / sha256 / size / 平台)
|
||
* ② 升级前备份当前安装目录(copy 或 listing 两种模式)
|
||
* ③ 触发安装(默认 NSIS 静默 /S;可注入 install 函数便于测试)
|
||
* ④ 安装后健康检查(GET /api/health,可选期望版本)
|
||
* ⑤ 任一步失败 → 自动回滚(恢复备份)+ 审计记录(本地 JSONL 为主,可选上报 /api/gov/audit)
|
||
*
|
||
* 审计取舍说明:
|
||
* - 本地日志文件({apsHome}/logs/upgrade-audit.jsonl,append-only)为默认审计通道;
|
||
* - 上报 /api/gov/audit/events(round-45 HH,矩阵 103 收口)为默认尝试的旁路通道:
|
||
* sidecar 启动时生成一次性审计 token(仅限本机、进程内,经 Electron main 注入
|
||
* auditApiUrl + auditToken + sidecarNonce);配置 auditApiUrl 即默认上报(带 Bearer
|
||
* token + x-aps-sidecar-nonce,过 SidecarIdentityApp nonce 门禁),端点不存在/403/
|
||
* 网络错误 → fail-soft 记本地、不阻塞升级流程(保持 round-43 语义);
|
||
* - web 模式该端点要求登录身份;desktop 模式由 nonce 门禁保护(见 docs/architecture/harness.md)。
|
||
*
|
||
* 回滚模式说明:
|
||
* - mode 'copy'(默认):整目录拷贝备份,可完整恢复文件内容;
|
||
* - mode 'listing':仅记录文件清单 + 各文件 sha256(适合大目录/仅审计),
|
||
* 此模式无法恢复内容,restoreBackup 会抛出 UpgradeError(需重装或改用 copy)。
|
||
*/
|
||
const crypto = require('crypto');
|
||
const fs = require('fs');
|
||
const fsp = fs.promises;
|
||
const http = require('http');
|
||
const path = require('path');
|
||
const { spawn } = require('child_process');
|
||
|
||
const LOOPBACK_HOST = '127.0.0.1';
|
||
|
||
class UpgradeError extends Error {
|
||
/**
|
||
* @param {string} code 机器可读错误码(CHECKSUM_MISMATCH / SIZE_MISMATCH / VERSION_NOT_NEWER / ...)
|
||
* @param {string} message 人类可读描述
|
||
* @param {object} [details] 附加字段(期望值/实际值)
|
||
*/
|
||
constructor(code, message, details = {}) {
|
||
super(message);
|
||
this.name = 'UpgradeError';
|
||
this.code = code;
|
||
this.details = details;
|
||
}
|
||
}
|
||
|
||
/** 语义化版本比较:返回 -1 / 0 / 1(支持 主.次.补 及任意数字段) */
|
||
function compareVersions(a, b) {
|
||
const parse = (v) => String(v).split('.').map((part) => {
|
||
const n = Number.parseInt(part, 10);
|
||
return Number.isNaN(n) ? 0 : n;
|
||
});
|
||
const pa = parse(a);
|
||
const pb = parse(b);
|
||
const len = Math.max(pa.length, pb.length);
|
||
for (let i = 0; i < len; i += 1) {
|
||
const x = pa[i] ?? 0;
|
||
const y = pb[i] ?? 0;
|
||
if (x !== y) return x < y ? -1 : 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/** 计算文件 SHA-256(小写十六进制) */
|
||
function sha256File(filePath) {
|
||
return new Promise((resolve, reject) => {
|
||
const hash = crypto.createHash('sha256');
|
||
const stream = fs.createReadStream(filePath);
|
||
stream.on('data', (chunk) => hash.update(chunk));
|
||
stream.on('end', () => resolve(hash.digest('hex')));
|
||
stream.on('error', reject);
|
||
});
|
||
}
|
||
|
||
function readJson(filePath) {
|
||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||
}
|
||
|
||
/** 读取并校验升级清单;返回规范化对象 */
|
||
function loadManifest(manifestPath) {
|
||
let manifest;
|
||
try {
|
||
manifest = readJson(manifestPath);
|
||
} catch (error) {
|
||
throw new UpgradeError('MANIFEST_READ_FAILED', `升级清单读取失败: ${manifestPath}`, { cause: String(error) });
|
||
}
|
||
const required = ['schemaVersion', 'product', 'version', 'package'];
|
||
for (const key of required) {
|
||
if (manifest[key] === undefined || manifest[key] === null || manifest[key] === '') {
|
||
throw new UpgradeError('MANIFEST_INVALID', `升级清单缺少必需字段: ${key}`);
|
||
}
|
||
}
|
||
const pkg = manifest.package;
|
||
if (!pkg.fileName || typeof pkg.size !== 'number' || !/^[0-9a-f]{64}$/.test(pkg.sha256 || '')) {
|
||
throw new UpgradeError('MANIFEST_INVALID', '升级清单 package 字段不完整(需 fileName/size/sha256[64hex])');
|
||
}
|
||
return manifest;
|
||
}
|
||
|
||
/**
|
||
* 校验升级包本身:存在性 + size + sha256 + 平台。
|
||
* @param {object} options
|
||
* @param {object} options.manifest 已 loadManifest 的清单
|
||
* @param {string} options.packagePath 安装包绝对路径
|
||
* @param {string} [options.platform] 期望平台(默认 process.platform)
|
||
*/
|
||
async function verifyPackage({ manifest, packagePath, platform = process.platform }) {
|
||
const pkg = manifest.package;
|
||
let stat;
|
||
try {
|
||
stat = await fsp.stat(packagePath);
|
||
} catch (error) {
|
||
throw new UpgradeError('PACKAGE_NOT_FOUND', `升级安装包不存在: ${packagePath}`, { path: packagePath });
|
||
}
|
||
if (!stat.isFile()) {
|
||
throw new UpgradeError('PACKAGE_NOT_FILE', `升级安装包不是文件: ${packagePath}`);
|
||
}
|
||
if (manifest.platform && manifest.platform !== platform) {
|
||
throw new UpgradeError('PLATFORM_MISMATCH', `升级包平台不匹配: 清单=${manifest.platform} 本机=${platform}`, {
|
||
expected: manifest.platform,
|
||
actual: platform,
|
||
});
|
||
}
|
||
if (stat.size !== pkg.size) {
|
||
throw new UpgradeError('SIZE_MISMATCH', `升级包大小不匹配: 期望=${pkg.size} 实际=${stat.size}`, {
|
||
expected: pkg.size,
|
||
actual: stat.size,
|
||
});
|
||
}
|
||
const actualSha = await sha256File(packagePath);
|
||
if (actualSha !== pkg.sha256) {
|
||
throw new UpgradeError('CHECKSUM_MISMATCH', `升级包 SHA-256 校验失败: 期望=${pkg.sha256} 实际=${actualSha}`, {
|
||
expected: pkg.sha256,
|
||
actual: actualSha,
|
||
});
|
||
}
|
||
return { verified: true, size: stat.size, sha256: actualSha };
|
||
}
|
||
|
||
/**
|
||
* 校验版本:目标必须严格高于当前版本(拒绝回退/同版本)。
|
||
*/
|
||
function ensureNewerVersion({ currentVersion, targetVersion }) {
|
||
const cmp = compareVersions(targetVersion, currentVersion);
|
||
if (cmp <= 0) {
|
||
throw new UpgradeError(
|
||
'VERSION_NOT_NEWER',
|
||
`拒绝升级: 目标版本 ${targetVersion} 不高于当前版本 ${currentVersion}`,
|
||
{ currentVersion, targetVersion },
|
||
);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function copyDirRecursive(src, dest) {
|
||
return fsp.cp(src, dest, { recursive: true, force: true });
|
||
}
|
||
|
||
async function walkFiles(root, base = root, acc = []) {
|
||
const entries = await fsp.readdir(root, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
const absolute = path.join(root, entry.name);
|
||
if (entry.isDirectory()) {
|
||
await walkFiles(absolute, base, acc);
|
||
} else {
|
||
acc.push(path.relative(base, absolute));
|
||
}
|
||
}
|
||
return acc;
|
||
}
|
||
|
||
async function sha256OfTree(root) {
|
||
const files = await walkFiles(root);
|
||
const listing = [];
|
||
for (const rel of files.sort()) {
|
||
const absolute = path.join(root, rel);
|
||
const stat = await fsp.stat(absolute);
|
||
listing.push({ path: rel, size: stat.size, sha256: await sha256File(absolute) });
|
||
}
|
||
return listing;
|
||
}
|
||
|
||
/**
|
||
* 备份安装目录。
|
||
* @param {object} options
|
||
* @param {string} options.installDir
|
||
* @param {string} options.backupDir
|
||
* @param {'copy'|'listing'} [options.mode] 默认 copy
|
||
*/
|
||
async function backupInstall({ installDir, backupDir, mode = 'copy' }) {
|
||
const resolvedInstall = path.resolve(installDir);
|
||
const resolvedBackup = path.resolve(backupDir);
|
||
if (resolvedInstall === resolvedBackup) {
|
||
throw new UpgradeError('BACKUP_SAME_DIR', '备份目录不能与安装目录相同');
|
||
}
|
||
if (!fs.existsSync(resolvedInstall)) {
|
||
throw new UpgradeError('INSTALL_DIR_MISSING', `安装目录不存在: ${resolvedInstall}`);
|
||
}
|
||
await fsp.rm(resolvedBackup, { recursive: true, force: true });
|
||
await fsp.mkdir(resolvedBackup, { recursive: true });
|
||
const listing = await sha256OfTree(resolvedInstall);
|
||
if (mode === 'copy') {
|
||
await copyDirRecursive(resolvedInstall, path.join(resolvedBackup, 'files'));
|
||
} else if (mode !== 'listing') {
|
||
throw new UpgradeError('BACKUP_MODE_INVALID', `未知备份模式: ${mode}`);
|
||
}
|
||
await fsp.writeFile(
|
||
path.join(resolvedBackup, 'backup-manifest.json'),
|
||
`${JSON.stringify({ mode, createdAt: new Date().toISOString(), files: listing }, null, 2)}\n`,
|
||
'utf8',
|
||
);
|
||
return { mode, backupDir: resolvedBackup, fileCount: listing.length };
|
||
}
|
||
|
||
/**
|
||
* 从备份恢复安装目录(copy 模式可完整恢复;listing 模式仅能校验、不能恢复内容)。
|
||
*/
|
||
async function restoreBackup({ backupDir, installDir, expectedMode = 'copy' }) {
|
||
const resolvedBackup = path.resolve(backupDir);
|
||
const resolvedInstall = path.resolve(installDir);
|
||
if (resolvedInstall === resolvedBackup) {
|
||
throw new UpgradeError('BACKUP_SAME_DIR', '备份目录不能与安装目录相同');
|
||
}
|
||
const backupManifestPath = path.join(resolvedBackup, 'backup-manifest.json');
|
||
if (!fs.existsSync(backupManifestPath)) {
|
||
throw new UpgradeError('BACKUP_MISSING', `备份不存在: ${backupManifestPath}`);
|
||
}
|
||
const backup = readJson(backupManifestPath);
|
||
if (backup.mode !== expectedMode) {
|
||
throw new UpgradeError(
|
||
'BACKUP_MODE_MISMATCH',
|
||
`备份模式不匹配: 备份=${backup.mode} 期望=${expectedMode}(listing 模式无法恢复内容,需重装)`,
|
||
{ backupMode: backup.mode, expectedMode },
|
||
);
|
||
}
|
||
if (backup.mode === 'listing') {
|
||
throw new UpgradeError('BACKUP_LISTING_ONLY', 'listing 模式备份不含文件内容,无法自动回滚(请重装)');
|
||
}
|
||
const filesSource = path.join(resolvedBackup, 'files');
|
||
if (!fs.existsSync(filesSource)) {
|
||
throw new UpgradeError('BACKUP_FILES_MISSING', `备份文件缺失: ${filesSource}`);
|
||
}
|
||
// 清空安装目录后整体恢复;不做跨盘/越界检查之外的危险操作。
|
||
await fsp.rm(resolvedInstall, { recursive: true, force: true });
|
||
await fsp.mkdir(resolvedInstall, { recursive: true });
|
||
await copyDirRecursive(filesSource, resolvedInstall);
|
||
return { restored: true, installDir: resolvedInstall, fileCount: backup.files.length };
|
||
}
|
||
|
||
/**
|
||
* 健康检查(round-44 FF):GET {base}/api/health,期望 HTTP 200。
|
||
* 版本比对:若 expectedVersion 且响应体含 version 字符串,则版本必须与 expectedVersion
|
||
* 完全一致(不一致 → HEALTH_VERSION_MISMATCH);响应体无 version 字段(或非字符串)时
|
||
* 退化为仅 HTTP 200 检查,不因缺失版本而失败(兼容旧网关/开发环境)。
|
||
* nonce 续接(round-45 HH):sidecarNonce 提供时携带 x-aps-sidecar-nonce 头——升级后新
|
||
* sidecar 进程启用 nonce 门禁(缺失/不匹配 → 403),安装后健康检查必须用新网关的 nonce;
|
||
* 403 且已携带 nonce 时错误 details.nonceMismatch=true,便于调用方区分“nonce 过期”与普通故障。
|
||
*/
|
||
function probeHealth({ base, expectedVersion, timeoutMs = 5_000, sidecarNonce = null }) {
|
||
const url = new URL('/api/health', base);
|
||
return new Promise((resolve, reject) => {
|
||
const headers = sidecarNonce ? { 'x-aps-sidecar-nonce': sidecarNonce } : {};
|
||
const request = http.get(url, { timeout: timeoutMs, headers }, (response) => {
|
||
let body = '';
|
||
response.on('data', (chunk) => { body += chunk; });
|
||
response.on('end', () => {
|
||
if (response.statusCode !== 200) {
|
||
reject(new UpgradeError('HEALTH_HTTP_STATUS', `健康检查 HTTP ${response.statusCode}`, {
|
||
url: url.href,
|
||
statusCode: response.statusCode,
|
||
nonceMismatch: response.statusCode === 403 && Boolean(sidecarNonce),
|
||
}));
|
||
return;
|
||
}
|
||
let parsed = null;
|
||
try { parsed = JSON.parse(body); } catch { /* 非 JSON 仍视为健康 */ }
|
||
const version = parsed && typeof parsed.version === 'string' ? parsed.version : null;
|
||
if (expectedVersion && version && version !== expectedVersion) {
|
||
reject(new UpgradeError('HEALTH_VERSION_MISMATCH', `健康检查版本不匹配: 期望=${expectedVersion} 实际=${version}`, {
|
||
expectedVersion,
|
||
actual: version,
|
||
}));
|
||
return;
|
||
}
|
||
resolve({
|
||
ok: true, statusCode: 200, version,
|
||
interfaceVersion: parsed?.interfaceVersion ?? null,
|
||
nonce: sidecarNonce, // 本次探测使用的 nonce(可追溯)
|
||
});
|
||
});
|
||
});
|
||
request.once('timeout', () => request.destroy(new UpgradeError('HEALTH_TIMEOUT', '健康检查超时')));
|
||
request.once('error', (error) => reject(error instanceof UpgradeError ? error : new UpgradeError('HEALTH_UNREACHABLE', `健康检查失败: ${error.message}`)));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* NSIS 静默安装触发(默认安装器;测试注入 install 函数替代)。
|
||
* NSIS 参数:/S 静默;/D= 安装目录(须为最后参数、不带引号)。
|
||
*/
|
||
function nsisInstall({ installerPath, installDir }) {
|
||
return new Promise((resolve, reject) => {
|
||
const args = ['/S'];
|
||
if (installDir) args.push(`/D=${installDir}`);
|
||
const child = spawn(installerPath, args, {
|
||
windowsHide: true,
|
||
stdio: 'ignore',
|
||
detached: false,
|
||
});
|
||
const timer = setTimeout(() => {
|
||
child.kill();
|
||
reject(new UpgradeError('INSTALL_TIMEOUT', '安装器执行超时'));
|
||
}, 10 * 60 * 1000);
|
||
child.once('error', (error) => {
|
||
clearTimeout(timer);
|
||
reject(new UpgradeError('INSTALL_SPAWN_FAILED', `安装器启动失败: ${error.message}`));
|
||
});
|
||
child.once('exit', (code) => {
|
||
clearTimeout(timer);
|
||
if (code === 0) resolve({ exitCode: 0 });
|
||
else reject(new UpgradeError('INSTALL_EXIT_NONZERO', `安装器退出码非零: ${code}`, { exitCode: code }));
|
||
});
|
||
});
|
||
}
|
||
|
||
class Updater {
|
||
/**
|
||
* @param {object} options
|
||
* @param {string} [options.apsHome] 用户目录(审计日志写 {apsHome}/logs/upgrade-audit.jsonl)
|
||
* @param {object} [options.logger] console 兼容 logger
|
||
* @param {string} [options.auditApiUrl] 可选:上报 /api/gov/audit 的 base(如 sidecar origin)
|
||
* @param {string} [options.auditToken] 可选:sidecar 一次性审计 token(Bearer 携带)
|
||
* @param {string} [options.sidecarNonce] 可选:sidecar 进程 nonce(x-aps-sidecar-nonce 头,过网关身份门禁)
|
||
*/
|
||
constructor({ apsHome, logger = console, auditApiUrl = null, auditToken = null, sidecarNonce = null } = {}) {
|
||
this.apsHome = apsHome || path.join(require('os').homedir(), '.aps');
|
||
this.logger = logger;
|
||
this.auditApiUrl = auditApiUrl;
|
||
this.auditToken = auditToken;
|
||
this.sidecarNonce = sidecarNonce;
|
||
}
|
||
|
||
/**
|
||
* ① 检查更新:读取清单 + 校验安装包 + 版本比较。
|
||
* @returns {Promise<{updateAvailable:boolean,currentVersion:string,targetVersion:string,manifest:object,verified:boolean}>}
|
||
*/
|
||
async checkForUpdate({ manifestPath, packagePath, currentVersion, platform }) {
|
||
const manifest = loadManifest(manifestPath);
|
||
let verified = false;
|
||
let verifyError = null;
|
||
try {
|
||
if (packagePath) {
|
||
await verifyPackage({ manifest, packagePath, platform });
|
||
verified = true;
|
||
}
|
||
} catch (error) {
|
||
verifyError = error instanceof UpgradeError ? error : new UpgradeError('VERIFY_FAILED', String(error));
|
||
}
|
||
const updateAvailable = (() => {
|
||
try {
|
||
ensureNewerVersion({ currentVersion, targetVersion: manifest.version });
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
})();
|
||
return {
|
||
updateAvailable,
|
||
currentVersion,
|
||
targetVersion: manifest.version,
|
||
manifest,
|
||
verified,
|
||
...(verifyError ? { verifyError: { code: verifyError.code, message: verifyError.message } } : {}),
|
||
};
|
||
}
|
||
|
||
/** 写本地审计 JSONL(append-only);配置 auditApiUrl 即默认尝试上报 /api/gov/audit/events。 */
|
||
async writeAudit({ event, category = 'UPGRADE', result = 'SUCCESS', payload = {} }) {
|
||
const entry = {
|
||
ts: new Date().toISOString(),
|
||
event,
|
||
category,
|
||
result,
|
||
payload,
|
||
};
|
||
const logsDir = path.join(this.apsHome, 'logs');
|
||
await fsp.mkdir(logsDir, { recursive: true });
|
||
const auditFile = path.join(logsDir, 'upgrade-audit.jsonl');
|
||
await fsp.appendFile(auditFile, `${JSON.stringify(entry)}\n`, 'utf8');
|
||
// round-45 HH:配置 auditApiUrl 即默认尝试上报(带 nonce+token);端点不存在/403/网络
|
||
// 错误一律 fail-soft——本地 JSONL 是主通道,上报失败不抛错、不阻塞升级(保持 round-43 语义)。
|
||
if (this.auditApiUrl) {
|
||
await this.reportAuditToApi(entry).catch((error) => {
|
||
this.logger.warn?.('[updater] 审计上报失败(仅本地记录):', error.message);
|
||
});
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
/** 上报 /api/gov/audit/events(round-45 HH:端点不存在/403 → 抛错,由 writeAudit fail-soft 兜底)。
|
||
* payload 转换:本地 entry {ts,event,category,result,payload} → 服务端审计链契约
|
||
* {category,action,power,actor,rationale,target,timestamp,result,idempotencyKey};
|
||
* idempotencyKey = sha256(event+category+result+ts+payload) 的确定性指纹——同一 entry
|
||
* 重试(如响应丢失)会被服务端幂等去重,不重复落链。 */
|
||
async reportAuditToApi(entry) {
|
||
const url = new URL('/api/gov/audit/events', this.auditApiUrl);
|
||
const headers = {
|
||
'content-type': 'application/json',
|
||
};
|
||
if (this.auditToken) headers.authorization = `Bearer ${this.auditToken}`;
|
||
if (this.sidecarNonce) headers['x-aps-sidecar-nonce'] = this.sidecarNonce;
|
||
const idempotencyKey = crypto
|
||
.createHash('sha256')
|
||
.update(JSON.stringify([entry.event, entry.category, entry.result, entry.ts, entry.payload ?? {}]))
|
||
.digest('hex');
|
||
const body = {
|
||
category: entry.category || 'UPGRADE',
|
||
action: entry.event,
|
||
power: 'P0',
|
||
actor: 'desktop-updater',
|
||
target: { type: 'UPGRADE' },
|
||
rationale: { source: 'desktop-updater', payload: entry.payload ?? {} },
|
||
timestamp: entry.ts,
|
||
result: entry.result || 'SUCCESS',
|
||
idempotencyKey,
|
||
};
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(body),
|
||
signal: AbortSignal.timeout(3_000),
|
||
});
|
||
if (!response.ok) {
|
||
throw new UpgradeError('AUDIT_API_HTTP', `审计上报 HTTP ${response.status}`, { status: response.status });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 完整升级流程(含失败自动回滚)。
|
||
* @param {object} options
|
||
* @param {string} options.manifestPath 升级清单路径
|
||
* @param {string} options.packagePath 安装包路径
|
||
* @param {string} options.installDir 安装目录
|
||
* @param {string} options.backupDir 备份目录
|
||
* @param {string} options.currentVersion 当前版本
|
||
* @param {string} [options.healthBase] 健康检查 base URL(默认 http://127.0.0.1:8000)
|
||
* @param {string} [options.expectedVersion] 健康检查期望版本(可选)
|
||
* @param {string} [options.sidecarNonce] 安装后健康检查用的 nonce(round-45 HH:升级后新
|
||
* sidecar 进程启用 nonce 门禁,探测必须带新网关的 nonce;缺省回落到构造时的
|
||
* this.sidecarNonce。新 nonce 由调用方(Electron main)在 sidecar 重启后经 SidecarManager
|
||
* 重新生成并传入,或在升级完成后由 main 更新 nonce 后再做最终健康检查)
|
||
* @param {'copy'|'listing'} [options.backupMode]
|
||
* @param {Function} [options.install] 安装函数(默认 nsisInstall);测试注入
|
||
* @param {boolean} [options.skipHealthCheck] 测试/无 sidecar 场景可跳过
|
||
* @returns {Promise<{ok:boolean,manifest:object,backup:object,health?:object}>}
|
||
*/
|
||
async applyUpdate(options) {
|
||
const {
|
||
manifestPath,
|
||
packagePath,
|
||
installDir,
|
||
backupDir,
|
||
currentVersion,
|
||
healthBase = `http://${LOOPBACK_HOST}:8000`,
|
||
expectedVersion = null,
|
||
backupMode = 'copy',
|
||
install = nsisInstall,
|
||
skipHealthCheck = false,
|
||
sidecarNonce = this.sidecarNonce,
|
||
} = options;
|
||
const manifest = loadManifest(manifestPath);
|
||
const report = {
|
||
ok: false,
|
||
manifest,
|
||
currentVersion,
|
||
targetVersion: manifest.version,
|
||
nonce: sidecarNonce ?? null, // round-45 HH:本次流程使用的 sidecar nonce(供调用方续接)
|
||
};
|
||
try {
|
||
// ① 校验
|
||
this.logger.info?.('[updater] 校验升级包…');
|
||
await verifyPackage({ manifest, packagePath });
|
||
ensureNewerVersion({ currentVersion, targetVersion: manifest.version });
|
||
await this.writeAudit({
|
||
event: 'upgrade.verified',
|
||
payload: { fromVersion: currentVersion, toVersion: manifest.version, fileName: manifest.package.fileName },
|
||
});
|
||
// ② 备份
|
||
this.logger.info?.('[updater] 备份当前安装目录…');
|
||
const backup = await backupInstall({ installDir, backupDir, mode: backupMode });
|
||
report.backup = backup;
|
||
await this.writeAudit({ event: 'upgrade.backup', payload: backup });
|
||
// ③ 安装
|
||
this.logger.info?.('[updater] 执行安装…');
|
||
await install({ installerPath: packagePath, installDir, manifest });
|
||
await this.writeAudit({ event: 'upgrade.installed', payload: { toVersion: manifest.version } });
|
||
// ④ 健康检查
|
||
let health = null;
|
||
if (!skipHealthCheck) {
|
||
this.logger.info?.('[updater] 安装后健康检查…');
|
||
health = await probeHealth({ base: healthBase, expectedVersion, sidecarNonce });
|
||
report.health = health;
|
||
await this.writeAudit({ event: 'upgrade.health-check', payload: health });
|
||
}
|
||
report.ok = true;
|
||
await this.writeAudit({ event: 'upgrade.succeeded', payload: { fromVersion: currentVersion, toVersion: manifest.version } });
|
||
return report;
|
||
} catch (error) {
|
||
// ⑤ 失败自动回滚(round-46:升级途中 sidecar 重启导致的 nonce 轮换除外——
|
||
// 安装已成功,健康检查 403 仅因 nonce 过期 → 返回 pending 语义,由调用方
|
||
// (Electron main 的 upgrade-nonce 协调器)以新 nonce 补验,不误触发回滚)
|
||
const reason = error instanceof UpgradeError ? error.code : 'UNKNOWN';
|
||
const isNonceMismatch = error instanceof UpgradeError
|
||
&& error.details && error.details.nonceMismatch === true;
|
||
if (isNonceMismatch) {
|
||
await this.writeAudit({
|
||
event: 'upgrade.health-pending', result: 'PENDING',
|
||
payload: { reason: 'NONCE_MISMATCH', message: error.message },
|
||
}).catch(() => {});
|
||
this.logger.warn?.('[updater] 健康检查 nonce 已轮换(sidecar 重启),跳过回滚,交由调用方以新 nonce 补验');
|
||
report.ok = false;
|
||
report.deferredVerification = true;
|
||
report.reason = 'NONCE_MISMATCH';
|
||
return report;
|
||
}
|
||
await this.writeAudit({ event: 'upgrade.failed', result: 'FAILED', payload: { reason, message: error.message } }).catch(() => {});
|
||
this.logger.error?.('[updater] 升级失败,自动回滚:', error.message);
|
||
const rollback = await this.rollback({
|
||
backupDir,
|
||
installDir,
|
||
reason,
|
||
backupMode,
|
||
}).catch((rollbackError) => {
|
||
this.logger.error?.('[updater] 回滚失败:', rollbackError.message);
|
||
return { rollbackFailed: true, error: rollbackError.message };
|
||
});
|
||
report.rollback = rollback;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 手动/自动回滚:恢复备份 + 审计。
|
||
*/
|
||
async rollback({ backupDir, installDir, reason = 'manual', backupMode = 'copy' }) {
|
||
const restored = await restoreBackup({ backupDir, installDir, expectedMode: backupMode });
|
||
await this.writeAudit({ event: 'rollback.done', result: 'SUCCESS', payload: { reason, ...restored } });
|
||
return restored;
|
||
}
|
||
|
||
/** 供测试/外部直接调用备份 */
|
||
backupInstall(options) {
|
||
return backupInstall(options);
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
UpgradeError,
|
||
Updater,
|
||
compareVersions,
|
||
sha256File,
|
||
loadManifest,
|
||
verifyPackage,
|
||
ensureNewerVersion,
|
||
backupInstall,
|
||
restoreBackup,
|
||
probeHealth,
|
||
nsisInstall,
|
||
LOOPBACK_HOST,
|
||
}; |