comate-ui-inspector 0.1.6-alpha.3 → 0.1.6-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 CHANGED
@@ -5,7 +5,7 @@
5
5
  * 跑在「Comate agent / hook / MCP 所在的那台机器」(通常是远程开发机),读写和它们同一份
6
6
  * ~/.ui-inspector/*.json。浏览器扩展(transport=http)直连本服务。
7
7
  */
8
- import {createServer} from 'node:http';
8
+ import {createServer, get} from 'node:http';
9
9
  import {readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, rmSync} from 'node:fs';
10
10
  import {dirname, resolve} from 'node:path';
11
11
  import {homedir} from 'node:os';
@@ -19,6 +19,8 @@ const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
19
19
  const figmaPendingFile = resolve(homedir(), '.ui-inspector/figma-pending.json');
20
20
  // figma 节点内导出的图片字节落盘到这里(按采集批次分子目录)。
21
21
  const figmaAssetsDir = resolve(homedir(), '.ui-inspector/figma-assets');
22
+ // bridge 实际绑定的端口写这里,供 cli(install --remote)读取并打印真正在用的端口。
23
+ const portFile = resolve(homedir(), '.ui-inspector/bridge.json');
22
24
  const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
23
25
  const HOST = process.env.UI_INSPECTOR_BRIDGE_HOST || '127.0.0.1';
24
26
 
@@ -276,6 +278,95 @@ const server = createServer((req, res) => {
276
278
  json(res, 404, {ok: false, error: 'not found'});
277
279
  });
278
280
 
