/**
* 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\*\/|)