/** * APS 桌面端「升级 nonce 续接」协调器(round-46 KK,矩阵 103 最后本地项)。 * * 背景(round-43~45 链路): * - sidecar 每次启动/重启都会轮换进程 nonce(x-aps-sidecar-nonce,见 sidecar.cjs), * 升级安装(NSIS /S)会替换安装目录内的 sidecar 产物,运行中的 sidecar 可能随之中断, * 由 SidecarManager 以「新进程 + 新 nonce」重启并触发 main 的 'restarted' 事件; * - updater.applyUpdate 安装后健康检查使用的是调用时(升级前/升级中)拿到的旧 nonce, * 无法保证能通过升级后新网关的 nonce 门禁(缺失/不匹配 → 403,见 probeHealth); * - round-45 HH 已让 applyUpdate 回传 report.nonce、接受 sidecarNonce 续接参数; * 本模块补齐 main 侧接线:sidecar 重启后把「新 nonce」交给待验证的升级流程重跑 * probeHealth(升级后健康检查 + 版本比对)。 * * 本模块把「pending 升级状态机 + sidecar restarted 后重验证」的核心逻辑抽成可注入协调器 * createUpgradeNonceCoordinator({ verifyHealth, onComplete, onFailure }),不依赖 Electron, * 可直接 node --test 单测(apps/desktop/test/upgrade-nonce.test.cjs)。Electron main * (main.cjs)负责接线: * - apply-update 前 setPending()(status='installing'); * - applyUpdate 返回后 markInstalled()(status='verifying',保留 pending 等待最终校验); * - sidecar 'restarted' 事件调用 onSidecarRestarted({ origin, nonce }): * 成功 → onComplete(上报 audit,携带新 nonce)并清空 pending; * 失败 → onFailure(本地 JSONL 留痕)并清空 pending; * 无 pending / 已终结 / 已过期 → no-op(不误伤无关重启)。 * * 版本比对语义:与 updater.probeHealth(round-45)一致——expectedVersion 由调用方显式 * 提供时才做严格版本比对(不匹配 → HEALTH_VERSION_MISMATCH 失败留痕);未提供时退化为 * 仅校验新网关接受新 nonce 且 HTTP 200(升级安装期内 sidecar 重启仍由旧 main 进程注入 * APS_APP_VERSION,此时 targetVersion 比对会误报,故不自动取 targetVersion 作比较值)。 * * 状态机: * (null) --setPending--> installing * installing --markInstalled--> verifying * installing --markFailed--> failed * verifying --onSidecarRestarted(+verifyHealth 成功)--> verified(onComplete 后清空) * verifying --onSidecarRestarted(+verifyHealth 失败)--> failed(onFailure 后清空) * verifying --超过 maxPendingMs--> stale(清空,no-op) * installing 期间收到 restarted 事件 → 延迟记录(deferredRestart),markInstalled 后立即重验证, * 避免「升级安装期间 sidecar 重启」这个真实场景被漏掉。 */ const fs = require('fs'); const path = require('path'); const UPGRADE_NONCE_EVENT_VERIFIED = 'upgrade.health-verified'; const UPGRADE_NONCE_EVENT_FAILED = 'upgrade.health-verify-failed'; /** 把任意错误归一化为 { code, message, details }(UpgradeError 保留 code/details)。 */ function normalizeUpgradeError(error) { if (error && typeof error === 'object' && (error.code || error.message)) { return { code: error.code || 'UNKNOWN', message: error.message || String(error), details: error.details ?? null, }; } return { code: 'UNKNOWN', message: String(error), details: null }; } /** * 创建升级 nonce 续接协调器(单 pending 槽;桌面单实例,同时只有一个升级)。 * * @param {object} options * @param {Function} options.verifyHealth 必填:升级后健康检查,签名 * async ({ origin, nonce, expectedVersion, pending }) => health(失败抛错)。 * main 侧通常直接包 probeHealth:({ origin, nonce, expectedVersion }) => * probeHealth({ base: origin, expectedVersion, sidecarNonce: nonce })。 * @param {Function} [options.onComplete] async ({ pending, health, nonce }) => void * 重验证成功回调(上报 audit,携带新 nonce);抛错不阻断状态机(已标记完成)。 * @param {Function} [options.onFailure] async ({ pending, error }) => void * 重验证失败回调(本地日志/审计留痕);抛错不阻断状态机(已标记失败)。 * @param {object} [options.logger] console 兼容 logger(默认 console)。 * @param {number} [options.maxPendingMs] pending 从 startedAt 起的有效期,超过后 * restarted 事件视为过期 no-op(默认 10 分钟,覆盖 NSIS 安装 + 应用重启耗时)。 * @param {Function} [options.now] 时钟注入(默认 Date.now),便于测试。 * @returns {{ * setPending, getPending, getLastResult, clearPending, * markInstalled, markFailed, onSidecarRestarted, * }} */ function createUpgradeNonceCoordinator(options = {}) { const { verifyHealth, onComplete = async () => {}, onFailure = async () => {}, logger = console, maxPendingMs = 10 * 60 * 1000, now = Date.now, statePath = null, } = options; if (typeof verifyHealth !== 'function') { throw new TypeError('createUpgradeNonceCoordinator: verifyHealth 必须为函数'); } let pending = null; let lastResult = null; // ---- 跨进程持久化(round-48):pending 升级状态落盘,应用重启后可续接 ---- function writeState() { if (!statePath) return; try { const dir = path.dirname(statePath); fs.mkdirSync(dir, { recursive: true }); const tmp = statePath + '.tmp'; fs.writeFileSync(tmp, JSON.stringify({ pending, lastResult, updatedAt: new Date(now()).toISOString() }), 'utf8'); fs.renameSync(tmp, statePath); } catch (error) { logger.warn?.('[upgrade-nonce] 持久化升级状态失败:', error.message); } } function readState() { if (!statePath) return null; try { const raw = fs.readFileSync(statePath, 'utf8'); return JSON.parse(raw); } catch { return null; } } /** 工厂初始化:恢复未过期的 pending(应用重启后 status 视为 verifying,等待新 sidecar 补验)。 */ function restoreState() { const saved = readState(); if (!saved || !saved.pending) return; const restored = saved.pending; const startedAtMs = Date.parse(restored.startedAt || ''); const expired = Number.isFinite(startedAtMs) && (now() - startedAtMs) > maxPendingMs; if (expired) { logger.warn?.('[upgrade-nonce] 恢复时发现升级状态已过期,丢弃'); return; } if (restored.status === 'installing' || restored.status === 'verifying') { pending = { ...restored, status: 'verifying', deferredRestart: null }; lastResult = saved.lastResult || null; logger.info?.('[upgrade-nonce] 已恢复待验证升级:', pending.id, '等待新 sidecar 补验'); } } restoreState(); /** 进行中的 pending(安装中/待验证)——已终结的 pending 可被新升级替换。 */ function isActive(target) { return Boolean(target && (target.status === 'installing' || target.status === 'verifying')); } function settle(status, extra = {}) { const snapshot = { ...pending, ...extra, status, settledAt: new Date(now()).toISOString(), }; lastResult = snapshot; pending = null; writeState(); return snapshot; } /** * 登记待验证升级(apply-update 前调用)。 * @param {object} upgrade 至少含 id;可选 currentVersion / targetVersion / * expectedVersion / healthBase / audit(审计凭据快照)/ startedAt。 * @returns {object|null} 登记的 pending;已有进行中 pending 时返回 null(不覆盖)。 */ function setPending(upgrade = {}) { if (isActive(pending)) { logger.warn?.('[upgrade-nonce] 已有进行中的升级待验证,忽略新 setPending:', upgrade.id); return null; } pending = { status: 'installing', deferredRestart: null, preliminaryHealth: null, appliedNonce: null, ...upgrade, startedAt: upgrade.startedAt || new Date(now()).toISOString(), }; lastResult = null; writeState(); return pending; } /** 当前 pending(可能为已终结的 failed,供观测;下个 setPending 会替换)。 */ function getPending() { return pending; } /** 最近一次终结结果(verified/failed/stale 快照),用于审计/日志。 */ function getLastResult() { return lastResult; } /** 清空 pending(id 缺省时强制清空;指定时仅清空匹配 id)。 */ function clearPending(id) { if (!pending) return false; if (id === undefined || pending.id === id) { pending = null; writeState(); return true; } return false; } /** * applyUpdate 成功返回后调用:installing → verifying。 * applyUpdate 自带的健康检查(旧/当前 nonce)视为初步校验;真正的最终校验 * 等 sidecar 重启(新 nonce)由 onSidecarRestarted 驱动。若安装期间已收到过 * restarted 事件(deferredRestart),这里会立即用新 nonce 补做最终校验。 * * @param {object} args * @param {string} [args.id] pending id(缺省对应当前 pending) * @param {object} [args.report] applyUpdate 返回值 * @param {boolean} [args.deferFinalVerify] true(默认)保留 pending 等待 restarted; * 无 sidecar 环境(如 dev)传 false 直接按初步校验完成并清空。 * @returns {Promise} pending 或终结结果 */ async function markInstalled({ id, report = {}, deferFinalVerify = true } = {}) { if (!pending || (id !== undefined && pending.id !== id) || pending.status !== 'installing') { return pending; } if (report && report.ok === false) { return markFailed({ id, error: new Error('applyUpdate 返回 ok=false') }); } pending.status = 'verifying'; pending.targetVersion = report.targetVersion || pending.targetVersion || null; pending.appliedNonce = report.nonce || pending.appliedNonce || null; pending.preliminaryHealth = report.health || null; // 注意:不做 expectedVersion ← targetVersion 自动回填——旧 main 进程重启的 sidecar // 仍上报旧 APS_APP_VERSION,自动比对会误报失败;版本比对由调用方显式提供。 const deferred = pending.deferredRestart; pending.deferredRestart = null; if (deferred && deferFinalVerify) { return onSidecarRestarted(deferred); } if (!deferFinalVerify) { const result = settle('verified', { health: pending.preliminaryHealth || null, nonce: pending.appliedNonce || null, verifiedVia: 'preliminary', }); return result; } writeState(); return pending; } /** * applyUpdate 抛错(含升级失败自动回滚)后调用:installing → failed。 * 仅记录状态供观测(renderer 已收到错误);不调用 onFailure(那是 restarted * 重验证失败路径的语义),也不清空——下个 setPending 会替换。 */ function markFailed({ id, error }) { if (!pending || (id !== undefined && pending.id !== id)) return pending; pending.status = 'failed'; pending.error = normalizeUpgradeError(error); writeState(); return pending; } /** * 核心入口:sidecar 'restarted' 事件后调用,用新 nonce 重跑升级后健康检查。 * @param {object} event { origin, nonce, at } * @returns {Promise<{handled:boolean, ok?:boolean, reason?:string, health?, nonce?, error?, pending?}>} */ async function onSidecarRestarted({ origin, nonce, at } = {}) { if (!pending) return { handled: false, reason: 'no-pending' }; if (pending.status === 'installing') { // 安装尚在进行(applyUpdate 未返回):只记录,等 markInstalled 后补做最终校验 pending.deferredRestart = { origin, nonce, at: at || new Date(now()).toISOString() }; return { handled: false, reason: 'installing-deferred' }; } if (pending.status !== 'verifying') { return { handled: false, reason: 'already-settled', status: pending.status }; } const elapsed = now() - Date.parse(pending.startedAt); if (Number.isFinite(elapsed) && elapsed > maxPendingMs) { logger.warn?.('[upgrade-nonce] pending 升级已过期(', elapsed, 'ms >', maxPendingMs, 'ms),跳过续接'); const stale = settle('stale', { reason: 'expired' }); return { handled: false, reason: 'stale', pending: stale }; } return handleSidecarRestarted({ origin, nonce, at: at || new Date(now()).toISOString() }); } async function handleSidecarRestarted({ origin, nonce, at }) { const target = pending; target.verifyAttempt = { origin, nonce, at }; try { const health = await verifyHealth({ origin, nonce, expectedVersion: target.expectedVersion || null, pending: target, }); target.status = 'verified'; target.health = health; target.nonce = nonce; // 本次续接实际使用的新 nonce(可追溯) target.verifiedAt = at; try { await onComplete({ pending: target, health, nonce }); } catch (error) { logger.error?.('[upgrade-nonce] onComplete 失败(升级已标记完成):', error); } const result = settle('verified', { health, nonce, verifiedVia: 'sidecar-restart' }); return { handled: true, ok: true, health, nonce, pending: result }; } catch (error) { const normalized = normalizeUpgradeError(error); target.status = 'failed'; target.error = normalized; target.nonce = nonce; // 记录尝试使用的 nonce(可追溯) target.verifiedAt = at; try { await onFailure({ pending: target, error: normalized }); } catch (failureError) { logger.error?.('[upgrade-nonce] onFailure 失败(状态仍标记失败):', failureError); } const result = settle('failed', { error: normalized, nonce, verifiedVia: 'sidecar-restart' }); logger.error?.('[upgrade-nonce] sidecar 重启后升级健康检查失败(新 nonce 续接):', normalized.message); return { handled: true, ok: false, error: normalized, nonce, pending: result }; } } return { setPending, getPending, getLastResult, clearPending, markInstalled, markFailed, onSidecarRestarted, }; } module.exports = { createUpgradeNonceCoordinator, normalizeUpgradeError, UPGRADE_NONCE_EVENT_VERIFIED, UPGRADE_NONCE_EVENT_FAILED, };