comate-ui-inspector 0.1.4 → 0.1.5-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/bridge/server.mjs +16 -0
- package/cli.mjs +129 -3
- package/enhancements/recorder/pane.html +28 -0
- package/enhancements/recorder/pane.js +36 -0
- package/enhancements/recorder/plugin.json +7 -0
- package/enhancements/recorder/recorder-main.js +221 -0
- package/extension/background.js +40 -7
- package/extension/bridge-content.js +46 -0
- package/extension/native-host/host.mjs +5 -0
- package/extension/popup.html +117 -58
- package/extension/popup.js +89 -0
- package/hooks/inject-ui-inspector.mjs +73 -17
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -33,13 +33,13 @@ ui-inspector install # 注册 native host + 个人级 hook,并打印
|
|
|
33
33
|
- **生效域名**:默认已含 `localhost` / `127.0.0.1`;项目域名(如 `https://local.oreateai.com`)自己加,添加时浏览器会弹权限申请,允许后即在该域名注入选取脚本。
|
|
34
34
|
- **传输通路**:
|
|
35
35
|
- `本地(Native Messaging)`——默认,浏览器按需拉起 `host.mjs`,零常驻。
|
|
36
|
-
- `远程(HTTP bridge)`——填开发机 bridge 的**完整地址**(如
|
|
36
|
+
- `远程(HTTP bridge)`——填开发机 bridge 的**完整地址**(如 `<remote-ip>:<port>`),用于远程开发(见下)。
|
|
37
37
|
|
|
38
38
|
## 远程开发(HTTP bridge 直连)
|
|
39
39
|
|
|
40
40
|
浏览器在本地、代码与 Comate 在远程开发机时,Native Messaging 跨不了机,改用 **HTTP bridge 直连**:在开发机把 bridge 暴露到网卡,浏览器用开发机可达地址直接连,无需 SSH 隧道。
|
|
41
41
|
|
|
42
|
-
前提:你的笔记本能直连开发机的内网地址(通常 `/etc/hosts` 把
|
|
42
|
+
前提:你的笔记本能直连开发机的内网地址(通常 `/etc/hosts` 把 `<remote-ip>:<port>` 指到开发机 IP,浏览器本来就能打开 dev 页)。
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
45
|
# 开发机上(和 Comate hook/MCP 同机):装 hook/MCP 并后台起 bridge(默认绑 0.0.0.0)
|
|
@@ -52,9 +52,11 @@ ui-inspector install --remote
|
|
|
52
52
|
然后在浏览器(笔记本):加载扩展(`chrome://extensions` 选 `extension` 目录)→ 点扩展图标,弹窗里把**传输通路**选「远程(HTTP bridge)」、地址填**完整地址** `devlocal.oreateai.com:8917`、生效域名加上你的 dev 域名。
|
|
53
53
|
|
|
54
54
|
验证直连:
|
|
55
|
+
|
|
55
56
|
```bash
|
|
56
|
-
curl http
|
|
57
|
+
curl http://<remote-ip>:<port>/state # 回 {"ok":true,...} 即通
|
|
57
58
|
```
|
|
59
|
+
|
|
58
60
|
不通多半是开发机防火墙没放行该端口,或 bridge 没起(看 `~/.ui-inspector/bridge.log`)。
|
|
59
61
|
|
|
60
62
|
> 注意:`--host 0.0.0.0` 会把 bridge 暴露在内网,谁能访问到这个端口谁就能写 pending;仅在可信内网用。dev 页若是 https、扩展连 http bridge 被浏览器拦,则给 bridge 上 https。
|
package/bridge/server.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {homedir} from 'node:os';
|
|
|
16
16
|
|
|
17
17
|
const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
|
|
18
18
|
const statusFile = resolve(homedir(), '.ui-inspector/status.json');
|
|
19
|
+
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
19
20
|
const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
|
|
20
21
|
// 默认只绑 127.0.0.1(走 SSH 隧道);直连模式(笔记本能直达开发机内网 IP)时用 0.0.0.0 暴露到网卡。
|
|
21
22
|
const HOST = process.env.UI_INSPECTOR_BRIDGE_HOST || '127.0.0.1';
|
|
@@ -82,6 +83,21 @@ const server = createServer((req, res) => {
|
|
|
82
83
|
json(res, 200, {ok: true, count: readStore().length, status: readStatus()});
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
86
|
+
if (req.method === 'POST' && url.pathname === '/recording') {
|
|
87
|
+
let body = '';
|
|
88
|
+
req.on('data', chunk => (body += chunk));
|
|
89
|
+
req.on('end', () => {
|
|
90
|
+
try {
|
|
91
|
+
const {recording} = JSON.parse(body || '{}');
|
|
92
|
+
mkdirSync(dirname(recordingFile), {recursive: true});
|
|
93
|
+
writeFileSync(recordingFile, `${JSON.stringify(recording ?? null, null, 4)}\n`, 'utf8');
|
|
94
|
+
json(res, 200, {ok: true});
|
|
95
|
+
} catch (error) {
|
|
96
|
+
json(res, 400, {ok: false, error: String(error)});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
85
101
|
json(res, 404, {ok: false, error: 'not found'});
|
|
86
102
|
});
|
|
87
103
|
|
package/cli.mjs
CHANGED
|
@@ -12,7 +12,17 @@
|
|
|
12
12
|
* 全部按「整机一份、个人级」接入,不绑定任何仓库。仅 macOS / Linux。
|
|
13
13
|
* 目标域名与传输通路(native/http)在扩展弹窗里配置,不写死在代码库。
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
chmodSync,
|
|
17
|
+
cpSync,
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
openSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
} from 'node:fs';
|
|
16
26
|
import {dirname, join, resolve} from 'node:path';
|
|
17
27
|
import {fileURLToPath} from 'node:url';
|
|
18
28
|
import {homedir, platform} from 'node:os';
|
|
@@ -26,6 +36,9 @@ const hostPath = resolve(pkgRoot, 'extension/native-host/host.mjs');
|
|
|
26
36
|
const serverPath = resolve(pkgRoot, 'mcp/server.mjs');
|
|
27
37
|
const hookPath = resolve(pkgRoot, 'hooks/inject-ui-inspector.mjs');
|
|
28
38
|
const extensionDir = resolve(pkgRoot, 'extension');
|
|
39
|
+
const enhanceSrcDir = resolve(pkgRoot, 'enhancements'); // 包内增强包源目录(每个子目录一个插件)
|
|
40
|
+
const enhanceDstDir = resolve(extensionDir, 'enhancements'); // 拷贝进扩展的已装增强包
|
|
41
|
+
const enhanceRegistry = resolve(extensionDir, 'enhancements.json'); // 已装增强包清单(数组)
|
|
29
42
|
const home = homedir();
|
|
30
43
|
const hooksFile = resolve(home, '.comate/hooks.json');
|
|
31
44
|
const pendingDir = resolve(home, '.ui-inspector');
|
|
@@ -129,7 +142,9 @@ function install({remote, port, host}) {
|
|
|
129
142
|
const bindHost = host || '0.0.0.0';
|
|
130
143
|
out('\n== 远程开发机:跳过 native host/扩展(这台没浏览器),后台启动 bridge ==');
|
|
131
144
|
startBridgeDetached(port, bindHost);
|
|
132
|
-
out(
|
|
145
|
+
out(
|
|
146
|
+
`\n浏览器在扩展弹窗里把传输设为「远程(HTTP bridge)」、地址填「本机可达地址:${port}」(如 devlocal.oreateai.com:${port})即可直连。`
|
|
147
|
+
);
|
|
133
148
|
return;
|
|
134
149
|
}
|
|
135
150
|
installNativeHost();
|
|
@@ -149,6 +164,108 @@ function bridge() {
|
|
|
149
164
|
import(resolve(pkgRoot, 'bridge/server.mjs'));
|
|
150
165
|
}
|
|
151
166
|
|
|
167
|
+
// ---------- 增强包(多插件)----------
|
|
168
|
+
// 源在包内 enhancements/<名>/(含 plugin.json)。`add` 把整目录拷进 extension/enhancements/<名>/,
|
|
169
|
+
// 并把描述写进 extension/enhancements.json;弹窗与 background 读该清单动态加载。需重新加载扩展生效。
|
|
170
|
+
function readRegistry() {
|
|
171
|
+
try {
|
|
172
|
+
const j = JSON.parse(readFileSync(enhanceRegistry, 'utf8'));
|
|
173
|
+
return Array.isArray(j) ? j : [];
|
|
174
|
+
} catch {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function writeRegistry(list) {
|
|
180
|
+
if (!list.length) {
|
|
181
|
+
if (existsSync(enhanceRegistry)) {
|
|
182
|
+
rmSync(enhanceRegistry);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
writeFileSync(enhanceRegistry, `${JSON.stringify(list, null, 4)}\n`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function availableEnhancements() {
|
|
190
|
+
try {
|
|
191
|
+
return readdirSync(enhanceSrcDir, {withFileTypes: true})
|
|
192
|
+
.filter(d => d.isDirectory() && existsSync(join(enhanceSrcDir, d.name, 'plugin.json')))
|
|
193
|
+
.map(d => d.name);
|
|
194
|
+
} catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function addEnhancement(name) {
|
|
200
|
+
if (!name) {
|
|
201
|
+
out(`用法:ui-inspector add <名>。可用:${availableEnhancements().join(', ') || '(无)'}`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const src = join(enhanceSrcDir, name);
|
|
205
|
+
const manifestPath = join(src, 'plugin.json');
|
|
206
|
+
if (!existsSync(manifestPath)) {
|
|
207
|
+
out(`未找到增强包「${name}」。可用:${availableEnhancements().join(', ') || '(无)'}`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
let descriptor;
|
|
211
|
+
try {
|
|
212
|
+
descriptor = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
213
|
+
} catch (error) {
|
|
214
|
+
out(`增强包 plugin.json 解析失败:${String(error)}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const id = descriptor.id || name;
|
|
218
|
+
cpSync(src, join(enhanceDstDir, id), {recursive: true});
|
|
219
|
+
const list = readRegistry().filter(p => p.id !== id);
|
|
220
|
+
list.push(descriptor);
|
|
221
|
+
writeRegistry(list);
|
|
222
|
+
out(`已安装增强包「${descriptor.name || id}」(${id}) -> ${join(enhanceDstDir, id)}`);
|
|
223
|
+
out('在 chrome://extensions 点「刷新」重新加载扩展后生效:弹窗出现对应「模式」tab。');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function removeEnhancement(name) {
|
|
227
|
+
if (!name) {
|
|
228
|
+
out('用法:ui-inspector remove <名>');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const dir = join(enhanceDstDir, name);
|
|
232
|
+
let removed = false;
|
|
233
|
+
if (existsSync(dir)) {
|
|
234
|
+
rmSync(dir, {recursive: true, force: true});
|
|
235
|
+
removed = true;
|
|
236
|
+
}
|
|
237
|
+
const before = readRegistry();
|
|
238
|
+
const after = before.filter(p => p.id !== name);
|
|
239
|
+
if (after.length !== before.length) {
|
|
240
|
+
removed = true;
|
|
241
|
+
}
|
|
242
|
+
writeRegistry(after);
|
|
243
|
+
out(removed ? `已移除增强包「${name}」` : `未安装增强包「${name}」`);
|
|
244
|
+
if (removed) {
|
|
245
|
+
out('在 chrome://extensions 点「刷新」重新加载扩展后生效。');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function listEnhancements() {
|
|
250
|
+
const available = availableEnhancements();
|
|
251
|
+
const installed = readRegistry();
|
|
252
|
+
const installedIds = new Set(installed.map(p => p.id));
|
|
253
|
+
out('可用增强包(包内 enhancements/):');
|
|
254
|
+
for (const name of available) {
|
|
255
|
+
out(` ${installedIds.has(name) ? '●' : '○'} ${name}${installedIds.has(name) ? '(已安装)' : ''}`);
|
|
256
|
+
}
|
|
257
|
+
if (!available.length) {
|
|
258
|
+
out(' (无)');
|
|
259
|
+
}
|
|
260
|
+
out('\n已安装(extension/enhancements.json):');
|
|
261
|
+
for (const p of installed) {
|
|
262
|
+
out(` ${p.id} — ${p.name || ''}`);
|
|
263
|
+
}
|
|
264
|
+
if (!installed.length) {
|
|
265
|
+
out(' (无)');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
152
269
|
function uninstall({purge}) {
|
|
153
270
|
for (const dir of nmDirs()) {
|
|
154
271
|
const file = join(dir, `${HOST_NAME}.json`);
|
|
@@ -204,12 +321,15 @@ function getOpt(name, def) {
|
|
|
204
321
|
}
|
|
205
322
|
|
|
206
323
|
function help() {
|
|
207
|
-
out('用法:ui-inspector <install|uninstall|status|bridge>');
|
|
324
|
+
out('用法:ui-inspector <install|uninstall|status|bridge|add|remove|list>');
|
|
208
325
|
out(' install 本地:注册 native host + 个人级 hook,并打印 MCP 配置与扩展目录');
|
|
209
326
|
out(' install --remote 远程开发机:装 hook/MCP 并后台起 bridge(默认绑 0.0.0.0 供浏览器直连,--port 默认 8917)');
|
|
210
327
|
out(' uninstall 移除上述注册(加 --purge 连 ~/.ui-inspector 数据一起删)');
|
|
211
328
|
out(' status 查看安装状态');
|
|
212
329
|
out(' bridge 启动 HTTP bridge(--port 默认 8917,--host 默认 0.0.0.0 供直连)');
|
|
330
|
+
out(' add <名> 安装增强包(拷进扩展并登记,如 add recorder);需重新加载扩展');
|
|
331
|
+
out(' remove <名> 卸载增强包');
|
|
332
|
+
out(' list 列出可用与已安装的增强包');
|
|
213
333
|
}
|
|
214
334
|
|
|
215
335
|
const cmd = process.argv[2] || 'help';
|
|
@@ -221,6 +341,12 @@ if (cmd === 'install') {
|
|
|
221
341
|
status();
|
|
222
342
|
} else if (cmd === 'bridge') {
|
|
223
343
|
bridge();
|
|
344
|
+
} else if (cmd === 'add') {
|
|
345
|
+
addEnhancement(process.argv[3]);
|
|
346
|
+
} else if (cmd === 'remove') {
|
|
347
|
+
removeEnhancement(process.argv[3]);
|
|
348
|
+
} else if (cmd === 'list') {
|
|
349
|
+
listEnhancements();
|
|
224
350
|
} else {
|
|
225
351
|
help();
|
|
226
352
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<label class="radio">
|
|
2
|
+
<input
|
|
3
|
+
type="checkbox"
|
|
4
|
+
id="rec-console"
|
|
5
|
+
checked
|
|
6
|
+
/>
|
|
7
|
+
录制控制台输出(console)
|
|
8
|
+
</label>
|
|
9
|
+
<label class="radio">
|
|
10
|
+
<input
|
|
11
|
+
type="checkbox"
|
|
12
|
+
id="rec-network"
|
|
13
|
+
checked
|
|
14
|
+
/>
|
|
15
|
+
录制网络请求(仅同域)
|
|
16
|
+
</label>
|
|
17
|
+
<button
|
|
18
|
+
id="rec-toggle"
|
|
19
|
+
class="primary"
|
|
20
|
+
style="margin-top: 6px"
|
|
21
|
+
>
|
|
22
|
+
开始录制
|
|
23
|
+
</button>
|
|
24
|
+
<div
|
|
25
|
+
id="rec-status"
|
|
26
|
+
class="hint"
|
|
27
|
+
></div>
|
|
28
|
+
<div class="hint">录制点击的元素(含组件源码)+ 勾选项;停止后在下次发消息时自动带给 Agent。</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 录制增强包的弹窗面板逻辑(随 pane.html 一起由 popup.js 注入后加载)。
|
|
3
|
+
* 自包含:读写 chrome.storage.local.recording {active,console,network,startedAt},
|
|
4
|
+
* bridge-content 监听该键的变化,驱动 MAIN 世界的 recorder-main.js 起停。
|
|
5
|
+
*/
|
|
6
|
+
(function () {
|
|
7
|
+
const byId = id => document.getElementById(id);
|
|
8
|
+
|
|
9
|
+
async function refresh() {
|
|
10
|
+
const {recording} = await chrome.storage.local.get({recording: null});
|
|
11
|
+
const active = !!(recording && recording.active);
|
|
12
|
+
byId('rec-toggle').textContent = active ? '停止录制' : '开始录制';
|
|
13
|
+
byId('rec-status').textContent = active ? '录制中…(操作页面后点停止)' : '';
|
|
14
|
+
byId('rec-console').disabled = active;
|
|
15
|
+
byId('rec-network').disabled = active;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
byId('rec-toggle').onclick = async () => {
|
|
19
|
+
const {recording} = await chrome.storage.local.get({recording: null});
|
|
20
|
+
if (recording && recording.active) {
|
|
21
|
+
await chrome.storage.local.set({recording: {active: false}});
|
|
22
|
+
} else {
|
|
23
|
+
await chrome.storage.local.set({
|
|
24
|
+
recording: {
|
|
25
|
+
active: true,
|
|
26
|
+
console: byId('rec-console').checked,
|
|
27
|
+
network: byId('rec-network').checked,
|
|
28
|
+
startedAt: Date.now(),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
refresh();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
refresh();
|
|
36
|
+
})();
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* MAIN world 录制脚本(增强包):录制期间抓取
|
|
3
|
+
* - 点击的元素(含 Vue 组件源码链,和选择模式同源)
|
|
4
|
+
* - console.log/info/warn/error
|
|
5
|
+
* - 同域名下的 fetch / XMLHttpRequest(方法/URL/状态/耗时)
|
|
6
|
+
* 由 bridge-content 依据 chrome.storage.recording 发来的 rec-control 起停;
|
|
7
|
+
* 停止时把整段录制 postMessage 出去,经 bridge→background→host/bridge 落盘 recording.json。
|
|
8
|
+
* 未开始录制时完全惰性,不打补丁、无开销。
|
|
9
|
+
*/
|
|
10
|
+
(function () {
|
|
11
|
+
if (window.__oreateRecorder) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
window.__oreateRecorder = true;
|
|
15
|
+
|
|
16
|
+
var rec = null; // 进行中的录制 {clicks,console,network,startedAt,pageUrl,opts}
|
|
17
|
+
var saved = {}; // 被打补丁的原函数
|
|
18
|
+
|
|
19
|
+
function cssPath(el) {
|
|
20
|
+
var parts = [];
|
|
21
|
+
var node = el;
|
|
22
|
+
while (node && node.nodeType === 1 && parts.length < 6) {
|
|
23
|
+
var seg = node.nodeName.toLowerCase();
|
|
24
|
+
if (node.id) {
|
|
25
|
+
parts.unshift(seg + '#' + node.id);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
var cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
|
|
29
|
+
if (cls.length) {
|
|
30
|
+
seg += '.' + cls.join('.');
|
|
31
|
+
}
|
|
32
|
+
parts.unshift(seg);
|
|
33
|
+
node = node.parentElement;
|
|
34
|
+
}
|
|
35
|
+
return parts.join(' > ');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function componentChain(el) {
|
|
39
|
+
var chain = [];
|
|
40
|
+
var instance = el.__vueParentComponent;
|
|
41
|
+
while (instance && chain.length < 6) {
|
|
42
|
+
var type = instance.type || {};
|
|
43
|
+
var file = type.__file || null;
|
|
44
|
+
var name = type.__name || type.name || null;
|
|
45
|
+
if (file || name) {
|
|
46
|
+
chain.push({name: name, file: file});
|
|
47
|
+
}
|
|
48
|
+
instance = instance.parent;
|
|
49
|
+
}
|
|
50
|
+
return chain;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fmt(a) {
|
|
54
|
+
if (typeof a === 'string') {
|
|
55
|
+
return a;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
return JSON.stringify(a);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return String(a);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sameHost(url) {
|
|
65
|
+
try {
|
|
66
|
+
return new URL(url, location.href).host === location.host;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onClick(e) {
|
|
73
|
+
if (!rec) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
var el = e.target;
|
|
77
|
+
if (!el || el.nodeType !== 1) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
rec.clicks.push({
|
|
81
|
+
selector: cssPath(el),
|
|
82
|
+
tag: el.nodeName.toLowerCase(),
|
|
83
|
+
text: (el.textContent || '').trim().slice(0, 80),
|
|
84
|
+
componentChain: componentChain(el),
|
|
85
|
+
ts: Date.now(),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function startConsole() {
|
|
90
|
+
['log', 'info', 'warn', 'error'].forEach(function (level) {
|
|
91
|
+
saved['c_' + level] = console[level];
|
|
92
|
+
console[level] = function () {
|
|
93
|
+
try {
|
|
94
|
+
if (rec) {
|
|
95
|
+
rec.console.push({
|
|
96
|
+
level: level,
|
|
97
|
+
text: Array.prototype.map.call(arguments, fmt).join(' ').slice(0, 2000),
|
|
98
|
+
ts: Date.now(),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// 记录失败不影响原 console
|
|
103
|
+
}
|
|
104
|
+
return saved['c_' + level].apply(console, arguments);
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stopConsole() {
|
|
110
|
+
['log', 'info', 'warn', 'error'].forEach(function (level) {
|
|
111
|
+
if (saved['c_' + level]) {
|
|
112
|
+
console[level] = saved['c_' + level];
|
|
113
|
+
delete saved['c_' + level];
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function startNetwork() {
|
|
119
|
+
if (window.fetch) {
|
|
120
|
+
saved.fetch = window.fetch;
|
|
121
|
+
window.fetch = function (input, init) {
|
|
122
|
+
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
|
123
|
+
var method = (init && init.method) || (input && input.method) || 'GET';
|
|
124
|
+
var t0 = Date.now();
|
|
125
|
+
var p = saved.fetch.apply(this, arguments);
|
|
126
|
+
if (rec && sameHost(url)) {
|
|
127
|
+
p.then(
|
|
128
|
+
function (r) {
|
|
129
|
+
var t1 = Date.now();
|
|
130
|
+
rec.network.push({method: method, url: url, status: r.status, startTs: t0, endTs: t1, ms: t1 - t0});
|
|
131
|
+
},
|
|
132
|
+
function () {
|
|
133
|
+
var t1 = Date.now();
|
|
134
|
+
rec.network.push({method: method, url: url, status: 0, startTs: t0, endTs: t1, ms: t1 - t0, error: true});
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return p;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
saved.xhrOpen = XMLHttpRequest.prototype.open;
|
|
142
|
+
saved.xhrSend = XMLHttpRequest.prototype.send;
|
|
143
|
+
XMLHttpRequest.prototype.open = function (method, url) {
|
|
144
|
+
this.__rec = {method: method, url: url, t0: Date.now()};
|
|
145
|
+
return saved.xhrOpen.apply(this, arguments);
|
|
146
|
+
};
|
|
147
|
+
XMLHttpRequest.prototype.send = function () {
|
|
148
|
+
var xhr = this;
|
|
149
|
+
var info = xhr.__rec;
|
|
150
|
+
if (info) {
|
|
151
|
+
xhr.addEventListener('loadend', function () {
|
|
152
|
+
if (rec && sameHost(info.url)) {
|
|
153
|
+
var t1 = Date.now();
|
|
154
|
+
rec.network.push({
|
|
155
|
+
method: info.method,
|
|
156
|
+
url: info.url,
|
|
157
|
+
status: xhr.status,
|
|
158
|
+
startTs: info.t0,
|
|
159
|
+
endTs: t1,
|
|
160
|
+
ms: t1 - info.t0,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return saved.xhrSend.apply(this, arguments);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function stopNetwork() {
|
|
170
|
+
if (saved.fetch) {
|
|
171
|
+
window.fetch = saved.fetch;
|
|
172
|
+
delete saved.fetch;
|
|
173
|
+
}
|
|
174
|
+
if (saved.xhrOpen) {
|
|
175
|
+
XMLHttpRequest.prototype.open = saved.xhrOpen;
|
|
176
|
+
delete saved.xhrOpen;
|
|
177
|
+
}
|
|
178
|
+
if (saved.xhrSend) {
|
|
179
|
+
XMLHttpRequest.prototype.send = saved.xhrSend;
|
|
180
|
+
delete saved.xhrSend;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function start(opts) {
|
|
185
|
+
if (rec) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
rec = {clicks: [], console: [], network: [], startedAt: Date.now(), pageUrl: location.href, opts: opts};
|
|
189
|
+
document.addEventListener('click', onClick, true);
|
|
190
|
+
if (opts.console) {
|
|
191
|
+
startConsole();
|
|
192
|
+
}
|
|
193
|
+
if (opts.network) {
|
|
194
|
+
startNetwork();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function stop() {
|
|
199
|
+
if (!rec) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
document.removeEventListener('click', onClick, true);
|
|
203
|
+
stopConsole();
|
|
204
|
+
stopNetwork();
|
|
205
|
+
rec.endedAt = Date.now();
|
|
206
|
+
var payload = rec;
|
|
207
|
+
rec = null;
|
|
208
|
+
window.postMessage({__uiInspector: 'recording', recording: payload}, '*');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
window.addEventListener('message', function (ev) {
|
|
212
|
+
if (ev.source !== window || !ev.data || ev.data.__uiInspectorBridge !== 'rec-control') {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (ev.data.active) {
|
|
216
|
+
start({console: !!ev.data.console, network: !!ev.data.network});
|
|
217
|
+
} else {
|
|
218
|
+
stop();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
})();
|
package/extension/background.js
CHANGED
|
@@ -59,6 +59,14 @@ async function viaHttp(target, message) {
|
|
|
59
59
|
});
|
|
60
60
|
return await r.json();
|
|
61
61
|
}
|
|
62
|
+
if (message.type === 'ingest-recording') {
|
|
63
|
+
const r = await fetch(`${base}/recording`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {'content-type': 'application/json'},
|
|
66
|
+
body: JSON.stringify({recording: message.recording || null}),
|
|
67
|
+
});
|
|
68
|
+
return await r.json();
|
|
69
|
+
}
|
|
62
70
|
const r = await fetch(`${base}/state`);
|
|
63
71
|
return await r.json();
|
|
64
72
|
} catch (error) {
|
|
@@ -75,7 +83,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
75
83
|
if (!msg || typeof msg.type !== 'string') {
|
|
76
84
|
return;
|
|
77
85
|
}
|
|
78
|
-
if (msg.type === 'ingest' || msg.type === 'state') {
|
|
86
|
+
if (msg.type === 'ingest' || msg.type === 'state' || msg.type === 'ingest-recording') {
|
|
79
87
|
dispatch(msg).then(sendResponse);
|
|
80
88
|
return true; // 异步 sendResponse
|
|
81
89
|
}
|
|
@@ -86,13 +94,38 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
86
94
|
});
|
|
87
95
|
|
|
88
96
|
// ---------- 动态内容脚本注册 ----------
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
];
|
|
97
|
+
// MAIN 世界脚本 = 基础选取脚本 + 各增强包的 main 脚本(enhancements.json,由 `ui-inspector add` 写入);
|
|
98
|
+
// ISOLATED 世界固定一份 bridge-content。增强包脚本路径相对扩展根:enhancements/<id>/<file>。
|
|
99
|
+
const BASE_MAIN_JS = ['inspector-main.js'];
|
|
100
|
+
const BRIDGE_SCRIPT = {id: 'ui-inspector-bridge', js: ['bridge-content.js']};
|
|
101
|
+
|
|
102
|
+
async function readEnhancements() {
|
|
103
|
+
try {
|
|
104
|
+
const r = await fetch(chrome.runtime.getURL('enhancements.json'));
|
|
105
|
+
if (!r.ok) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const j = await r.json().catch(() => []);
|
|
109
|
+
return Array.isArray(j) ? j.filter(p => p && p.id) : [];
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function buildScripts() {
|
|
116
|
+
const plugins = await readEnhancements();
|
|
117
|
+
const mainJs = [...BASE_MAIN_JS];
|
|
118
|
+
for (const p of plugins) {
|
|
119
|
+
for (const file of p.main || []) {
|
|
120
|
+
mainJs.push(`enhancements/${p.id}/${file}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return [{id: 'ui-inspector-main', js: mainJs, world: 'MAIN'}, BRIDGE_SCRIPT];
|
|
124
|
+
}
|
|
93
125
|
|
|
94
126
|
async function syncScripts() {
|
|
95
127
|
const {domains} = await getConfig();
|
|
128
|
+
const scripts = await buildScripts();
|
|
96
129
|
// 只注册已授予主机权限的域名;未授权的等弹窗 request 到权限后再同步。
|
|
97
130
|
const allowed = [];
|
|
98
131
|
for (const match of domains || []) {
|
|
@@ -105,7 +138,7 @@ async function syncScripts() {
|
|
|
105
138
|
}
|
|
106
139
|
}
|
|
107
140
|
try {
|
|
108
|
-
const existing = await chrome.scripting.getRegisteredContentScripts({ids:
|
|
141
|
+
const existing = await chrome.scripting.getRegisteredContentScripts({ids: scripts.map(s => s.id)});
|
|
109
142
|
if (existing.length) {
|
|
110
143
|
await chrome.scripting.unregisterContentScripts({ids: existing.map(s => s.id)});
|
|
111
144
|
}
|
|
@@ -116,7 +149,7 @@ async function syncScripts() {
|
|
|
116
149
|
return;
|
|
117
150
|
}
|
|
118
151
|
await chrome.scripting.registerContentScripts(
|
|
119
|
-
|
|
152
|
+
scripts.map(s => ({
|
|
120
153
|
id: s.id,
|
|
121
154
|
js: s.js,
|
|
122
155
|
matches: allowed,
|
|
@@ -30,6 +30,52 @@
|
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
// 录制(增强包):MAIN 的录制结果 → background 落盘 recording.json
|
|
34
|
+
window.addEventListener('message', event => {
|
|
35
|
+
if (event.source !== window || !event.data || event.data.__uiInspector !== 'recording') {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!alive()) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
chrome.runtime.sendMessage(
|
|
43
|
+
{type: 'ingest-recording', recording: event.data.recording},
|
|
44
|
+
() => void chrome.runtime.lastError
|
|
45
|
+
);
|
|
46
|
+
} catch {
|
|
47
|
+
// 上下文失效
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// 录制起停:弹窗写 chrome.storage.recording,这里转成 rec-control 发给 MAIN 的 recorder。
|
|
52
|
+
function relayRecControl(r) {
|
|
53
|
+
window.postMessage(
|
|
54
|
+
{
|
|
55
|
+
__uiInspectorBridge: 'rec-control',
|
|
56
|
+
active: !!(r && r.active),
|
|
57
|
+
console: !!(r && r.console),
|
|
58
|
+
network: !!(r && r.network),
|
|
59
|
+
},
|
|
60
|
+
'*'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
chrome.storage.local.get({recording: null}, o => {
|
|
65
|
+
if (chrome.runtime.lastError || !o || !o.recording || !o.recording.active) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
relayRecControl(o.recording); // 页面在录制中途加载:补发开始
|
|
69
|
+
});
|
|
70
|
+
chrome.storage.onChanged.addListener((changes, area) => {
|
|
71
|
+
if (area === 'local' && changes.recording) {
|
|
72
|
+
relayRecControl(changes.recording.newValue);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
// 上下文失效
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
const timer = setInterval(() => {
|
|
34
80
|
if (!alive()) {
|
|
35
81
|
clearInterval(timer);
|
|
@@ -18,6 +18,7 @@ import {homedir} from 'node:os';
|
|
|
18
18
|
// 共享落盘:一台机器一份,放用户主目录,不绑定任何仓库——一份代码可服务多个项目。
|
|
19
19
|
const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
|
|
20
20
|
const statusFile = resolve(homedir(), '.ui-inspector/status.json');
|
|
21
|
+
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
21
22
|
|
|
22
23
|
function readStore() {
|
|
23
24
|
try {
|
|
@@ -57,6 +58,10 @@ function handle(msg) {
|
|
|
57
58
|
resp = {ok: true, pending: targets.length};
|
|
58
59
|
} else if (msg && msg.type === 'state') {
|
|
59
60
|
resp = {ok: true, count: readStore().length, status: readStatus()};
|
|
61
|
+
} else if (msg && msg.type === 'ingest-recording') {
|
|
62
|
+
mkdirSync(dirname(recordingFile), {recursive: true});
|
|
63
|
+
writeFileSync(recordingFile, `${JSON.stringify(msg.recording ?? null, null, 4)}\n`, 'utf8');
|
|
64
|
+
resp = {ok: true};
|
|
60
65
|
} else {
|
|
61
66
|
resp = {ok: false, error: 'unknown message'};
|
|
62
67
|
}
|
package/extension/popup.html
CHANGED
|
@@ -95,76 +95,135 @@
|
|
|
95
95
|
color: #1a7f37;
|
|
96
96
|
font-size: 12px;
|
|
97
97
|
}
|
|
98
|
+
.tabs {
|
|
99
|
+
display: flex;
|
|
100
|
+
gap: 6px;
|
|
101
|
+
margin-bottom: 8px;
|
|
102
|
+
}
|
|
103
|
+
.tab {
|
|
104
|
+
flex: 1;
|
|
105
|
+
padding: 6px 0;
|
|
106
|
+
border: 1px solid #d0d7de;
|
|
107
|
+
border-radius: 6px;
|
|
108
|
+
background: #f6f8fa;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
font: inherit;
|
|
111
|
+
}
|
|
112
|
+
.tab.active {
|
|
113
|
+
background: #1f6feb;
|
|
114
|
+
color: #fff;
|
|
115
|
+
border-color: #1f6feb;
|
|
116
|
+
}
|
|
98
117
|
</style>
|
|
99
118
|
</head>
|
|
100
119
|
<body>
|
|
101
120
|
<h1>ui-inspector 配置</h1>
|
|
102
121
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
</
|
|
122
|
+
<!-- 顶层 tab:模式 / 设置 -->
|
|
123
|
+
<div
|
|
124
|
+
class="tabs"
|
|
125
|
+
id="top-tabs"
|
|
126
|
+
>
|
|
127
|
+
<button
|
|
128
|
+
class="tab active"
|
|
129
|
+
data-top="mode"
|
|
130
|
+
>
|
|
131
|
+
模式
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
class="tab"
|
|
135
|
+
data-top="settings"
|
|
136
|
+
>
|
|
137
|
+
设置
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<!-- 「模式」页:选择 Element + 各增强包模式(popup.js 读 enhancements.json 动态填充子 tab) -->
|
|
142
|
+
<div id="top-mode">
|
|
143
|
+
<div
|
|
144
|
+
class="tabs"
|
|
145
|
+
id="mode-tabs"
|
|
146
|
+
></div>
|
|
147
|
+
<section
|
|
148
|
+
id="pane-select"
|
|
149
|
+
class="mode-pane"
|
|
150
|
+
>
|
|
151
|
+
<div class="hint">按住 Alt 点选页面元素(可多选),回 Comate 写修改意见即可。</div>
|
|
152
|
+
</section>
|
|
153
|
+
<div id="mode-panes"></div>
|
|
154
|
+
</div>
|
|
121
155
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
本地(Native Messaging,默认)
|
|
131
|
-
</label>
|
|
132
|
-
<label class="radio">
|
|
133
|
-
<input
|
|
134
|
-
type="radio"
|
|
135
|
-
name="transport"
|
|
136
|
-
value="http"
|
|
137
|
-
/>
|
|
138
|
-
远程(HTTP bridge)
|
|
139
|
-
</label>
|
|
140
|
-
<div id="bridge-config">
|
|
141
|
-
<div
|
|
142
|
-
class="row"
|
|
143
|
-
style="margin-top: 6px"
|
|
144
|
-
>
|
|
156
|
+
<!-- 「设置」页:生效域名 + 传输通路 -->
|
|
157
|
+
<div
|
|
158
|
+
id="top-settings"
|
|
159
|
+
style="display: none"
|
|
160
|
+
>
|
|
161
|
+
<section>
|
|
162
|
+
<div class="label">生效域名</div>
|
|
163
|
+
<div class="row">
|
|
145
164
|
<input
|
|
146
|
-
id="
|
|
165
|
+
id="domain-input"
|
|
147
166
|
type="text"
|
|
148
|
-
placeholder="
|
|
167
|
+
placeholder="如 https://example.com"
|
|
149
168
|
/>
|
|
169
|
+
<button
|
|
170
|
+
id="add-domain"
|
|
171
|
+
class="primary"
|
|
172
|
+
>
|
|
173
|
+
添加
|
|
174
|
+
</button>
|
|
150
175
|
</div>
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
176
|
+
<ul id="domain-list"></ul>
|
|
177
|
+
<div class="hint">添加时浏览器会请求该域名的访问权限,允许后即在该域名注入选取脚本。</div>
|
|
178
|
+
</section>
|
|
179
|
+
|
|
180
|
+
<section>
|
|
181
|
+
<div class="label">传输通路</div>
|
|
182
|
+
<label class="radio">
|
|
183
|
+
<input
|
|
184
|
+
type="radio"
|
|
185
|
+
name="transport"
|
|
186
|
+
value="native"
|
|
187
|
+
/>
|
|
188
|
+
本地(Native Messaging,默认)
|
|
189
|
+
</label>
|
|
190
|
+
<label class="radio">
|
|
191
|
+
<input
|
|
192
|
+
type="radio"
|
|
193
|
+
name="transport"
|
|
194
|
+
value="http"
|
|
195
|
+
/>
|
|
196
|
+
远程(HTTP bridge)
|
|
197
|
+
</label>
|
|
198
|
+
<div id="bridge-config">
|
|
199
|
+
<div
|
|
200
|
+
class="row"
|
|
201
|
+
style="margin-top: 6px"
|
|
202
|
+
>
|
|
203
|
+
<input
|
|
204
|
+
id="bridge-target"
|
|
205
|
+
type="text"
|
|
206
|
+
placeholder="devlocal.oreateai.com:8917"
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
<div class="hint">
|
|
210
|
+
填开发机 bridge 的完整地址(如
|
|
211
|
+
<code>devlocal.oreateai.com:8917</code>
|
|
212
|
+
)。开发机上
|
|
213
|
+
<code>ui-inspector install --remote</code>
|
|
214
|
+
起 bridge(默认绑 0.0.0.0 供直连)。
|
|
215
|
+
</div>
|
|
157
216
|
</div>
|
|
158
|
-
</
|
|
159
|
-
</section>
|
|
217
|
+
</section>
|
|
160
218
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
219
|
+
<button
|
|
220
|
+
id="save"
|
|
221
|
+
class="primary"
|
|
222
|
+
>
|
|
223
|
+
保存传输设置
|
|
224
|
+
</button>
|
|
225
|
+
<div id="msg"></div>
|
|
226
|
+
</div>
|
|
168
227
|
|
|
169
228
|
<script src="popup.js"></script>
|
|
170
229
|
</body>
|
package/extension/popup.js
CHANGED
|
@@ -138,3 +138,92 @@ $('domain-input').addEventListener('keydown', e => {
|
|
|
138
138
|
|
|
139
139
|
renderDomains();
|
|
140
140
|
renderTransport();
|
|
141
|
+
|
|
142
|
+
// ---------- 顶层 tab:模式 / 设置 ----------
|
|
143
|
+
function initTopTabs() {
|
|
144
|
+
const topTabs = [...document.querySelectorAll('#top-tabs .tab')];
|
|
145
|
+
const panes = {mode: $('top-mode'), settings: $('top-settings')};
|
|
146
|
+
for (const b of topTabs) {
|
|
147
|
+
b.onclick = () => {
|
|
148
|
+
for (const x of topTabs) {
|
|
149
|
+
x.classList.toggle('active', x === b);
|
|
150
|
+
}
|
|
151
|
+
for (const [key, el] of Object.entries(panes)) {
|
|
152
|
+
el.style.display = b.dataset.top === key ? 'block' : 'none';
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
initTopTabs();
|
|
158
|
+
|
|
159
|
+
// ---------- 「模式」页:选择 Element + 增强包子 tab ----------
|
|
160
|
+
// 读 extension/enhancements.json(由 `ui-inspector add <名>` 拷贝插件并写入)。
|
|
161
|
+
// 子 tab 第一项固定「选择 Element」,其后每个增强包一项;无增强包时隐藏子 tab 条只留默认提示。
|
|
162
|
+
// 面板 HTML 片段从 enhancements/<id>/<pane> 拉取内嵌,随后加载 enhancements/<id>/<paneJs>。
|
|
163
|
+
async function initModes() {
|
|
164
|
+
let plugins = [];
|
|
165
|
+
try {
|
|
166
|
+
const r = await fetch(chrome.runtime.getURL('enhancements.json'));
|
|
167
|
+
if (r.ok) {
|
|
168
|
+
const j = await r.json().catch(() => []);
|
|
169
|
+
if (Array.isArray(j)) {
|
|
170
|
+
plugins = j.filter(p => p && p.id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
plugins = [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const tabsEl = $('mode-tabs');
|
|
178
|
+
const panesEl = $('mode-panes');
|
|
179
|
+
const paneEls = {select: $('pane-select')};
|
|
180
|
+
|
|
181
|
+
function makeTab(key, label, active) {
|
|
182
|
+
const b = document.createElement('button');
|
|
183
|
+
b.className = active ? 'tab active' : 'tab';
|
|
184
|
+
b.textContent = label;
|
|
185
|
+
b.dataset.mode = key;
|
|
186
|
+
b.onclick = () => switchMode(key);
|
|
187
|
+
tabsEl.appendChild(b);
|
|
188
|
+
return b;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function switchMode(key) {
|
|
192
|
+
for (const b of tabsEl.querySelectorAll('.tab')) {
|
|
193
|
+
b.classList.toggle('active', b.dataset.mode === key);
|
|
194
|
+
}
|
|
195
|
+
for (const [k, el] of Object.entries(paneEls)) {
|
|
196
|
+
el.style.display = k === key ? 'block' : 'none';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
makeTab('select', '选择 Element', true);
|
|
201
|
+
|
|
202
|
+
for (const p of plugins) {
|
|
203
|
+
const pane = document.createElement('section');
|
|
204
|
+
pane.className = 'mode-pane';
|
|
205
|
+
pane.style.display = 'none';
|
|
206
|
+
if (p.pane) {
|
|
207
|
+
try {
|
|
208
|
+
const html = await fetch(chrome.runtime.getURL(`enhancements/${p.id}/${p.pane}`)).then(x => x.text());
|
|
209
|
+
pane.innerHTML = html;
|
|
210
|
+
} catch {
|
|
211
|
+
pane.textContent = `加载 ${p.id} 面板失败`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
panesEl.appendChild(pane);
|
|
215
|
+
paneEls[p.id] = pane;
|
|
216
|
+
makeTab(p.id, p.name || p.id, false);
|
|
217
|
+
if (p.paneJs) {
|
|
218
|
+
const s = document.createElement('script');
|
|
219
|
+
s.src = `enhancements/${p.id}/${p.paneJs}`;
|
|
220
|
+
document.body.appendChild(s);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 只有「选择 Element」一个模式时,子 tab 条没有意义,隐藏它。
|
|
225
|
+
if (!plugins.length) {
|
|
226
|
+
tabsEl.style.display = 'none';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
initModes();
|
|
@@ -14,23 +14,33 @@ import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
|
|
|
14
14
|
import {dirname, resolve} from 'node:path';
|
|
15
15
|
import {homedir} from 'node:os';
|
|
16
16
|
|
|
17
|
-
// 共享落盘:读用户主目录下的 pending(与 host / mcp 同一路径),一份代码可服务多个仓库。
|
|
17
|
+
// 共享落盘:读用户主目录下的 pending / recording(与 host / bridge / mcp 同一路径),一份代码可服务多个仓库。
|
|
18
18
|
const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
|
|
19
|
+
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
19
20
|
|
|
20
21
|
function emit(output) {
|
|
21
22
|
process.stdout.write(JSON.stringify(output));
|
|
22
23
|
process.exit(0);
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
function readJson(file, fallback) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
const parsed = readJson(storeFile, []);
|
|
35
|
+
const list = Array.isArray(parsed) ? parsed : [];
|
|
36
|
+
const recording = readJson(recordingFile, null);
|
|
37
|
+
const hasRec =
|
|
38
|
+
recording &&
|
|
39
|
+
((recording.clicks && recording.clicks.length) ||
|
|
40
|
+
(recording.console && recording.console.length) ||
|
|
41
|
+
(recording.network && recording.network.length));
|
|
42
|
+
|
|
43
|
+
if (!list.length && !hasRec) {
|
|
34
44
|
emit({decision: 'allow'});
|
|
35
45
|
}
|
|
36
46
|
|
|
@@ -47,20 +57,66 @@ function describe(target, index, total) {
|
|
|
47
57
|
.join('\n');
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
]
|
|
60
|
+
function describeRecording(r) {
|
|
61
|
+
const base = r.startedAt || 0;
|
|
62
|
+
const secs = Math.round(((r.endedAt || 0) - base || 0) / 1000);
|
|
63
|
+
const rel = t => (typeof t === 'number' ? `+${((t - base) / 1000).toFixed(2)}s` : '+?');
|
|
64
|
+
|
|
65
|
+
// 把点击 / 控制台 / 网络归并成一条按时间排序的时间线;网络拆成「发起」「完成」两个事件。
|
|
66
|
+
const events = [];
|
|
67
|
+
(r.clicks || []).forEach(c => {
|
|
68
|
+
const files = (c.componentChain || []).map(x => x && x.file).filter(Boolean);
|
|
69
|
+
events.push({
|
|
70
|
+
ts: c.ts,
|
|
71
|
+
text:
|
|
72
|
+
`点击 <${c.tag}> ${JSON.stringify((c.text || '').slice(0, 40))} @ ${c.selector}` +
|
|
73
|
+
(files.length ? ` | 源码 ${files.join(' <- ')}` : ''),
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
(r.console || []).forEach(l => {
|
|
77
|
+
events.push({ts: l.ts, text: `控制台[${l.level}] ${String(l.text).slice(0, 300)}`});
|
|
78
|
+
});
|
|
79
|
+
(r.network || []).forEach(n => {
|
|
80
|
+
events.push({ts: n.startTs, text: `网络→发起 ${n.method} ${n.url}`});
|
|
81
|
+
events.push({ts: n.endTs, text: `网络←完成 ${n.method} ${n.url} → ${n.status} (${n.ms}ms)`});
|
|
82
|
+
});
|
|
83
|
+
// V8 的 sort 稳定:同一时刻保持插入序(发起在完成之前)。
|
|
84
|
+
events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
85
|
+
|
|
86
|
+
const parts = [
|
|
87
|
+
`【ui-inspector 录制】用户录制了一段操作(页面 ${r.pageUrl || ''},约 ${secs}s,共 ${events.length} 个事件,时间为相对录制起点的偏移):`,
|
|
88
|
+
];
|
|
89
|
+
events.slice(0, 120).forEach(e => parts.push(` ${rel(e.ts)} ${e.text}`));
|
|
90
|
+
if (events.length > 120) {
|
|
91
|
+
parts.push(` …(其余 ${events.length - 120} 个事件省略)`);
|
|
92
|
+
}
|
|
93
|
+
return parts.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sections = [];
|
|
97
|
+
if (list.length) {
|
|
98
|
+
const total = list.length;
|
|
99
|
+
sections.push(
|
|
100
|
+
[
|
|
101
|
+
`【ui-inspector】用户在页面上选中了 ${total} 个元素,接下来这条消息是针对它们的修改意见:`,
|
|
102
|
+
list.map((entry, index) => describe(entry?.target ?? {}, index, total)).join('\n'),
|
|
103
|
+
].join('\n')
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (hasRec) {
|
|
107
|
+
sections.push(describeRecording(recording));
|
|
108
|
+
}
|
|
109
|
+
sections.push(
|
|
110
|
+
'请据此定位源码并按用户意见修改。页面选中态已自动取消;改动过程中可调用 MCP 工具 notify_browser 向页面推送状态(如「修改中…」「已完成」),会在浏览器弹 toast。'
|
|
111
|
+
);
|
|
57
112
|
|
|
58
|
-
//
|
|
113
|
+
// 「拿到即消费」:注入后清空 pending 与 recording
|
|
59
114
|
try {
|
|
60
115
|
mkdirSync(dirname(storeFile), {recursive: true});
|
|
61
116
|
writeFileSync(storeFile, '[]\n', 'utf8');
|
|
117
|
+
writeFileSync(recordingFile, 'null\n', 'utf8');
|
|
62
118
|
} catch {
|
|
63
119
|
// 清空失败不影响注入
|
|
64
120
|
}
|
|
65
121
|
|
|
66
|
-
emit({decision: 'allow', additionalContext});
|
|
122
|
+
emit({decision: 'allow', additionalContext: sections.join('\n\n')});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comate-ui-inspector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5-alpha.2",
|
|
4
4
|
"description": "按住 Alt 点选页面元素,把「元素 + Vue 组件源码路径」交给 Comate 定位源码修改。浏览器扩展 + Native Messaging host + MCP + hook。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"cli.mjs",
|
|
12
12
|
"extension",
|
|
13
|
+
"enhancements",
|
|
13
14
|
"mcp",
|
|
14
15
|
"bridge",
|
|
15
16
|
"hooks",
|