comate-ui-inspector 0.1.5-alpha.1 → 0.1.5-alpha.3
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 +85 -11
- package/enhancements/recorder/recorder-main.js +33 -26
- package/extension/background.js +20 -4
- package/extension/bridge-content.js +119 -17
- package/extension/native-host/host.mjs +56 -3
- package/extension/popup.html +107 -63
- package/extension/popup.js +31 -12
- package/hooks/inject-ui-inspector.mjs +38 -21
- package/package.json +1 -1
package/bridge/server.mjs
CHANGED
|
@@ -43,6 +43,65 @@ function writeStore(list) {
|
|
|
43
43
|
writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// 录制累积:跨刷新/跨页面逐条追加进 recording.json(缓冲在后端,不在页面内存)。
|
|
47
|
+
const REC_CAP = 1000;
|
|
48
|
+
function readRecording() {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(readFileSync(recordingFile, 'utf8'));
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function writeRecording(rec) {
|
|
56
|
+
mkdirSync(dirname(recordingFile), {recursive: true});
|
|
57
|
+
writeFileSync(recordingFile, `${JSON.stringify(rec ?? null, null, 4)}\n`, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
function recStart(startedAt, pageUrl) {
|
|
60
|
+
const cur = readRecording();
|
|
61
|
+
if (cur && cur.active && cur.startedAt === startedAt) {
|
|
62
|
+
return; // 同一会话重复 start(多标签页),忽略
|
|
63
|
+
}
|
|
64
|
+
writeRecording({active: true, startedAt, pageUrl, clicks: [], console: [], network: []});
|
|
65
|
+
}
|
|
66
|
+
function recEvent(kind, item) {
|
|
67
|
+
const cur = readRecording();
|
|
68
|
+
if (!cur || !cur.active) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const key = kind === 'click' ? 'clicks' : kind === 'console' ? 'console' : kind === 'network' ? 'network' : null;
|
|
72
|
+
if (!key) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(cur[key])) {
|
|
76
|
+
cur[key] = [];
|
|
77
|
+
}
|
|
78
|
+
if (cur[key].length < REC_CAP) {
|
|
79
|
+
cur[key].push(item);
|
|
80
|
+
writeRecording(cur);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function recStop(endedAt) {
|
|
84
|
+
const cur = readRecording();
|
|
85
|
+
if (!cur) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
cur.active = false;
|
|
89
|
+
cur.endedAt = endedAt || Date.now();
|
|
90
|
+
writeRecording(cur);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readBody(req, cb) {
|
|
94
|
+
let body = '';
|
|
95
|
+
req.on('data', chunk => (body += chunk));
|
|
96
|
+
req.on('end', () => {
|
|
97
|
+
try {
|
|
98
|
+
cb(JSON.parse(body || '{}'));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
cb(null, error);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
46
105
|
function cors(res) {
|
|
47
106
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
48
107
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
|
@@ -83,18 +142,33 @@ const server = createServer((req, res) => {
|
|
|
83
142
|
json(res, 200, {ok: true, count: readStore().length, status: readStatus()});
|
|
84
143
|
return;
|
|
85
144
|
}
|
|
86
|
-
if (req.method === 'POST' && url.pathname === '/
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
145
|
+
if (req.method === 'POST' && url.pathname === '/rec/start') {
|
|
146
|
+
readBody(req, (data, err) => {
|
|
147
|
+
if (err) {
|
|
148
|
+
return json(res, 400, {ok: false, error: String(err)});
|
|
149
|
+
}
|
|
150
|
+
recStart(data.startedAt, data.pageUrl);
|
|
151
|
+
json(res, 200, {ok: true});
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (req.method === 'POST' && url.pathname === '/rec/event') {
|
|
156
|
+
readBody(req, (data, err) => {
|
|
157
|
+
if (err) {
|
|
158
|
+
return json(res, 400, {ok: false, error: String(err)});
|
|
159
|
+
}
|
|
160
|
+
recEvent(data.kind, data.item);
|
|
161
|
+
json(res, 200, {ok: true});
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (req.method === 'POST' && url.pathname === '/rec/stop') {
|
|
166
|
+
readBody(req, (data, err) => {
|
|
167
|
+
if (err) {
|
|
168
|
+
return json(res, 400, {ok: false, error: String(err)});
|
|
97
169
|
}
|
|
170
|
+
recStop(data.endedAt);
|
|
171
|
+
json(res, 200, {ok: true});
|
|
98
172
|
});
|
|
99
173
|
return;
|
|
100
174
|
}
|
|
@@ -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,13 +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();
|
|
136
|
+
emit('network', {method: method, url: url, status: r.status, startTs: t0, endTs: t1, ms: t1 - t0});
|
|
130
137
|
},
|
|
131
138
|
function () {
|
|
132
|
-
|
|
139
|
+
var t1 = Date.now();
|
|
140
|
+
emit('network', {method: method, url: url, status: 0, startTs: t0, endTs: t1, ms: t1 - t0, error: true});
|
|
133
141
|
}
|
|
134
142
|
);
|
|
135
143
|
}
|
|
@@ -147,13 +155,15 @@
|
|
|
147
155
|
var info = xhr.__rec;
|
|
148
156
|
if (info) {
|
|
149
157
|
xhr.addEventListener('loadend', function () {
|
|
150
|
-
if (
|
|
151
|
-
|
|
158
|
+
if (on && sameHost(info.url)) {
|
|
159
|
+
var t1 = Date.now();
|
|
160
|
+
emit('network', {
|
|
152
161
|
method: info.method,
|
|
153
162
|
url: info.url,
|
|
154
163
|
status: xhr.status,
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
startTs: info.t0,
|
|
165
|
+
endTs: t1,
|
|
166
|
+
ms: t1 - info.t0,
|
|
157
167
|
});
|
|
158
168
|
}
|
|
159
169
|
});
|
|
@@ -178,30 +188,27 @@
|
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
function start(opts) {
|
|
181
|
-
if (
|
|
191
|
+
if (on) {
|
|
182
192
|
return;
|
|
183
193
|
}
|
|
184
|
-
|
|
194
|
+
on = {console: !!opts.console, network: !!opts.network};
|
|
185
195
|
document.addEventListener('click', onClick, true);
|
|
186
|
-
if (
|
|
196
|
+
if (on.console) {
|
|
187
197
|
startConsole();
|
|
188
198
|
}
|
|
189
|
-
if (
|
|
199
|
+
if (on.network) {
|
|
190
200
|
startNetwork();
|
|
191
201
|
}
|
|
192
202
|
}
|
|
193
203
|
|
|
194
204
|
function stop() {
|
|
195
|
-
if (!
|
|
205
|
+
if (!on) {
|
|
196
206
|
return;
|
|
197
207
|
}
|
|
198
208
|
document.removeEventListener('click', onClick, true);
|
|
199
209
|
stopConsole();
|
|
200
210
|
stopNetwork();
|
|
201
|
-
|
|
202
|
-
var payload = rec;
|
|
203
|
-
rec = null;
|
|
204
|
-
window.postMessage({__uiInspector: 'recording', recording: payload}, '*');
|
|
211
|
+
on = null;
|
|
205
212
|
}
|
|
206
213
|
|
|
207
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 {
|
|
@@ -42,6 +42,54 @@ function writeStore(list) {
|
|
|
42
42
|
writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// 录制累积:跨刷新/跨页面逐条追加进 recording.json(缓冲在后端,不在页面内存)。
|
|
46
|
+
const REC_CAP = 1000; // 每类事件上限,防爆
|
|
47
|
+
function readRecording() {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(readFileSync(recordingFile, 'utf8'));
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function writeRecording(rec) {
|
|
55
|
+
mkdirSync(dirname(recordingFile), {recursive: true});
|
|
56
|
+
writeFileSync(recordingFile, `${JSON.stringify(rec ?? null, null, 4)}\n`, 'utf8');
|
|
57
|
+
}
|
|
58
|
+
function recStart(startedAt, pageUrl) {
|
|
59
|
+
const cur = readRecording();
|
|
60
|
+
// 同一会话(startedAt 相同)重复 start(多标签页)直接忽略,避免互相清空。
|
|
61
|
+
if (cur && cur.active && cur.startedAt === startedAt) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
writeRecording({active: true, startedAt, pageUrl, clicks: [], console: [], network: []});
|
|
65
|
+
}
|
|
66
|
+
function recEvent(kind, item) {
|
|
67
|
+
const cur = readRecording();
|
|
68
|
+
if (!cur || !cur.active) {
|
|
69
|
+
return; // 未在录制,丢弃
|
|
70
|
+
}
|
|
71
|
+
const key = kind === 'click' ? 'clicks' : kind === 'console' ? 'console' : kind === 'network' ? 'network' : null;
|
|
72
|
+
if (!key) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(cur[key])) {
|
|
76
|
+
cur[key] = [];
|
|
77
|
+
}
|
|
78
|
+
if (cur[key].length < REC_CAP) {
|
|
79
|
+
cur[key].push(item);
|
|
80
|
+
writeRecording(cur);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function recStop(endedAt) {
|
|
84
|
+
const cur = readRecording();
|
|
85
|
+
if (!cur) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
cur.active = false;
|
|
89
|
+
cur.endedAt = endedAt || Date.now();
|
|
90
|
+
writeRecording(cur);
|
|
91
|
+
}
|
|
92
|
+
|
|
45
93
|
function send(obj) {
|
|
46
94
|
const body = Buffer.from(JSON.stringify(obj), 'utf8');
|
|
47
95
|
const header = Buffer.alloc(4);
|
|
@@ -58,9 +106,14 @@ function handle(msg) {
|
|
|
58
106
|
resp = {ok: true, pending: targets.length};
|
|
59
107
|
} else if (msg && msg.type === 'state') {
|
|
60
108
|
resp = {ok: true, count: readStore().length, status: readStatus()};
|
|
61
|
-
} else if (msg && msg.type === '
|
|
62
|
-
|
|
63
|
-
|
|
109
|
+
} else if (msg && msg.type === 'rec-start') {
|
|
110
|
+
recStart(msg.startedAt, msg.pageUrl);
|
|
111
|
+
resp = {ok: true};
|
|
112
|
+
} else if (msg && msg.type === 'rec-event') {
|
|
113
|
+
recEvent(msg.kind, msg.item);
|
|
114
|
+
resp = {ok: true};
|
|
115
|
+
} else if (msg && msg.type === 'rec-stop') {
|
|
116
|
+
recStop(msg.endedAt);
|
|
64
117
|
resp = {ok: true};
|
|
65
118
|
} else {
|
|
66
119
|
resp = {ok: false, error: 'unknown message'};
|
package/extension/popup.html
CHANGED
|
@@ -114,18 +114,56 @@
|
|
|
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>
|
|
120
142
|
<h1>ui-inspector 配置</h1>
|
|
121
143
|
|
|
122
|
-
<!--
|
|
144
|
+
<!-- 顶层 tab:模式 / 设置 -->
|
|
123
145
|
<div
|
|
124
|
-
|
|
125
|
-
|
|
146
|
+
class="tabs"
|
|
147
|
+
id="top-tabs"
|
|
126
148
|
>
|
|
149
|
+
<button
|
|
150
|
+
class="tab active"
|
|
151
|
+
data-top="mode"
|
|
152
|
+
>
|
|
153
|
+
模式
|
|
154
|
+
</button>
|
|
155
|
+
<button
|
|
156
|
+
class="tab"
|
|
157
|
+
data-top="settings"
|
|
158
|
+
>
|
|
159
|
+
设置
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<!-- 「模式」页:选择 Element + 各增强包模式(popup.js 读 enhancements.json 动态填充子 tab) -->
|
|
164
|
+
<div id="top-mode">
|
|
127
165
|
<div
|
|
128
|
-
class="
|
|
166
|
+
class="subtabs"
|
|
129
167
|
id="mode-tabs"
|
|
130
168
|
></div>
|
|
131
169
|
<section
|
|
@@ -137,71 +175,77 @@
|
|
|
137
175
|
<div id="mode-panes"></div>
|
|
138
176
|
</div>
|
|
139
177
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
<button
|
|
149
|
-
id="add-domain"
|
|
150
|
-
class="primary"
|
|
151
|
-
>
|
|
152
|
-
添加
|
|
153
|
-
</button>
|
|
154
|
-
</div>
|
|
155
|
-
<ul id="domain-list"></ul>
|
|
156
|
-
<div class="hint">添加时浏览器会请求该域名的访问权限,允许后即在该域名注入选取脚本。</div>
|
|
157
|
-
</section>
|
|
158
|
-
|
|
159
|
-
<section>
|
|
160
|
-
<div class="label">传输通路</div>
|
|
161
|
-
<label class="radio">
|
|
162
|
-
<input
|
|
163
|
-
type="radio"
|
|
164
|
-
name="transport"
|
|
165
|
-
value="native"
|
|
166
|
-
/>
|
|
167
|
-
本地(Native Messaging,默认)
|
|
168
|
-
</label>
|
|
169
|
-
<label class="radio">
|
|
170
|
-
<input
|
|
171
|
-
type="radio"
|
|
172
|
-
name="transport"
|
|
173
|
-
value="http"
|
|
174
|
-
/>
|
|
175
|
-
远程(HTTP bridge)
|
|
176
|
-
</label>
|
|
177
|
-
<div id="bridge-config">
|
|
178
|
-
<div
|
|
179
|
-
class="row"
|
|
180
|
-
style="margin-top: 6px"
|
|
181
|
-
>
|
|
178
|
+
<!-- 「设置」页:生效域名 + 传输通路 -->
|
|
179
|
+
<div
|
|
180
|
+
id="top-settings"
|
|
181
|
+
style="display: none"
|
|
182
|
+
>
|
|
183
|
+
<section>
|
|
184
|
+
<div class="label">生效域名</div>
|
|
185
|
+
<div class="row">
|
|
182
186
|
<input
|
|
183
|
-
id="
|
|
187
|
+
id="domain-input"
|
|
184
188
|
type="text"
|
|
185
|
-
placeholder="
|
|
189
|
+
placeholder="如 https://example.com"
|
|
186
190
|
/>
|
|
191
|
+
<button
|
|
192
|
+
id="add-domain"
|
|
193
|
+
class="primary"
|
|
194
|
+
>
|
|
195
|
+
添加
|
|
196
|
+
</button>
|
|
187
197
|
</div>
|
|
188
|
-
<
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
198
|
+
<ul id="domain-list"></ul>
|
|
199
|
+
<div class="hint">添加时浏览器会请求该域名的访问权限,允许后即在该域名注入选取脚本。</div>
|
|
200
|
+
</section>
|
|
201
|
+
|
|
202
|
+
<section>
|
|
203
|
+
<div class="label">传输通路</div>
|
|
204
|
+
<label class="radio">
|
|
205
|
+
<input
|
|
206
|
+
type="radio"
|
|
207
|
+
name="transport"
|
|
208
|
+
value="native"
|
|
209
|
+
/>
|
|
210
|
+
本地(Native Messaging,默认)
|
|
211
|
+
</label>
|
|
212
|
+
<label class="radio">
|
|
213
|
+
<input
|
|
214
|
+
type="radio"
|
|
215
|
+
name="transport"
|
|
216
|
+
value="http"
|
|
217
|
+
/>
|
|
218
|
+
远程(HTTP bridge)
|
|
219
|
+
</label>
|
|
220
|
+
<div id="bridge-config">
|
|
221
|
+
<div
|
|
222
|
+
class="row"
|
|
223
|
+
style="margin-top: 6px"
|
|
224
|
+
>
|
|
225
|
+
<input
|
|
226
|
+
id="bridge-target"
|
|
227
|
+
type="text"
|
|
228
|
+
placeholder="devlocal.oreateai.com:8917"
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="hint">
|
|
232
|
+
填开发机 bridge 的完整地址(如
|
|
233
|
+
<code>devlocal.oreateai.com:8917</code>
|
|
234
|
+
)。开发机上
|
|
235
|
+
<code>ui-inspector install --remote</code>
|
|
236
|
+
起 bridge(默认绑 0.0.0.0 供直连)。
|
|
237
|
+
</div>
|
|
194
238
|
</div>
|
|
195
|
-
</
|
|
196
|
-
</section>
|
|
239
|
+
</section>
|
|
197
240
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
241
|
+
<button
|
|
242
|
+
id="save"
|
|
243
|
+
class="primary"
|
|
244
|
+
>
|
|
245
|
+
保存传输设置
|
|
246
|
+
</button>
|
|
247
|
+
<div id="msg"></div>
|
|
248
|
+
</div>
|
|
205
249
|
|
|
206
250
|
<script src="popup.js"></script>
|
|
207
251
|
</body>
|
package/extension/popup.js
CHANGED
|
@@ -139,12 +139,28 @@ $('domain-input').addEventListener('keydown', e => {
|
|
|
139
139
|
renderDomains();
|
|
140
140
|
renderTransport();
|
|
141
141
|
|
|
142
|
-
// ----------
|
|
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 ----------
|
|
143
160
|
// 读 extension/enhancements.json(由 `ui-inspector add <名>` 拷贝插件并写入)。
|
|
144
|
-
//
|
|
145
|
-
// 「选择 Element」固定第一项 + 每个插件一项;点 tab 切换对应面板。
|
|
161
|
+
// 子 tab 第一项固定「选择 Element」,其后每个增强包一项;无增强包时隐藏子 tab 条只留默认提示。
|
|
146
162
|
// 面板 HTML 片段从 enhancements/<id>/<pane> 拉取内嵌,随后加载 enhancements/<id>/<paneJs>。
|
|
147
|
-
async function
|
|
163
|
+
async function initModes() {
|
|
148
164
|
let plugins = [];
|
|
149
165
|
try {
|
|
150
166
|
const r = await fetch(chrome.runtime.getURL('enhancements.json'));
|
|
@@ -157,9 +173,6 @@ async function initEnhancements() {
|
|
|
157
173
|
} catch {
|
|
158
174
|
plugins = [];
|
|
159
175
|
}
|
|
160
|
-
if (!plugins.length) {
|
|
161
|
-
return; // 基础版:不显示模式 tab
|
|
162
|
-
}
|
|
163
176
|
|
|
164
177
|
const tabsEl = $('mode-tabs');
|
|
165
178
|
const panesEl = $('mode-panes');
|
|
@@ -167,16 +180,16 @@ async function initEnhancements() {
|
|
|
167
180
|
|
|
168
181
|
function makeTab(key, label, active) {
|
|
169
182
|
const b = document.createElement('button');
|
|
170
|
-
b.className = active ? '
|
|
183
|
+
b.className = active ? 'subtab active' : 'subtab';
|
|
171
184
|
b.textContent = label;
|
|
185
|
+
b.dataset.mode = key;
|
|
172
186
|
b.onclick = () => switchMode(key);
|
|
173
187
|
tabsEl.appendChild(b);
|
|
174
|
-
b.dataset.mode = key;
|
|
175
188
|
return b;
|
|
176
189
|
}
|
|
177
190
|
|
|
178
191
|
function switchMode(key) {
|
|
179
|
-
for (const b of tabsEl.querySelectorAll('.
|
|
192
|
+
for (const b of tabsEl.querySelectorAll('.subtab')) {
|
|
180
193
|
b.classList.toggle('active', b.dataset.mode === key);
|
|
181
194
|
}
|
|
182
195
|
for (const [k, el] of Object.entries(paneEls)) {
|
|
@@ -208,6 +221,12 @@ async function initEnhancements() {
|
|
|
208
221
|
}
|
|
209
222
|
}
|
|
210
223
|
|
|
211
|
-
|
|
224
|
+
// 没有增强包:只有「选择 Element」一个模式,顶层「模式/设置」tab 失去意义——
|
|
225
|
+
// 隐藏顶层 tab 与模式页,直接展示「设置」面板内容。
|
|
226
|
+
if (!plugins.length) {
|
|
227
|
+
$('top-tabs').style.display = 'none';
|
|
228
|
+
$('top-mode').style.display = 'none';
|
|
229
|
+
$('top-settings').style.display = 'block';
|
|
230
|
+
}
|
|
212
231
|
}
|
|
213
|
-
|
|
232
|
+
initModes();
|
|
@@ -34,8 +34,11 @@ function readJson(file, fallback) {
|
|
|
34
34
|
const parsed = readJson(storeFile, []);
|
|
35
35
|
const list = Array.isArray(parsed) ? parsed : [];
|
|
36
36
|
const recording = readJson(recordingFile, null);
|
|
37
|
+
// 只注入**已停止**的录制(active === false)。录制进行中(跨刷新/跨页面累积)时不消费、不清空,
|
|
38
|
+
// 以免中途给 Comate 发消息把整段会话吞掉。
|
|
39
|
+
const recDone = recording && recording.active === false;
|
|
37
40
|
const hasRec =
|
|
38
|
-
|
|
41
|
+
recDone &&
|
|
39
42
|
((recording.clicks && recording.clicks.length) ||
|
|
40
43
|
(recording.console && recording.console.length) ||
|
|
41
44
|
(recording.network && recording.network.length));
|
|
@@ -58,25 +61,37 @@ function describe(target, index, total) {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
function describeRecording(r) {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
const base = r.startedAt || 0;
|
|
65
|
+
const secs = Math.round(((r.endedAt || 0) - base || 0) / 1000);
|
|
66
|
+
const rel = t => (typeof t === 'number' ? `+${((t - base) / 1000).toFixed(2)}s` : '+?');
|
|
67
|
+
|
|
68
|
+
// 把点击 / 控制台 / 网络归并成一条按时间排序的时间线;网络拆成「发起」「完成」两个事件。
|
|
69
|
+
const events = [];
|
|
70
|
+
(r.clicks || []).forEach(c => {
|
|
71
|
+
const files = (c.componentChain || []).map(x => x && x.file).filter(Boolean);
|
|
72
|
+
events.push({
|
|
73
|
+
ts: c.ts,
|
|
74
|
+
text:
|
|
75
|
+
`点击 <${c.tag}> ${JSON.stringify((c.text || '').slice(0, 40))} @ ${c.selector}` +
|
|
76
|
+
(files.length ? ` | 源码 ${files.join(' <- ')}` : ''),
|
|
71
77
|
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
});
|
|
79
|
+
(r.console || []).forEach(l => {
|
|
80
|
+
events.push({ts: l.ts, text: `控制台[${l.level}] ${String(l.text).slice(0, 300)}`});
|
|
81
|
+
});
|
|
82
|
+
(r.network || []).forEach(n => {
|
|
83
|
+
events.push({ts: n.startTs, text: `网络→发起 ${n.method} ${n.url}`});
|
|
84
|
+
events.push({ts: n.endTs, text: `网络←完成 ${n.method} ${n.url} → ${n.status} (${n.ms}ms)`});
|
|
85
|
+
});
|
|
86
|
+
// V8 的 sort 稳定:同一时刻保持插入序(发起在完成之前)。
|
|
87
|
+
events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
88
|
+
|
|
89
|
+
const parts = [
|
|
90
|
+
`【ui-inspector 录制】用户录制了一段操作(页面 ${r.pageUrl || ''},约 ${secs}s,共 ${events.length} 个事件,时间为相对录制起点的偏移):`,
|
|
91
|
+
];
|
|
92
|
+
events.slice(0, 120).forEach(e => parts.push(` ${rel(e.ts)} ${e.text}`));
|
|
93
|
+
if (events.length > 120) {
|
|
94
|
+
parts.push(` …(其余 ${events.length - 120} 个事件省略)`);
|
|
80
95
|
}
|
|
81
96
|
return parts.join('\n');
|
|
82
97
|
}
|
|
@@ -98,11 +113,13 @@ sections.push(
|
|
|
98
113
|
'请据此定位源码并按用户意见修改。页面选中态已自动取消;改动过程中可调用 MCP 工具 notify_browser 向页面推送状态(如「修改中…」「已完成」),会在浏览器弹 toast。'
|
|
99
114
|
);
|
|
100
115
|
|
|
101
|
-
// 「拿到即消费」:注入后清空 pending
|
|
116
|
+
// 「拿到即消费」:注入后清空 pending;录制仅在确实注入(已停止)时清空,进行中的保留。
|
|
102
117
|
try {
|
|
103
118
|
mkdirSync(dirname(storeFile), {recursive: true});
|
|
104
119
|
writeFileSync(storeFile, '[]\n', 'utf8');
|
|
105
|
-
|
|
120
|
+
if (hasRec) {
|
|
121
|
+
writeFileSync(recordingFile, 'null\n', 'utf8');
|
|
122
|
+
}
|
|
106
123
|
} catch {
|
|
107
124
|
// 清空失败不影响注入
|
|
108
125
|
}
|
package/package.json
CHANGED