279
- server.listen(PORT, HOST, () => {
280
- process.stdout.write(`ui-inspector bridge listening on http://${HOST}:${PORT}\n`);
281
+ let currentPort = PORT;
282
+ let triedReuse = false;
283
+ let bound = false;
284
+ const MAX_PORT_TRIES = 20;
285
+
286
+ // 绑定成功:把实际端口写进 bridge.json(cli install --remote 据此打印真正在用的端口)。
287
+ server.on('listening', () => {
288
+ bound = true;
289
+ const addr = server.address();
290
+ const actual = (addr && addr.port) || currentPort;
291
+ try {
292
+ mkdirSync(dirname(portFile), {recursive: true});
293
+ writeFileSync(
294
+ portFile,
295
+ `${JSON.stringify({host: HOST, port: actual, pid: process.pid, ts: Date.now()})}\n`,
296
+ 'utf8'
297
+ );
298
+ } catch {
299
+ // 写端口文件失败不影响服务
300
+ }
301
+ process.stdout.write(`ui-inspector bridge listening on http://${HOST}:${actual}\n`);
302
+ });
303
+
304
+ // 顺延到下一个端口;连试 MAX_PORT_TRIES 个都不行才放弃。bound 后幂等,避免重复 listen 崩溃。
305
+ function hopNext() {
306
+ if (bound) {
307
+ return;
308
+ }
309
+ if (currentPort - PORT >= MAX_PORT_TRIES) {
310
+ process.stderr.write(
311
+ `从 ${PORT} 起连续 ${MAX_PORT_TRIES} 个端口都被占用,放弃。手动指定 UI_INSPECTOR_BRIDGE_PORT。\n`
312
+ );
313
+ process.exit(1);
314
+ return;
315
+ }
316
+ currentPort += 1;
317
+ server.listen(currentPort, HOST);
318
+ }
319
+
320
+ // 端口被占用是常见情况。基准端口被占时先探测是不是已有我们的 bridge:是就复用退出(幂等);
321
+ // 否则自动顺延到下一个可用端口。其它启动错误直接友好报错退出,不抛未捕获异常。
322
+ server.on('error', err => {
323
+ if (!err || err.code !== 'EADDRINUSE') {
324
+ process.stderr.write(`ui-inspector bridge 启动失败:${String(err)}\n`);
325
+ process.exit(1);
326
+ return;
327
+ }
328
+ if (currentPort === PORT && !triedReuse) {
329
+ triedReuse = true;
330
+ const probeHost = HOST === '0.0.0.0' ? '127.0.0.1' : HOST;
331
+ // probe 的 end/error/timeout 可能多次触发(如 destroy 又引发 error),用 settled 保证只处理一次。
332
+ let settled = false;
333
+ const settle = fn => {
334
+ if (settled) {
335
+ return;
336
+ }
337
+ settled = true;
338
+ fn();
339
+ };
340
+ const req = get({host: probeHost, port: PORT, path: '/state', timeout: 1500}, res => {
341
+ let body = '';
342
+ res.on('data', c => (body += c));
343
+ res.on('end', () =>
344
+ settle(() => {
345
+ let ok = false;
346
+ try {
347
+ ok = JSON.parse(body).ok === true;
348
+ } catch {
349
+ ok = false;
350
+ }
351
+ if (ok) {
352
+ process.stdout.write(
353
+ `ui-inspector bridge 已在 http://${HOST}:${PORT} 运行,复用现有实例即可。\n`
354
+ );
355
+ process.exit(0);
356
+ } else {
357
+ hopNext(); // 被非 bridge 占用 → 换端口
358
+ }
359
+ })
360
+ );
361
+ });
362
+ req.on('error', () => settle(hopNext));
363
+ req.on('timeout', () => {
364
+ req.destroy();
365
+ settle(hopNext);
366
+ });
367
+ return;
368
+ }
369
+ hopNext();
281
370
  });
371
+
372
+ server.listen(currentPort, HOST);
package/cli.mjs CHANGED
@@ -119,6 +119,29 @@ function installNativeHost() {
119
119
  }
120
120
 
121
121
  // 后台拉起 bridge 并与本进程脱钩,install --remote 用:装完 hook/MCP 顺手把 bridge 跑起来。
122
+ // bridge 可能因端口被占而自动顺延端口,所以不能直接用传入的 port——读它落盘的 bridge.json 拿真正在用的端口。
123
+ function sleepSync(ms) {
124
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
125
+ }
126
+
127
+ // 轮询 ~/.ui-inspector/bridge.json,拿一条「本次启动之后写入」的记录(ts >= since)。读不到返回 null。
128
+ function readActualBridgePort(since, timeoutMs = 4000) {
129
+ const f = resolve(pendingDir, 'bridge.json');
130
+ const deadline = Date.now() + timeoutMs;
131
+ while (Date.now() < deadline) {
132
+ try {
133
+ const j = JSON.parse(readFileSync(f, 'utf8'));
134
+ if (j && j.port && (j.ts || 0) >= since) {
135
+ return j.port;
136
+ }
137
+ } catch {
138
+ // 文件还没写出来
139
+ }
140
+ sleepSync(150);
141
+ }
142
+ return null;
143
+ }
144
+
122
145
  function startBridgeDetached(port, host) {
123
146
  mkdirSync(pendingDir, {recursive: true});
124
147
  const logFile = resolve(pendingDir, 'bridge.log');
@@ -127,14 +150,21 @@ function startBridgeDetached(port, host) {
127
150
  if (host) {
128
151
  env.UI_INSPECTOR_BRIDGE_HOST = String(host);
129
152
  }
153
+ const spawnedAt = Date.now();
130
154
  const child = spawn(process.execPath, [resolve(pkgRoot, 'bridge/server.mjs')], {
131
155
  detached: true,
132
156
  stdio: ['ignore', fd, fd],
133
157
  env,
134
158
  });
135
159
  child.unref();
136
- out(`bridge 已后台启动(pid ${child.pid},监听 ${host || '127.0.0.1'}:${port}),日志:${logFile}`);
160
+ // 端口可能被占而自动顺延;读 bridge.json 拿真正在用的端口。复用现有实例时子进程不写新记录,回落到传入 port。
161
+ const actual = readActualBridgePort(spawnedAt) || port;
162
+ out(`bridge 已后台启动(pid ${child.pid},监听 ${host || '127.0.0.1'}:${actual}),日志:${logFile}`);
163
+ if (String(actual) !== String(port)) {
164
+ out(` 注意:端口 ${port} 被占用,已自动改用 ${actual}——扩展弹窗地址请填这个端口。`);
165
+ }
137
166
  out(` 停止:kill ${child.pid}`);
167
+ return actual;
138
168
  }
139
169
 
140
170
  function install({remote, port, host}) {
@@ -142,9 +172,9 @@ function install({remote, port, host}) {
142
172
  if (remote) {
143
173
  const bindHost = host || '0.0.0.0';
144
174
  out('\n== 远程开发机:跳过 native host/扩展(这台没浏览器),后台启动 bridge ==');
145
- startBridgeDetached(port, bindHost);
175
+ const actual = startBridgeDetached(port, bindHost);
146
176
  out(
147
- `\n浏览器在扩展弹窗里把传输设为「远程(HTTP bridge)」、地址填「本机可达地址:${port}」(如 devlocal.oreateai.com:${port})即可直连。`
177
+ `\n浏览器在扩展弹窗里把传输设为「远程(HTTP bridge)」、地址填「本机可达地址:${actual}」(如 devlocal.oreateai.com:${actual})即可直连。`
148
178
  );
149
179
  return;
150
180
  }
@@ -196,8 +196,9 @@
196
196
  ovBar.remove();
197
197
  ovBar = null;
198
198
  }
199
- document.removeEventListener('mousemove', ovDrag);
200
- document.removeEventListener('mouseup', ovDragEnd);
199
+ document.removeEventListener('pointermove', ovDrag);
200
+ document.removeEventListener('pointerup', ovDragEnd);
201
+ document.removeEventListener('pointercancel', ovDragEnd);
201
202
  }
202
203
 
203
204
  function ovApply() {
@@ -232,22 +233,30 @@
232
233
  ovImg = document.createElement('img');
233
234
  ovImg.src = data.dataUrl;
234
235
  ovImg.draggable = false;
235
- ovImg.style.cssText = 'display:block;cursor:move;box-shadow:0 0 0 1px rgba(255,0,0,0.35);';
236
+ // touch-action:none —— 否则移动端/模拟器会把触摸当成页面滚动吞掉,pointermove 不触发。
237
+ ovImg.style.cssText = 'display:block;cursor:move;touch-action:none;box-shadow:0 0 0 1px rgba(255,0,0,0.35);';
236
238
  ovImg.addEventListener('load', function () {
237
239
  if (!ovs.baseW) {
238
240
  ovs.baseW = ovImg.naturalWidth || 0;
239
241
  }
240
242
  ovApply();
241
243
  });
242
- ovImg.addEventListener('mousedown', function (e) {
244
+ // 用 Pointer Events 统一鼠标 + 触摸(含 DevTools 移动端模拟器把鼠标转成 touch 的情况)。
245
+ ovImg.addEventListener('pointerdown', function (e) {
243
246
  if (ovs.through) {
244
247
  return;
245
248
  }
246
249
  dragFrom = {dx: e.clientX - ovs.x, dy: e.clientY - ovs.y};
250
+ try {
251
+ ovImg.setPointerCapture(e.pointerId);
252
+ } catch (err) {
253
+ // 某些环境不支持指针捕获,忽略
254
+ }
247
255
  e.preventDefault();
248
256
  });
249
- document.addEventListener('mousemove', ovDrag);
250
- document.addEventListener('mouseup', ovDragEnd);
257
+ document.addEventListener('pointermove', ovDrag);
258
+ document.addEventListener('pointerup', ovDragEnd);
259
+ document.addEventListener('pointercancel', ovDragEnd);
251
260
  ov.appendChild(ovImg);
252
261
  document.documentElement.appendChild(ov);
253
262
  ovToolbar();
@@ -39,6 +39,17 @@
39
39
  panelHead.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;';
40
40
  var panelCount = document.createElement('span');
41
41
  panelCount.style.cssText = 'font-weight:600;';
42
+ // 右侧按钮组:收起/展开 + 清空。
43
+ var panelBtns = document.createElement('div');
44
+ panelBtns.style.cssText = 'display:flex;gap:6px;flex:none;';
45
+ var panelToggle = document.createElement('button');
46
+ panelToggle.textContent = '收起';
47
+ panelToggle.style.cssText =
48
+ 'border:none;background:#2f2f2f;color:#fff;border-radius:4px;padding:2px 8px;cursor:pointer;font:inherit;';
49
+ panelToggle.onclick = function () {
50
+ panelCollapsed = !panelCollapsed;
51
+ renderPanel();
52
+ };
42
53
  var panelClear = document.createElement('button');
43
54
  panelClear.textContent = '清空';
44
55
  panelClear.style.cssText =
@@ -49,8 +60,10 @@
49
60
  renderPanel();
50
61
  showHint('已清空全部选中');
51
62
  };
63
+ panelBtns.appendChild(panelToggle);
64
+ panelBtns.appendChild(panelClear);
52
65
  panelHead.appendChild(panelCount);
53
- panelHead.appendChild(panelClear);
66
+ panelHead.appendChild(panelBtns);
54
67
  var panelList = document.createElement('div');
55
68
  panel.appendChild(panelHead);
56
69
  panel.appendChild(panelList);
@@ -81,6 +94,7 @@
81
94
  // DOM 树上下级导航状态:navPath 从叶子往上累积,navIdx 当前停在哪一层(0 = 叶子)。
82
95
  var navPath = [];
83
96
  var navIdx = 0;
97
+ var panelCollapsed = false; // 侧边栏收起态:仅隐藏列表,保留标题与计数
84
98
 
85
99
  function renderPanel() {
86
100
  if (!selected.length) {
@@ -89,6 +103,8 @@
89
103
  }
90
104
  panel.style.display = 'block';
91
105
  panelCount.textContent = '已选中 ' + selected.length + ' 个';
106
+ panelToggle.textContent = panelCollapsed ? '展开' : '收起';
107
+ panelList.style.display = panelCollapsed ? 'none' : 'block';
92
108
  panelList.textContent = '';
93
109
  renumber();
94
110
  selected.forEach(function (item, i) {
@@ -225,15 +225,13 @@
225
225
  <input
226
226
  id="bridge-target"
227
227
  type="text"
228
- placeholder="devlocal.oreateai.com:8917"
228
+ placeholder="<remote-ip>:<port>"
229
229
  />
230
230
  </div>
231
231
  <div class="hint">
232
232
  填开发机 bridge 的完整地址(如
233
- <code>devlocal.oreateai.com:8917</code>
234
- )。开发机上
235
- <code>ui-inspector install --remote</code>
236
- 起 bridge(默认绑 0.0.0.0 供直连)。
233
+ <code><\remote-ip>:<\port></code>
234
+ )
237
235
  </div>
238
236
  </div>
239
237
  </section>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comate-ui-inspector",
3
- "version": "0.1.6-alpha.3",
3
+ "version": "0.1.6-alpha.4",
4
4
  "description": "按住 Alt 点选页面元素,把「元素 + Vue 组件源码路径」交给 Comate 定位源码修改。浏览器扩展 + Native Messaging host + MCP + hook。",
5
5
  "type": "module",
6
6
  "license": "MIT",