/** * UIExtensionLoader — 主流程 * * Manifest Reconciler:对比注册表(期望状态)与清单文件(当前状态), * 自动完成扩展点的 apply / revert / skip。 * * 共享文件的扩展按组处理:任何一个需要变更 → 恢复 backup → 重新 apply 全组。 * * 每次 Gateway 启动时调用 loadUIExtensions()。 */ import { join } from 'node:path'; import { logger } from '../../util/logger.js'; import { resolveControlUiDir, findFileByPrefix, extractBundleHash } from './locator.js'; import { readManifest, writeManifest, removeManifest } from './manifest.js'; import { restoreFile } from './backup.js'; import { UI_EXTENSIONS } from './registry-regex.js'; import { REMOVED_UI_EXTENSIONS } from './removed-extensions.js'; import type { ManifestData } from './manifest.js'; import type { UIExtension } from './types.js'; import type { RemovedUIExtension, RemovedRevertResult } from './removed-extensions.js'; // ── 注入策略:正则模式链(无需 .map,21 版本全仿真验证通过)────────────────── /** * 获取当前 bundle 的 hash(用于检测 OC 升级)。 */ function getCurrentBundleHash(controlUiDir: string): string { const assetsDir = join(controlUiDir, 'assets'); const bundlePath = findFileByPrefix(assetsDir, 'index-'); return bundlePath ? extractBundleHash(bundlePath) : 'unknown'; } /** * 判断一个扩展是否需要变更(新增、更新、或删除)。 */ function needsChange( ext: UIExtension | null, // null = 已从注册表删除 oldVersion: number | null // null = manifest 中不存在 ): boolean { if (ext === null) return true; // 需要删除 if (oldVersion === null) return true; // 需要新增 return ext.version !== oldVersion; // 需要更新 } /** * 加载全部 UI 扩展。 * * Reconciliation 流程: * 1. 读取 manifest(不存在视为空) * 2. 检测 bundleHash:不同 → OC 升级 → 全部重新 apply * 3. 独立扩展逐个 apply/revert * 4. 共享文件扩展按组处理:任何一个变更 → 恢复 backup → 重新 apply 全组 * 5. 写入新 manifest */ export async function loadUIExtensions(): Promise { const controlUiDir = resolveControlUiDir(); if (!controlUiDir) { logger.warn('[ui-ext] control-ui directory not found, skipping'); return; } logger.info(`[ui-ext] control-ui located: ${controlUiDir}`); const currentHash = getCurrentBundleHash(controlUiDir); const manifest = readManifest(controlUiDir); const removedIds = new Set(REMOVED_UI_EXTENSIONS.map(ext => ext.id)); // bundleHash 不同 → OC 升级了 const isOcUpgrade = manifest !== null && manifest.bundleHash !== currentHash; const needFullReapply = isOcUpgrade; if (isOcUpgrade) { logger.info(`[ui-ext] OC upgrade detected (${manifest!.bundleHash} → ${currentHash}), re-applying all`); } // These are removed backend dist patches from the previous SSE strategy. // Scan every startup regardless of manifest state: migration sites may have // missing manifests, stale backups, or hand-applied copies. // Track which shared files were restored so we force those groups to rebuild. const forceSharedRebuild = new Set(); for (const ext of REMOVED_UI_EXTENSIONS) { const result = safeRevertRemoved(ext, controlUiDir); if (result === 'failed') { logger.error(`[ui-ext] ${ext.id}: removed backend patch could not be reverted; aborting UI extension load`); return; } if (result === 'restored' && ext.sharedFile) { forceSharedRebuild.add(ext.sharedFile); logger.info(`[ui-ext] ${ext.id}: shared file '${ext.sharedFile}' restored, will force group rebuild`); } } const oldExtensions = (manifest && !needFullReapply) ? Object.fromEntries(Object.entries(manifest.extensions).filter(([id]) => !removedIds.has(id))) : {}; // ── 分组:独立扩展 vs 共享文件扩展 ── const independentExts: UIExtension[] = []; const sharedGroups = new Map(); for (const ext of UI_EXTENSIONS) { if (ext.sharedFile) { const group = sharedGroups.get(ext.sharedFile) ?? []; group.push(ext); sharedGroups.set(ext.sharedFile, group); } else { independentExts.push(ext); } } const newExtensions: Record = {}; let applied = 0; let skipped = 0; // ── 处理独立扩展(逐个 apply/revert)── for (const ext of independentExts) { const old = oldExtensions[ext.id]; if (old && old.version === ext.version && !ext.alwaysApply) { skipped++; newExtensions[ext.id] = { version: ext.version }; logger.debug(`[ui-ext] ${ext.id}: skip (v${ext.version} unchanged)`); continue; } if (old && old.version !== ext.version) { // 版本变了,先 revert logger.debug(`[ui-ext] ${ext.id}: revert (v${old.version} → v${ext.version})`); safeRevert(ext, controlUiDir); } logger.debug(`[ui-ext] ${ext.id}: apply v${ext.version}`); if (safeApply(ext, controlUiDir)) { newExtensions[ext.id] = { version: ext.version }; applied++; } } // ── 处理共享文件扩展(按组批量 reconcile)── for (const [sharedFile, group] of sharedGroups) { // 检查这个组里是否有任何扩展需要变更 let groupNeedsChange = forceSharedRebuild.has(sharedFile); if (groupNeedsChange) { logger.info(`[ui-ext] Shared group '${sharedFile}': forced rebuild due to removed extension cleanup`); } if (!groupNeedsChange) { for (const ext of group) { const old = oldExtensions[ext.id]; if (needsChange(ext, old?.version ?? null)) { groupNeedsChange = true; break; } } } // 检查是否有被删除的扩展(在 manifest 中属于该组但不在当前注册表中) // 如果有,必须触发组重建以清除残留补丁 const groupRegistryIds = new Set(group.map(e => e.id)); for (const oldId of Object.keys(oldExtensions)) { // 只关心曾经属于本组的扩展(不在当前注册表中 + 不在当前组中) if (!groupRegistryIds.has(oldId) && !UI_EXTENSIONS.some(e => e.id === oldId)) { // 无法确定旧扩展属于哪个组(manifest 不记录 sharedFile),保守触发重建 logger.info(`[ui-ext] Orphaned extension '${oldId}' found, triggering group '${sharedFile}' rebuild`); groupNeedsChange = true; break; } } if (!groupNeedsChange) { // 全组无变更,跳过 for (const ext of group) { newExtensions[ext.id] = { version: ext.version }; skipped++; logger.debug(`[ui-ext] ${ext.id}: skip (shared group '${sharedFile}' unchanged)`); } continue; } // 有变更 → 恢复 backup → 重新 apply 全组 logger.info(`[ui-ext] Shared group '${sharedFile}': changes detected, rebuilding`); // 恢复 backup(仅当之前有补丁时才需要还原) const assetsDir = join(controlUiDir, 'assets'); const bundlePath = findFileByPrefix(assetsDir, 'index-'); if (bundlePath) { const restored = restoreFile(bundlePath); if (restored) { logger.debug(`[ui-ext] Restored backup for shared group '${sharedFile}'`); } else { logger.debug(`[ui-ext] No backup to restore (first install or OC upgrade)`); } } // 重新 apply 全组中所有应保留的扩展 for (const ext of group) { logger.debug(`[ui-ext] ${ext.id}: apply v${ext.version} (group rebuild)`); if (safeApply(ext, controlUiDir)) { newExtensions[ext.id] = { version: ext.version }; applied++; } } } // ── 写入新 manifest ── const newManifest: ManifestData = { bundleHash: currentHash, strategy: 'regex', extensions: newExtensions, }; writeManifest(controlUiDir, newManifest); logger.info(`[ui-ext] Done: ${applied} applied, ${skipped} skipped`); } /** * 卸载全部 UI 扩展(插件卸载时调用)。 * * 共享文件扩展:恢复 backup 即可(一次还原覆盖所有修改)。 * 独立扩展:逐个 revert。 */ export async function unloadUIExtensions(): Promise { const controlUiDir = resolveControlUiDir(); if (!controlUiDir) return; // 先恢复共享文件的 backup const assetsDir = join(controlUiDir, 'assets'); const bundlePath = findFileByPrefix(assetsDir, 'index-'); if (bundlePath) { restoreFile(bundlePath); } // 再逆序 revert 独立扩展 for (const ext of UI_EXTENSIONS.slice().reverse()) { if (!ext.sharedFile) { safeRevert(ext, controlUiDir); } } removeManifest(controlUiDir); logger.info('[ui-ext] All UI extensions unloaded'); } // ── Helpers ────────────────────────────────────────────────────────────── function safeApply(ext: UIExtension, controlUiDir: string): boolean { try { return ext.apply(controlUiDir); } catch (err) { logger.error(`[ui-ext] Failed to apply '${ext.id}': ${(err as Error).message}`); return false; } } function safeRevert(ext: UIExtension, controlUiDir: string): void { try { ext.revert(controlUiDir); } catch (err) { logger.error(`[ui-ext] Failed to revert '${ext.id}': ${(err as Error).message}`); } } function safeRevertRemoved(ext: RemovedUIExtension, controlUiDir: string): RemovedRevertResult { try { return ext.revert(controlUiDir); } catch (err) { logger.error(`[ui-ext] Failed to revert removed extension '${ext.id}': ${(err as Error).message}`); return 'failed'; } }