comate-ui-inspector 0.1.5-alpha.2 → 0.1.5-alpha.4
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/bridge/server.mjs +80 -12
- package/enhancements/recorder/pane.html +2 -2
- package/enhancements/recorder/pane.js +3 -1
- package/enhancements/recorder/recorder-main.js +27 -24
- package/extension/background.js +20 -4
- package/extension/bridge-content.js +119 -17
- package/extension/enhancements/recorder/pane.html +28 -0
- package/extension/enhancements/recorder/pane.js +38 -0
- package/extension/enhancements/recorder/plugin.json +7 -0
- package/extension/enhancements/recorder/recorder-main.js +224 -0
- package/extension/enhancements.json +11 -0
- package/extension/native-host/host.mjs +52 -4
- package/extension/popup.html +23 -1
- package/extension/popup.js +7 -4
- package/hooks/inject-ui-inspector.mjs +49 -8
- package/package.json +1 -1
package/bridge/server.mjs
CHANGED
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
* 安全:只绑 127.0.0.1,不暴露到网络;远程访问一律走 `ssh -L` 把端口转发到本地 127.0.0.1。
|
|
11
11
|
*/
|
|
12
12
|
import {createServer} from 'node:http';
|
|
13
|
-
import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
|
|
13
|
+
import {readFileSync, writeFileSync, appendFileSync, mkdirSync} from 'node:fs';
|
|
14
14
|
import {dirname, resolve} from 'node:path';
|
|
15
15
|
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
|
+
// 录制:元信息 + append-only 事件流分离,避免多请求读改写竞态把 active 盖回 true。
|
|
19
20
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
21
|
+
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
20
22
|
const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
|
|
21
23
|
// 默认只绑 127.0.0.1(走 SSH 隧道);直连模式(笔记本能直达开发机内网 IP)时用 0.0.0.0 暴露到网卡。
|
|
22
24
|
const HOST = process.env.UI_INSPECTOR_BRIDGE_HOST || '127.0.0.1';
|
|
@@ -43,6 +45,57 @@ function writeStore(list) {
|
|
|
43
45
|
writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// 录制累积:元信息与事件流分离,避免多请求读改写竞态。
|
|
49
|
+
function readMeta() {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(recordingFile, 'utf8'));
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function writeMeta(meta) {
|
|
57
|
+
mkdirSync(dirname(recordingFile), {recursive: true});
|
|
58
|
+
writeFileSync(recordingFile, `${JSON.stringify(meta ?? null, null, 4)}\n`, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
function recStart(startedAt, pageUrl) {
|
|
61
|
+
const cur = readMeta();
|
|
62
|
+
if (cur && cur.active && cur.startedAt === startedAt) {
|
|
63
|
+
return; // 同一会话重复 start,忽略
|
|
64
|
+
}
|
|
65
|
+
writeMeta({active: true, startedAt, pageUrl});
|
|
66
|
+
mkdirSync(dirname(eventsFile), {recursive: true});
|
|
67
|
+
writeFileSync(eventsFile, '', 'utf8');
|
|
68
|
+
}
|
|
69
|
+
function recEvent(kind, item) {
|
|
70
|
+
const cur = readMeta();
|
|
71
|
+
if (!cur || !cur.active) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
mkdirSync(dirname(eventsFile), {recursive: true});
|
|
75
|
+
appendFileSync(eventsFile, `${JSON.stringify({kind, item})}\n`, 'utf8');
|
|
76
|
+
}
|
|
77
|
+
function recStop(endedAt) {
|
|
78
|
+
const cur = readMeta();
|
|
79
|
+
if (!cur) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
cur.active = false;
|
|
83
|
+
cur.endedAt = endedAt || Date.now();
|
|
84
|
+
writeMeta(cur);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readBody(req, cb) {
|
|
88
|
+
let body = '';
|
|
89
|
+
req.on('data', chunk => (body += chunk));
|
|
90
|
+
req.on('end', () => {
|
|
91
|
+
try {
|
|
92
|
+
cb(JSON.parse(body || '{}'));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
cb(null, error);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
46
99
|
function cors(res) {
|
|
47
100
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
48
101
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
|
@@ -83,18 +136,33 @@ const server = createServer((req, res) => {
|
|
|
83
136
|
json(res, 200, {ok: true, count: readStore().length, status: readStatus()});
|
|
84
137
|
return;
|
|
85
138
|
}
|
|
86
|
-
if (req.method === 'POST' && url.pathname === '/
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
139
|
+
if (req.method === 'POST' && url.pathname === '/rec/start') {
|
|
140
|
+
readBody(req, (data, err) => {
|
|
141
|
+
if (err) {
|
|
142
|
+
return json(res, 400, {ok: false, error: String(err)});
|
|
143
|
+
}
|
|
144
|
+
recStart(data.startedAt, data.pageUrl);
|
|
145
|
+
json(res, 200, {ok: true});
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (req.method === 'POST' && url.pathname === '/rec/event') {
|
|
150
|
+
readBody(req, (data, err) => {
|
|
151
|
+
if (err) {
|
|
152
|
+
return json(res, 400, {ok: false, error: String(err)});
|
|
153
|
+
}
|
|
154
|
+
recEvent(data.kind, data.item);
|
|
155
|
+
json(res, 200, {ok: true});
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (req.method === 'POST' && url.pathname === '/rec/stop') {
|
|
160
|
+
readBody(req, (data, err) => {
|
|
161
|
+
if (err) {
|
|
162
|
+
return json(res, 400, {ok: false, error: String(err)});
|
|
97
163
|
}
|
|
164
|
+
recStop(data.endedAt);
|
|
165
|
+
json(res, 200, {ok: true});
|
|
98
166
|
});
|
|
99
167
|
return;
|
|
100
168
|
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
const {recording} = await chrome.storage.local.get({recording: null});
|
|
20
20
|
if (recording && recording.active) {
|
|
21
21
|
await chrome.storage.local.set({recording: {active: false}});
|
|
22
|
+
refresh();
|
|
22
23
|
} else {
|
|
23
24
|
await chrome.storage.local.set({
|
|
24
25
|
recording: {
|
|
@@ -28,8 +29,9 @@
|
|
|
28
29
|
startedAt: Date.now(),
|
|
29
30
|
},
|
|
30
31
|
});
|
|
32
|
+
// 开始录制后关闭 popup,让用户直接去页面操作;页面顶部会出现「正在录制」提示条。
|
|
33
|
+
window.close();
|
|
31
34
|
}
|
|
32
|
-
refresh();
|
|
33
35
|
};
|
|
34
36
|
|
|
35
37
|
refresh();
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* MAIN world
|
|
2
|
+
* MAIN world 录制脚本(增强包):录制期间抓取并**逐条上报**
|
|
3
3
|
* - 点击的元素(含 Vue 组件源码链,和选择模式同源)
|
|
4
4
|
* - console.log/info/warn/error
|
|
5
|
-
* - 同域名下的 fetch / XMLHttpRequest(方法/URL
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* - 同域名下的 fetch / XMLHttpRequest(方法/URL/状态/发起与完成时间)
|
|
6
|
+
* 每个事件即时 postMessage 出去,经 bridge→background→host/bridge 追加进 recording.json。
|
|
7
|
+
* 这样页面刷新/跳转也不丢数据:缓冲在后端,不在页面内存,录制可跨页面连续进行。
|
|
8
|
+
* 本脚本自身无状态缓冲;未开始录制时完全惰性,不打补丁、无开销。
|
|
9
9
|
*/
|
|
10
10
|
(function () {
|
|
11
11
|
if (window.__oreateRecorder) {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
}
|
|
14
14
|
window.__oreateRecorder = true;
|
|
15
15
|
|
|
16
|
-
var
|
|
16
|
+
var on = null; // 录制中:{console, network};未录制:null
|
|
17
17
|
var saved = {}; // 被打补丁的原函数
|
|
18
18
|
|
|
19
19
|
function cssPath(el) {
|
|
@@ -69,19 +69,25 @@
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// 逐条上报:kind = click | console | network
|
|
73
|
+
function emit(kind, item) {
|
|
74
|
+
window.postMessage({__uiInspector: 'rec-event', kind: kind, item: item}, '*');
|
|
75
|
+
}
|
|
76
|
+
|
|
72
77
|
function onClick(e) {
|
|
73
|
-
if (!
|
|
78
|
+
if (!on) {
|
|
74
79
|
return;
|
|
75
80
|
}
|
|
76
81
|
var el = e.target;
|
|
77
82
|
if (!el || el.nodeType !== 1) {
|
|
78
83
|
return;
|
|
79
84
|
}
|
|
80
|
-
|
|
85
|
+
emit('click', {
|
|
81
86
|
selector: cssPath(el),
|
|
82
87
|
tag: el.nodeName.toLowerCase(),
|
|
83
88
|
text: (el.textContent || '').trim().slice(0, 80),
|
|
84
89
|
componentChain: componentChain(el),
|
|
90
|
+
pageUrl: location.href,
|
|
85
91
|
ts: Date.now(),
|
|
86
92
|
});
|
|
87
93
|
}
|
|
@@ -91,8 +97,8 @@
|
|
|
91
97
|
saved['c_' + level] = console[level];
|
|
92
98
|
console[level] = function () {
|
|
93
99
|
try {
|
|
94
|
-
if (
|
|
95
|
-
|
|
100
|
+
if (on) {
|
|
101
|
+
emit('console', {
|
|
96
102
|
level: level,
|
|
97
103
|
text: Array.prototype.map.call(arguments, fmt).join(' ').slice(0, 2000),
|
|
98
104
|
ts: Date.now(),
|
|
@@ -123,15 +129,15 @@
|
|
|
123
129
|
var method = (init && init.method) || (input && input.method) || 'GET';
|
|
124
130
|
var t0 = Date.now();
|
|
125
131
|
var p = saved.fetch.apply(this, arguments);
|
|
126
|
-
if (
|
|
132
|
+
if (on && sameHost(url)) {
|
|
127
133
|
p.then(
|
|
128
134
|
function (r) {
|
|
129
135
|
var t1 = Date.now();
|
|
130
|
-
|
|
136
|
+
emit('network', {method: method, url: url, status: r.status, startTs: t0, endTs: t1, ms: t1 - t0});
|
|
131
137
|
},
|
|
132
138
|
function () {
|
|
133
139
|
var t1 = Date.now();
|
|
134
|
-
|
|
140
|
+
emit('network', {method: method, url: url, status: 0, startTs: t0, endTs: t1, ms: t1 - t0, error: true});
|
|
135
141
|
}
|
|
136
142
|
);
|
|
137
143
|
}
|
|
@@ -149,9 +155,9 @@
|
|
|
149
155
|
var info = xhr.__rec;
|
|
150
156
|
if (info) {
|
|
151
157
|
xhr.addEventListener('loadend', function () {
|
|
152
|
-
if (
|
|
158
|
+
if (on && sameHost(info.url)) {
|
|
153
159
|
var t1 = Date.now();
|
|
154
|
-
|
|
160
|
+
emit('network', {
|
|
155
161
|
method: info.method,
|
|
156
162
|
url: info.url,
|
|
157
163
|
status: xhr.status,
|
|
@@ -182,30 +188,27 @@
|
|
|
182
188
|
}
|
|
183
189
|
|
|
184
190
|
function start(opts) {
|
|
185
|
-
if (
|
|
191
|
+
if (on) {
|
|
186
192
|
return;
|
|
187
193
|
}
|
|
188
|
-
|
|
194
|
+
on = {console: !!opts.console, network: !!opts.network};
|
|
189
195
|
document.addEventListener('click', onClick, true);
|
|
190
|
-
if (
|
|
196
|
+
if (on.console) {
|
|
191
197
|
startConsole();
|
|
192
198
|
}
|
|
193
|
-
if (
|
|
199
|
+
if (on.network) {
|
|
194
200
|
startNetwork();
|
|
195
201
|
}
|
|
196
202
|
}
|
|
197
203
|
|
|
198
204
|
function stop() {
|
|
199
|
-
if (!
|
|
205
|
+
if (!on) {
|
|
200
206
|
return;
|
|
201
207
|
}
|
|
202
208
|
document.removeEventListener('click', onClick, true);
|
|
203
209
|
stopConsole();
|
|
204
210
|
stopNetwork();
|
|
205
|
-
|
|
206
|
-
var payload = rec;
|
|
207
|
-
rec = null;
|
|
208
|
-
window.postMessage({__uiInspector: 'recording', recording: payload}, '*');
|
|
211
|
+
on = null;
|
|
209
212
|
}
|
|
210
213
|
|
|
211
214
|
window.addEventListener('message', function (ev) {
|
package/extension/background.js
CHANGED
|
@@ -59,11 +59,27 @@ async function viaHttp(target, message) {
|
|
|
59
59
|
});
|
|
60
60
|
return await r.json();
|
|
61
61
|
}
|
|
62
|
-
if (message.type === '
|
|
63
|
-
const r = await fetch(`${base}/
|
|
62
|
+
if (message.type === 'rec-start') {
|
|
63
|
+
const r = await fetch(`${base}/rec/start`, {
|
|
64
64
|
method: 'POST',
|
|
65
65
|
headers: {'content-type': 'application/json'},
|
|
66
|
-
body: JSON.stringify({
|
|
66
|
+
body: JSON.stringify({startedAt: message.startedAt, pageUrl: message.pageUrl}),
|
|
67
|
+
});
|
|
68
|
+
return await r.json();
|
|
69
|
+
}
|
|
70
|
+
if (message.type === 'rec-event') {
|
|
71
|
+
const r = await fetch(`${base}/rec/event`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {'content-type': 'application/json'},
|
|
74
|
+
body: JSON.stringify({kind: message.kind, item: message.item}),
|
|
75
|
+
});
|
|
76
|
+
return await r.json();
|
|
77
|
+
}
|
|
78
|
+
if (message.type === 'rec-stop') {
|
|
79
|
+
const r = await fetch(`${base}/rec/stop`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {'content-type': 'application/json'},
|
|
82
|
+
body: JSON.stringify({endedAt: message.endedAt}),
|
|
67
83
|
});
|
|
68
84
|
return await r.json();
|
|
69
85
|
}
|
|
@@ -83,7 +99,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
83
99
|
if (!msg || typeof msg.type !== 'string') {
|
|
84
100
|
return;
|
|
85
101
|
}
|
|
86
|
-
if (msg.type === 'ingest' || msg.type === 'state' || msg.type === '
|
|
102
|
+
if (msg.type === 'ingest' || msg.type === 'state' || msg.type === 'rec-start' || msg.type === 'rec-event' || msg.type === 'rec-stop') {
|
|
87
103
|
dispatch(msg).then(sendResponse);
|
|
88
104
|
return true; // 异步 sendResponse
|
|
89
105
|
}
|
|
@@ -30,9 +30,83 @@
|
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
//
|
|
33
|
+
// 录制起停:弹窗写 chrome.storage.recording,这里转成 rec-control 发给 MAIN 的 recorder。
|
|
34
|
+
function relayRecControl(r) {
|
|
35
|
+
window.postMessage(
|
|
36
|
+
{
|
|
37
|
+
__uiInspectorBridge: 'rec-control',
|
|
38
|
+
active: !!(r && r.active),
|
|
39
|
+
console: !!(r && r.console),
|
|
40
|
+
network: !!(r && r.network),
|
|
41
|
+
},
|
|
42
|
+
'*'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 复用 inspector-main 的底部 pill 弹 toast(MAIN world 监听 'status')。
|
|
47
|
+
function toast(text, level) {
|
|
48
|
+
window.postMessage({__uiInspectorBridge: 'status', text: text, level: level || 'success'}, '*');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 录制中页面顶部常驻提示条(ISOLATED world 建 DOM,右侧带停止按钮)。
|
|
52
|
+
var banner = null;
|
|
53
|
+
function ensureBanner() {
|
|
54
|
+
if (banner) {
|
|
55
|
+
return banner;
|
|
56
|
+
}
|
|
57
|
+
banner = document.createElement('div');
|
|
58
|
+
banner.style.cssText =
|
|
59
|
+
'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:2147483647;' +
|
|
60
|
+
'display:none;align-items:center;gap:10px;padding:8px 10px 8px 14px;border-radius:999px;' +
|
|
61
|
+
'background:#111;color:#fff;font:12px/1.4 sans-serif;box-shadow:0 2px 10px rgba(0,0,0,0.25);';
|
|
62
|
+
var style = document.createElement('style');
|
|
63
|
+
style.textContent =
|
|
64
|
+
'@keyframes oreateRecPulse{0%{box-shadow:0 0 0 0 rgba(255,90,90,0.7)}' +
|
|
65
|
+
'70%{box-shadow:0 0 0 6px rgba(255,90,90,0)}100%{box-shadow:0 0 0 0 rgba(255,90,90,0)}}';
|
|
66
|
+
var dot = document.createElement('span');
|
|
67
|
+
dot.style.cssText =
|
|
68
|
+
'width:8px;height:8px;border-radius:50%;background:#ff5a5a;animation:oreateRecPulse 1.2s infinite;';
|
|
69
|
+
var label = document.createElement('span');
|
|
70
|
+
label.textContent = '正在录制…';
|
|
71
|
+
var stop = document.createElement('button');
|
|
72
|
+
stop.textContent = '停止';
|
|
73
|
+
stop.style.cssText =
|
|
74
|
+
'border:none;border-radius:999px;padding:4px 12px;background:#ff5a5a;color:#fff;cursor:pointer;font:inherit;';
|
|
75
|
+
stop.onclick = stopRecording;
|
|
76
|
+
banner.appendChild(style);
|
|
77
|
+
banner.appendChild(dot);
|
|
78
|
+
banner.appendChild(label);
|
|
79
|
+
banner.appendChild(stop);
|
|
80
|
+
return banner;
|
|
81
|
+
}
|
|
82
|
+
function showBanner() {
|
|
83
|
+
ensureBanner();
|
|
84
|
+
if (!banner.isConnected && document.body) {
|
|
85
|
+
document.body.appendChild(banner);
|
|
86
|
+
}
|
|
87
|
+
banner.style.display = 'flex';
|
|
88
|
+
}
|
|
89
|
+
function hideBanner() {
|
|
90
|
+
if (banner) {
|
|
91
|
+
banner.style.display = 'none';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// 停止:置 recording.active=false(触发 onChanged → 停录 + 收起提示条),录制结果落盘后弹 toast。
|
|
95
|
+
function stopRecording() {
|
|
96
|
+
hideBanner();
|
|
97
|
+
if (!alive()) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
chrome.storage.local.set({recording: {active: false}}, () => void chrome.runtime.lastError);
|
|
102
|
+
} catch {
|
|
103
|
+
// 上下文失效
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// MAIN 逐条上报的录制事件 → background 追加进 recording.json(跨刷新/跨页面累积)。
|
|
34
108
|
window.addEventListener('message', event => {
|
|
35
|
-
if (event.source !== window || !event.data || event.data.__uiInspector !== '
|
|
109
|
+
if (event.source !== window || !event.data || event.data.__uiInspector !== 'rec-event') {
|
|
36
110
|
return;
|
|
37
111
|
}
|
|
38
112
|
if (!alive()) {
|
|
@@ -40,7 +114,7 @@
|
|
|
40
114
|
}
|
|
41
115
|
try {
|
|
42
116
|
chrome.runtime.sendMessage(
|
|
43
|
-
{type: '
|
|
117
|
+
{type: 'rec-event', kind: event.data.kind, item: event.data.item},
|
|
44
118
|
() => void chrome.runtime.lastError
|
|
45
119
|
);
|
|
46
120
|
} catch {
|
|
@@ -48,28 +122,56 @@
|
|
|
48
122
|
}
|
|
49
123
|
});
|
|
50
124
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
125
|
+
function recMsg(msg, cb) {
|
|
126
|
+
if (!alive()) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
chrome.runtime.sendMessage(msg, resp => {
|
|
131
|
+
void chrome.runtime.lastError;
|
|
132
|
+
if (cb) {
|
|
133
|
+
cb(resp);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
// 上下文失效
|
|
138
|
+
}
|
|
62
139
|
}
|
|
140
|
+
|
|
63
141
|
try {
|
|
64
142
|
chrome.storage.local.get({recording: null}, o => {
|
|
65
143
|
if (chrome.runtime.lastError || !o || !o.recording || !o.recording.active) {
|
|
66
144
|
return;
|
|
67
145
|
}
|
|
68
|
-
|
|
146
|
+
// 页面在录制中途加载:补发开始、显示提示条,但不重置后端已累积的数据(靠 startedAt 去重)。
|
|
147
|
+
relayRecControl(o.recording);
|
|
148
|
+
showBanner();
|
|
69
149
|
});
|
|
70
150
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
71
|
-
if (area
|
|
72
|
-
|
|
151
|
+
if (area !== 'local' || !changes.recording) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const r = changes.recording.newValue;
|
|
155
|
+
const was = changes.recording.oldValue;
|
|
156
|
+
const nowActive = !!(r && r.active);
|
|
157
|
+
const wasActive = !!(was && was.active);
|
|
158
|
+
relayRecControl(r);
|
|
159
|
+
if (nowActive && !wasActive) {
|
|
160
|
+
// 新一轮录制:告知后端开新会话(按 startedAt 去重,多标签页不会互相清空)。
|
|
161
|
+
recMsg({type: 'rec-start', startedAt: (r && r.startedAt) || Date.now(), pageUrl: location.href});
|
|
162
|
+
showBanner();
|
|
163
|
+
} else if (!nowActive && wasActive) {
|
|
164
|
+
// 停止:收起提示条、后端收尾,成功后提示已带入会话。
|
|
165
|
+
hideBanner();
|
|
166
|
+
recMsg({type: 'rec-stop', endedAt: Date.now()}, resp => {
|
|
167
|
+
if (resp && resp.ok) {
|
|
168
|
+
toast('已更新到会话上下文中', 'success');
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
} else if (nowActive) {
|
|
172
|
+
showBanner();
|
|
173
|
+
} else {
|
|
174
|
+
hideBanner();
|
|
73
175
|
}
|
|
74
176
|
});
|
|
75
177
|
} catch {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<label class="radio">
|
|
2
|
+
<input
|
|
3
|
+
type="checkbox"
|
|
4
|
+
id="rec-console"
|
|
5
|
+
checked
|
|
6
|
+
/>
|
|
7
|
+
录制控制台输出
|
|
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,38 @@
|
|
|
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
|
+
refresh();
|
|
23
|
+
} else {
|
|
24
|
+
await chrome.storage.local.set({
|
|
25
|
+
recording: {
|
|
26
|
+
active: true,
|
|
27
|
+
console: byId('rec-console').checked,
|
|
28
|
+
network: byId('rec-network').checked,
|
|
29
|
+
startedAt: Date.now(),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
// 开始录制后关闭 popup,让用户直接去页面操作;页面顶部会出现「正在录制」提示条。
|
|
33
|
+
window.close();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
refresh();
|
|
38
|
+
})();
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* MAIN world 录制脚本(增强包):录制期间抓取并**逐条上报**
|
|
3
|
+
* - 点击的元素(含 Vue 组件源码链,和选择模式同源)
|
|
4
|
+
* - console.log/info/warn/error
|
|
5
|
+
* - 同域名下的 fetch / XMLHttpRequest(方法/URL/状态/发起与完成时间)
|
|
6
|
+
* 每个事件即时 postMessage 出去,经 bridge→background→host/bridge 追加进 recording.json。
|
|
7
|
+
* 这样页面刷新/跳转也不丢数据:缓冲在后端,不在页面内存,录制可跨页面连续进行。
|
|
8
|
+
* 本脚本自身无状态缓冲;未开始录制时完全惰性,不打补丁、无开销。
|
|
9
|
+
*/
|
|
10
|
+
(function () {
|
|
11
|
+
if (window.__oreateRecorder) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
window.__oreateRecorder = true;
|
|
15
|
+
|
|
16
|
+
var on = null; // 录制中:{console, network};未录制:null
|
|
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
|
+
// 逐条上报:kind = click | console | network
|
|
73
|
+
function emit(kind, item) {
|
|
74
|
+
window.postMessage({__uiInspector: 'rec-event', kind: kind, item: item}, '*');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onClick(e) {
|
|
78
|
+
if (!on) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
var el = e.target;
|
|
82
|
+
if (!el || el.nodeType !== 1) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
emit('click', {
|
|
86
|
+
selector: cssPath(el),
|
|
87
|
+
tag: el.nodeName.toLowerCase(),
|
|
88
|
+
text: (el.textContent || '').trim().slice(0, 80),
|
|
89
|
+
componentChain: componentChain(el),
|
|
90
|
+
pageUrl: location.href,
|
|
91
|
+
ts: Date.now(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function startConsole() {
|
|
96
|
+
['log', 'info', 'warn', 'error'].forEach(function (level) {
|
|
97
|
+
saved['c_' + level] = console[level];
|
|
98
|
+
console[level] = function () {
|
|
99
|
+
try {
|
|
100
|
+
if (on) {
|
|
101
|
+
emit('console', {
|
|
102
|
+
level: level,
|
|
103
|
+
text: Array.prototype.map.call(arguments, fmt).join(' ').slice(0, 2000),
|
|
104
|
+
ts: Date.now(),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// 记录失败不影响原 console
|
|
109
|
+
}
|
|
110
|
+
return saved['c_' + level].apply(console, arguments);
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stopConsole() {
|
|
116
|
+
['log', 'info', 'warn', 'error'].forEach(function (level) {
|
|
117
|
+
if (saved['c_' + level]) {
|
|
118
|
+
console[level] = saved['c_' + level];
|
|
119
|
+
delete saved['c_' + level];
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function startNetwork() {
|
|
125
|
+
if (window.fetch) {
|
|
126
|
+
saved.fetch = window.fetch;
|
|
127
|
+
window.fetch = function (input, init) {
|
|
128
|
+
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
|
129
|
+
var method = (init && init.method) || (input && input.method) || 'GET';
|
|
130
|
+
var t0 = Date.now();
|
|
131
|
+
var p = saved.fetch.apply(this, arguments);
|
|
132
|
+
if (on && sameHost(url)) {
|
|
133
|
+
p.then(
|
|
134
|
+
function (r) {
|
|
135
|
+
var t1 = Date.now();
|
|
136
|
+
emit('network', {method: method, url: url, status: r.status, startTs: t0, endTs: t1, ms: t1 - t0});
|
|
137
|
+
},
|
|
138
|
+
function () {
|
|
139
|
+
var t1 = Date.now();
|
|
140
|
+
emit('network', {method: method, url: url, status: 0, startTs: t0, endTs: t1, ms: t1 - t0, error: true});
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return p;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
saved.xhrOpen = XMLHttpRequest.prototype.open;
|
|
148
|
+
saved.xhrSend = XMLHttpRequest.prototype.send;
|
|
149
|
+
XMLHttpRequest.prototype.open = function (method, url) {
|
|
150
|
+
this.__rec = {method: method, url: url, t0: Date.now()};
|
|
151
|
+
return saved.xhrOpen.apply(this, arguments);
|
|
152
|
+
};
|
|
153
|
+
XMLHttpRequest.prototype.send = function () {
|
|
154
|
+
var xhr = this;
|
|
155
|
+
var info = xhr.__rec;
|
|
156
|
+
if (info) {
|
|
157
|
+
xhr.addEventListener('loadend', function () {
|
|
158
|
+
if (on && sameHost(info.url)) {
|
|
159
|
+
var t1 = Date.now();
|
|
160
|
+
emit('network', {
|
|
161
|
+
method: info.method,
|
|
162
|
+
url: info.url,
|
|
163
|
+
status: xhr.status,
|
|
164
|
+
startTs: info.t0,
|
|
165
|
+
endTs: t1,
|
|
166
|
+
ms: t1 - info.t0,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return saved.xhrSend.apply(this, arguments);
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function stopNetwork() {
|
|
176
|
+
if (saved.fetch) {
|
|
177
|
+
window.fetch = saved.fetch;
|
|
178
|
+
delete saved.fetch;
|
|
179
|
+
}
|
|
180
|
+
if (saved.xhrOpen) {
|
|
181
|
+
XMLHttpRequest.prototype.open = saved.xhrOpen;
|
|
182
|
+
delete saved.xhrOpen;
|
|
183
|
+
}
|
|
184
|
+
if (saved.xhrSend) {
|
|
185
|
+
XMLHttpRequest.prototype.send = saved.xhrSend;
|
|
186
|
+
delete saved.xhrSend;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function start(opts) {
|
|
191
|
+
if (on) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
on = {console: !!opts.console, network: !!opts.network};
|
|
195
|
+
document.addEventListener('click', onClick, true);
|
|
196
|
+
if (on.console) {
|
|
197
|
+
startConsole();
|
|
198
|
+
}
|
|
199
|
+
if (on.network) {
|
|
200
|
+
startNetwork();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function stop() {
|
|
205
|
+
if (!on) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
document.removeEventListener('click', onClick, true);
|
|
209
|
+
stopConsole();
|
|
210
|
+
stopNetwork();
|
|
211
|
+
on = null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
window.addEventListener('message', function (ev) {
|
|
215
|
+
if (ev.source !== window || !ev.data || ev.data.__uiInspectorBridge !== 'rec-control') {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (ev.data.active) {
|
|
219
|
+
start({console: !!ev.data.console, network: !!ev.data.network});
|
|
220
|
+
} else {
|
|
221
|
+
stop();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
})();
|
|
@@ -11,14 +11,17 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 数据文件与 MCP server / hook 共用:统一在用户主目录 ~/.ui-inspector/pending.json(与仓库位置无关)。
|
|
13
13
|
*/
|
|
14
|
-
import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
|
|
14
|
+
import {readFileSync, writeFileSync, appendFileSync, mkdirSync} from 'node:fs';
|
|
15
15
|
import {dirname, resolve} from 'node:path';
|
|
16
16
|
import {homedir} from 'node:os';
|
|
17
17
|
|
|
18
18
|
// 共享落盘:一台机器一份,放用户主目录,不绑定任何仓库——一份代码可服务多个项目。
|
|
19
19
|
const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
|
|
20
20
|
const statusFile = resolve(homedir(), '.ui-inspector/status.json');
|
|
21
|
+
// 录制拆成两份:元信息(active/startedAt/pageUrl/endedAt)与事件流(append-only,每行一条)。
|
|
22
|
+
// 分开是为了避免「每条事件读改写整份 JSON」的多进程竞态把 active 盖回 true——事件只追加,不碰 active。
|
|
21
23
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
24
|
+
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
22
25
|
|
|
23
26
|
function readStore() {
|
|
24
27
|
try {
|
|
@@ -42,6 +45,46 @@ function writeStore(list) {
|
|
|
42
45
|
writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
// 录制累积:跨刷新/跨页面逐条追加。元信息与事件流分离,避免多进程读改写竞态。
|
|
49
|
+
function readMeta() {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(recordingFile, 'utf8'));
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function writeMeta(meta) {
|
|
57
|
+
mkdirSync(dirname(recordingFile), {recursive: true});
|
|
58
|
+
writeFileSync(recordingFile, `${JSON.stringify(meta ?? null, null, 4)}\n`, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
function recStart(startedAt, pageUrl) {
|
|
61
|
+
const cur = readMeta();
|
|
62
|
+
// 同一会话(startedAt 相同)重复 start(多标签页/弹窗+页面双触发)直接忽略,避免清空已录数据。
|
|
63
|
+
if (cur && cur.active && cur.startedAt === startedAt) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
writeMeta({active: true, startedAt, pageUrl});
|
|
67
|
+
mkdirSync(dirname(eventsFile), {recursive: true});
|
|
68
|
+
writeFileSync(eventsFile, '', 'utf8'); // 清空事件流
|
|
69
|
+
}
|
|
70
|
+
function recEvent(kind, item) {
|
|
71
|
+
const cur = readMeta();
|
|
72
|
+
if (!cur || !cur.active) {
|
|
73
|
+
return; // 未在录制,丢弃
|
|
74
|
+
}
|
|
75
|
+
mkdirSync(dirname(eventsFile), {recursive: true});
|
|
76
|
+
appendFileSync(eventsFile, `${JSON.stringify({kind, item})}\n`, 'utf8'); // 追加,原子、无竞态
|
|
77
|
+
}
|
|
78
|
+
function recStop(endedAt) {
|
|
79
|
+
const cur = readMeta();
|
|
80
|
+
if (!cur) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
cur.active = false;
|
|
84
|
+
cur.endedAt = endedAt || Date.now();
|
|
85
|
+
writeMeta(cur); // 只有 start/stop 动 active,事件追加不碰它 → stop 后 active 不会被盖回
|
|
86
|
+
}
|
|
87
|
+
|
|
45
88
|
function send(obj) {
|
|
46
89
|
const body = Buffer.from(JSON.stringify(obj), 'utf8');
|
|
47
90
|
const header = Buffer.alloc(4);
|
|
@@ -58,9 +101,14 @@ function handle(msg) {
|
|
|
58
101
|
resp = {ok: true, pending: targets.length};
|
|
59
102
|
} else if (msg && msg.type === 'state') {
|
|
60
103
|
resp = {ok: true, count: readStore().length, status: readStatus()};
|
|
61
|
-
} else if (msg && msg.type === '
|
|
62
|
-
|
|
63
|
-
|
|
104
|
+
} else if (msg && msg.type === 'rec-start') {
|
|
105
|
+
recStart(msg.startedAt, msg.pageUrl);
|
|
106
|
+
resp = {ok: true};
|
|
107
|
+
} else if (msg && msg.type === 'rec-event') {
|
|
108
|
+
recEvent(msg.kind, msg.item);
|
|
109
|
+
resp = {ok: true};
|
|
110
|
+
} else if (msg && msg.type === 'rec-stop') {
|
|
111
|
+
recStop(msg.endedAt);
|
|
64
112
|
resp = {ok: true};
|
|
65
113
|
} else {
|
|
66
114
|
resp = {ok: false, error: 'unknown message'};
|
package/extension/popup.html
CHANGED
|
@@ -114,6 +114,28 @@
|
|
|
114
114
|
color: #fff;
|
|
115
115
|
border-color: #1f6feb;
|
|
116
116
|
}
|
|
117
|
+
/* 模式子 tab:与顶层「模式/设置」区分——走胶囊描边风格,选中为浅蓝底+蓝字,不填满。 */
|
|
118
|
+
.subtabs {
|
|
119
|
+
display: flex;
|
|
120
|
+
gap: 6px;
|
|
121
|
+
margin-bottom: 8px;
|
|
122
|
+
}
|
|
123
|
+
.subtab {
|
|
124
|
+
flex: 0 0 auto;
|
|
125
|
+
padding: 4px 12px;
|
|
126
|
+
border: 1px solid #d0d7de;
|
|
127
|
+
border-radius: 999px;
|
|
128
|
+
background: #fff;
|
|
129
|
+
color: #1f2328;
|
|
130
|
+
cursor: pointer;
|
|
131
|
+
font: inherit;
|
|
132
|
+
font-size: 12px;
|
|
133
|
+
}
|
|
134
|
+
.subtab.active {
|
|
135
|
+
background: #ddf4ff;
|
|
136
|
+
color: #0969da;
|
|
137
|
+
border-color: #54aeff;
|
|
138
|
+
}
|
|
117
139
|
</style>
|
|
118
140
|
</head>
|
|
119
141
|
<body>
|
|
@@ -141,7 +163,7 @@
|
|
|
141
163
|
<!-- 「模式」页:选择 Element + 各增强包模式(popup.js 读 enhancements.json 动态填充子 tab) -->
|
|
142
164
|
<div id="top-mode">
|
|
143
165
|
<div
|
|
144
|
-
class="
|
|
166
|
+
class="subtabs"
|
|
145
167
|
id="mode-tabs"
|
|
146
168
|
></div>
|
|
147
169
|
<section
|
package/extension/popup.js
CHANGED
|
@@ -180,7 +180,7 @@ async function initModes() {
|
|
|
180
180
|
|
|
181
181
|
function makeTab(key, label, active) {
|
|
182
182
|
const b = document.createElement('button');
|
|
183
|
-
b.className = active ? '
|
|
183
|
+
b.className = active ? 'subtab active' : 'subtab';
|
|
184
184
|
b.textContent = label;
|
|
185
185
|
b.dataset.mode = key;
|
|
186
186
|
b.onclick = () => switchMode(key);
|
|
@@ -189,7 +189,7 @@ async function initModes() {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
function switchMode(key) {
|
|
192
|
-
for (const b of tabsEl.querySelectorAll('.
|
|
192
|
+
for (const b of tabsEl.querySelectorAll('.subtab')) {
|
|
193
193
|
b.classList.toggle('active', b.dataset.mode === key);
|
|
194
194
|
}
|
|
195
195
|
for (const [k, el] of Object.entries(paneEls)) {
|
|
@@ -221,9 +221,12 @@ async function initModes() {
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
//
|
|
224
|
+
// 没有增强包:只有「选择 Element」一个模式,顶层「模式/设置」tab 失去意义——
|
|
225
|
+
// 隐藏顶层 tab 与模式页,直接展示「设置」面板内容。
|
|
225
226
|
if (!plugins.length) {
|
|
226
|
-
|
|
227
|
+
$('top-tabs').style.display = 'none';
|
|
228
|
+
$('top-mode').style.display = 'none';
|
|
229
|
+
$('top-settings').style.display = 'block';
|
|
227
230
|
}
|
|
228
231
|
}
|
|
229
232
|
initModes();
|
|
@@ -16,7 +16,9 @@ import {homedir} from 'node:os';
|
|
|
16
16
|
|
|
17
17
|
// 共享落盘:读用户主目录下的 pending / recording(与 host / bridge / mcp 同一路径),一份代码可服务多个仓库。
|
|
18
18
|
const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
|
|
19
|
+
// 录制:元信息(active/startedAt/pageUrl/endedAt)+ append-only 事件流(每行一条 {kind,item})。
|
|
19
20
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
21
|
+
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
20
22
|
|
|
21
23
|
function emit(output) {
|
|
22
24
|
process.stdout.write(JSON.stringify(output));
|
|
@@ -31,14 +33,50 @@ function readJson(file, fallback) {
|
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
// 读 append-only 事件流,按类型归并
|
|
37
|
+
function readEvents() {
|
|
38
|
+
const clicks = [];
|
|
39
|
+
const con = [];
|
|
40
|
+
const network = [];
|
|
41
|
+
let text = '';
|
|
42
|
+
try {
|
|
43
|
+
text = readFileSync(eventsFile, 'utf8');
|
|
44
|
+
} catch {
|
|
45
|
+
return {clicks, console: con, network};
|
|
46
|
+
}
|
|
47
|
+
for (const line of text.split('\n')) {
|
|
48
|
+
if (!line.trim()) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
let ev;
|
|
52
|
+
try {
|
|
53
|
+
ev = JSON.parse(line);
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!ev || !ev.item) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ev.kind === 'click') {
|
|
61
|
+
clicks.push(ev.item);
|
|
62
|
+
} else if (ev.kind === 'console') {
|
|
63
|
+
con.push(ev.item);
|
|
64
|
+
} else if (ev.kind === 'network') {
|
|
65
|
+
network.push(ev.item);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {clicks, console: con, network};
|
|
69
|
+
}
|
|
70
|
+
|
|
34
71
|
const parsed = readJson(storeFile, []);
|
|
35
72
|
const list = Array.isArray(parsed) ? parsed : [];
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
73
|
+
const meta = readJson(recordingFile, null);
|
|
74
|
+
// 只注入**已停止**的录制(active === false)。录制进行中(跨刷新/跨页面累积)时不消费、不清空,
|
|
75
|
+
// 以免中途给 Comate 发消息把整段会话吞掉。
|
|
76
|
+
const recDone = meta && meta.active === false;
|
|
77
|
+
const events = recDone ? readEvents() : {clicks: [], console: [], network: []};
|
|
78
|
+
const recording = recDone ? {...meta, ...events} : null;
|
|
79
|
+
const hasRec = recDone && (events.clicks.length || events.console.length || events.network.length);
|
|
42
80
|
|
|
43
81
|
if (!list.length && !hasRec) {
|
|
44
82
|
emit({decision: 'allow'});
|
|
@@ -110,11 +148,14 @@ sections.push(
|
|
|
110
148
|
'请据此定位源码并按用户意见修改。页面选中态已自动取消;改动过程中可调用 MCP 工具 notify_browser 向页面推送状态(如「修改中…」「已完成」),会在浏览器弹 toast。'
|
|
111
149
|
);
|
|
112
150
|
|
|
113
|
-
// 「拿到即消费」:注入后清空 pending
|
|
151
|
+
// 「拿到即消费」:注入后清空 pending;录制仅在确实注入(已停止)时清空元信息与事件流,进行中的保留。
|
|
114
152
|
try {
|
|
115
153
|
mkdirSync(dirname(storeFile), {recursive: true});
|
|
116
154
|
writeFileSync(storeFile, '[]\n', 'utf8');
|
|
117
|
-
|
|
155
|
+
if (hasRec) {
|
|
156
|
+
writeFileSync(recordingFile, 'null\n', 'utf8');
|
|
157
|
+
writeFileSync(eventsFile, '', 'utf8');
|
|
158
|
+
}
|
|
118
159
|
} catch {
|
|
119
160
|
// 清空失败不影响注入
|
|
120
161
|
}
|
package/package.json
CHANGED