/** * UIExtension 注册表 — 正则模式链方案(无需 source map) * * 用正则锚点定位注入位置,无需 source map。 * * 验证来源:full-inject.mjs — 21 版本 × 6 扩展点,acorn 语法 100% 合法。 */ import { readFileSync, writeFileSync, copyFileSync, unlinkSync, existsSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { join } from 'node:path'; import { logger } from '../../util/logger.js'; import { findFileByPrefix } from './locator.js'; import { backupFile, restoreFile } from './backup.js'; import { makeMarker, hasMarker } from './types.js'; import type { UIExtension } from './types.js'; // ── 常量 ────────────────────────────────────────────────────────────────── const OVERRIDE_ASSET_URL = new URL('../assets/clawlink-override.js', import.meta.url); function getOverrideAssetHash(): string { return createHash('sha256') .update(readFileSync(OVERRIDE_ASSET_URL)) .digest('hex') .slice(0, 12); } // ── 辅助函数 ────────────────────────────────────────────────────────────── /** 获取 bundle 路径(不要求 source map 存在)。 */ function resolveBundlePath(controlUiDir: string): string | null { const assetsDir = join(controlUiDir, 'assets'); const bundlePath = findFileByPrefix(assetsDir, 'index-'); if (!bundlePath) { logger.debug('[ui-ext:regex] Main bundle not found'); return null; } return bundlePath; } /** 在 push({...}) 内找 object literal 闭合 } 的位置。 */ function findObjectLiteralClose(code: string, pushParenStart: number): number { let i = pushParenStart; while (i < code.length && code[i] !== '{') i++; if (i >= code.length) return -1; let depth = 1; let inTemplate = false; for (let j = i + 1; j < code.length; j++) { const ch = code[j]; if (ch === '`') { inTemplate = !inTemplate; continue; } if (inTemplate) continue; if (ch === '{') depth++; if (ch === '}') { depth--; if (depth === 0) return j; } } return -1; } function findFunctionByAnchor( content: string, anchor: string, lookBack = 5000, ): { anchorIdx: number; funcName: string; params: string; bodyStart: number; bodyEnd: number; body: string; } | null { const anchorIdx = content.indexOf(anchor); if (anchorIdx < 0) return null; const searchStart = Math.max(0, anchorIdx - lookBack); const regionBefore = content.substring(searchStart, anchorIdx); const funcRe = /function\s+(\w+)\(([^)]*)\)\{/g; let lastFunc: RegExpExecArray | null = null; let funcMatch: RegExpExecArray | null; while ((funcMatch = funcRe.exec(regionBefore)) !== null) lastFunc = funcMatch; if (!lastFunc) return null; const bodyStart = searchStart + lastFunc.index + lastFunc[0].length; let braceDepth = 1; let bodyEnd = bodyStart; for (let i = bodyStart; i < content.length && braceDepth > 0; i++) { if (content[i] === '{') braceDepth++; else if (content[i] === '}') braceDepth--; bodyEnd = i + 1; } if (braceDepth !== 0) return null; return { anchorIdx, funcName: lastFunc[1], params: lastFunc[2], bodyStart, bodyEnd, body: content.substring(bodyStart, bodyEnd), }; } // ── Extension 1: override-script ───────────────────────────────────────── const overrideScript: UIExtension = { id: 'override-script', version: 1, alwaysApply: true, apply(controlUiDir: string): boolean { if (!existsSync(OVERRIDE_ASSET_URL)) { logger.warn('[ui-ext:regex] override-script: asset missing'); return false; } const dest = join(controlUiDir, 'clawlink-override.js'); if (existsSync(dest)) { const srcContent = readFileSync(OVERRIDE_ASSET_URL, 'utf-8'); const destContent = readFileSync(dest, 'utf-8'); if (srcContent === destContent) { logger.debug('[ui-ext:regex] override-script: content unchanged, skip'); return true; } } copyFileSync(OVERRIDE_ASSET_URL, dest); logger.info('[ui-ext:regex] override-script: deployed clawlink-override.js'); return true; }, revert(controlUiDir: string): void { const dest = join(controlUiDir, 'clawlink-override.js'); if (existsSync(dest)) { unlinkSync(dest); logger.info('[ui-ext:regex] override-script: removed'); } }, }; // ── Extension 2: html-script-tag ───────────────────────────────────────── const htmlScriptTag: UIExtension = { id: 'html-script-tag', version: 4, alwaysApply: true, apply(controlUiDir: string): boolean { const indexPath = join(controlUiDir, 'index.html'); if (!existsSync(indexPath)) { logger.warn('[ui-ext:regex] html-script-tag: index.html not found'); return false; } let html = readFileSync(indexPath, 'utf-8'); const htmlMarker = ``; const overrideHash = getOverrideAssetHash(); const scriptTag = `${htmlMarker}`; const markedScriptPattern = /(?:\/\*clawlink:html-script-tag\*\/|)]*clawlink-override[^>]*><\/script>\n?\s*/g; if (html.includes(htmlMarker) || hasMarker(html, this.id)) { const nextHtml = html.replace(markedScriptPattern, `${scriptTag}\n `); if (nextHtml === html) { logger.debug('[ui-ext:regex] html-script-tag: already applied'); } else { writeFileSync(indexPath, nextHtml, 'utf-8'); logger.info(`[ui-ext:regex] html-script-tag: updated override cache-bust (${overrideHash})`); } return true; } const oldScriptPattern = /]*clawlink-override[^>]*><\/script>\n?\s*/g; if (oldScriptPattern.test(html)) { html = html.replace(oldScriptPattern, ''); } const viteAnchor = '