comate-ui-inspector 0.1.2-alpha.1 → 0.1.2-alpha.5

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 CHANGED
@@ -6,9 +6,10 @@
6
6
 
7
7
  - `extension/`:Chrome/Edge MV3 扩展(`manifest.json` + MAIN/ISOLATED 内容脚本 + background SW)。
8
8
  - `extension/native-host/host.mjs`:Native Messaging host(浏览器按需拉起,零依赖)。
9
- - `mcp/server.mjs`:MCP stdio server,`clear_pending_edits` = 取消选中。
9
+ - `mcp/server.mjs`:MCP stdio server,`clear_pending_edits` = 取消选中,`notify_browser` = 推送页面 toast。
10
+ - `bridge/server.mjs`:HTTP bridge(远程开发用),绑 127.0.0.1,`/ingest` `/state`,读写同一份 `~/.ui-inspector/*.json`。
10
11
  - `hooks/inject-ui-inspector.mjs`:Comate `UserPromptSubmit` hook,提交时自动注入全部选中元素。
11
- - `cli.mjs`:命令入口,提供 `install` / `uninstall` / `status`(发布为 npm 包后暴露为 `ui-inspector` 命令)。
12
+ - `cli.mjs`:命令入口,提供 `install` / `uninstall` / `status` / `bridge`(发布为 npm 包后暴露为 `ui-inspector` 命令)。
12
13
 
13
14
  数据落在 `~/.ui-inspector/pending.json`(用户主目录、一台机器一份)。hook 注册为**个人级**(`~/.comate/hooks.json`,对所有项目生效),所以**整机只装一次、所有仓库通用**,命令在哪个目录跑都行。
14
15
 
@@ -23,6 +24,39 @@ ui-inspector install # 注册 native host + 个人级 hook,并打印
23
24
 
24
25
  1. 把打印的 **MCP 配置**贴进 Comate 的 MCP 设置,**reload Comate**。
25
26
  2. `chrome://extensions` → 开发者模式 →「加载已解压的扩展程序」→ 选打印出来的 **扩展目录**(只需一次)。
27
+ 3. 点扩展图标打开弹窗,**配置生效域名与传输通路**(见下)。
28
+
29
+ ## 配置:域名与传输通路(扩展弹窗)
30
+
31
+ 目标域名和传输方式不写死在代码里,装好扩展后点图标在弹窗里配:
32
+
33
+ - **生效域名**:默认已含 `localhost` / `127.0.0.1`;项目域名(如 `https://local.oreateai.com`)自己加,添加时浏览器会弹权限申请,允许后即在该域名注入选取脚本。
34
+ - **传输通路**:
35
+ - `本地(Native Messaging)`——默认,浏览器按需拉起 `host.mjs`,零常驻。
36
+ - `远程(HTTP bridge)`——填 bridge 地址(默认 `http://127.0.0.1:8917`),用于远程开发(见下)。
37
+
38
+ ## 远程开发(HTTP bridge)
39
+
40
+ 浏览器在本地、代码与 Comate 在远程开发机时,Native Messaging 跨不了机,改用 HTTP bridge。流程精简为两条命令:
41
+
42
+ ```bash
43
+ # 远程机上(和 Comate hook/MCP 同机):装 hook/MCP 并后台起 bridge
44
+ ui-inspector install --remote # --port 默认 8917;跳过无浏览器的 native host
45
+ # 本地笔记本:把 bridge 端口 + dev server 端口一起转发到本地 127.0.0.1
46
+ ui-inspector link <remote-ssh-目标> --port <dev端口>
47
+ ```
48
+
49
+ `install --remote` 会打印 MCP 配置(贴进**远程**的 Comate 并 reload),并把 bridge 后台拉起(日志在 `~/.ui-inspector/bridge.log`)。`link` 本质是 `ssh -N -L 8917:127.0.0.1:8917 -L <dev端口>:127.0.0.1:<dev端口> <remote>`,保持前台、Ctrl-C 断开。
50
+
51
+ 链路断了(ssh 掉线 / 远程 bridge 或 dev server 停了)时,先在本地自检:
52
+
53
+ ```bash
54
+ ui-inspector doctor <remote> --port <dev端口> # 定位断在哪层并打印对应恢复命令
55
+ ```
56
+
57
+ 恢复顺序:先确保远程 bridge 活(`ui-inspector bridge` / `install --remote`)→ 再接本地隧道(`ui-inspector link`)→ 再看远程 dev server。
58
+
59
+ 本地仍需一次:加载扩展(`chrome://extensions` 选 `extension` 目录),在弹窗把传输切到 `远程(HTTP bridge)`、加上你的 dev 域名。bridge 只绑 127.0.0.1、不暴露到网络,访问全程走 SSH 转发,所以 https 页面连 `127.0.0.1` 不触发混合内容拦截。
26
60
 
27
61
  ## 卸载 / 状态
28
62
 
@@ -49,7 +83,7 @@ npm publish # 发到公网 npm(package.json 的 publishConfig 已
49
83
 
50
84
  ## 说明 / 限制
51
85
 
52
- - 扩展默认只在 `local.oreateai.com` / `localhost` / `127.0.0.1` 生效;别的开发域名改 `extension/manifest.json` 的 `content_scripts[].matches`。
86
+ - 扩展生效域名在**弹窗**里配(默认 `localhost` / `127.0.0.1`,项目域名自行添加);不再写死在 `manifest.json`,改用 `chrome.scripting` 按配置动态注册。
53
87
  - 只有 **dev 构建** 拿得到组件源码路径(Vue dev 的 `__file`);生产构建组件链为空,退回选择器/文本定位。
54
88
  - **Windows 暂不支持**(native host 需 `.bat` 包装器)。
55
89
  - 扩展 ID 用 `manifest.json` 的公钥 `key` 钉死,所有人加载后一致;私钥未使用、未入库。
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * ui-inspector HTTP bridge(零依赖)——远程开发用。
4
+ *
5
+ * 跑在「Comate agent / hook / MCP 所在的那台机器」(通常是远程开发机),读写和它们同一份
6
+ * ~/.ui-inspector/*.json。本地浏览器扩展(transport=http)经 SSH 端口转发访问本服务:
7
+ * POST /ingest {targets:[...]} → 覆盖写 pending.json,回 {ok,pending}
8
+ * GET /state → 回 {ok,count,status}
9
+ *
10
+ * 安全:只绑 127.0.0.1,不暴露到网络;远程访问一律走 `ssh -L` 把端口转发到本地 127.0.0.1。
11
+ */
12
+ import {createServer} from 'node:http';
13
+ import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
14
+ import {dirname, resolve} from 'node:path';
15
+ import {homedir} from 'node:os';
16
+
17
+ const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
18
+ const statusFile = resolve(homedir(), '.ui-inspector/status.json');
19
+ const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
20
+
21
+ function readStore() {
22
+ try {
23
+ const parsed = JSON.parse(readFileSync(storeFile, 'utf8'));
24
+ return Array.isArray(parsed) ? parsed : [];
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ function readStatus() {
31
+ try {
32
+ return JSON.parse(readFileSync(statusFile, 'utf8'));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function writeStore(list) {
39
+ mkdirSync(dirname(storeFile), {recursive: true});
40
+ writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
41
+ }
42
+
43
+ function cors(res) {
44
+ res.setHeader('Access-Control-Allow-Origin', '*');
45
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
46
+ res.setHeader('Access-Control-Allow-Headers', 'content-type');
47
+ }
48
+
49
+ function json(res, code, obj) {
50
+ cors(res);
51
+ res.writeHead(code, {'content-type': 'application/json'});
52
+ res.end(JSON.stringify(obj));
53
+ }
54
+
55
+ const server = createServer((req, res) => {
56
+ if (req.method === 'OPTIONS') {
57
+ cors(res);
58
+ res.writeHead(204);
59
+ res.end();
60
+ return;
61
+ }
62
+ const url = new URL(req.url, 'http://127.0.0.1');
63
+ if (req.method === 'POST' && url.pathname === '/ingest') {
64
+ let body = '';
65
+ req.on('data', chunk => (body += chunk));
66
+ req.on('end', () => {
67
+ try {
68
+ const {targets} = JSON.parse(body || '{}');
69
+ const now = new Date().toISOString();
70
+ const list = (Array.isArray(targets) ? targets : []).map(target => ({target, receivedAt: now}));
71
+ writeStore(list);
72
+ json(res, 200, {ok: true, pending: list.length});
73
+ } catch (error) {
74
+ json(res, 400, {ok: false, error: String(error)});
75
+ }
76
+ });
77
+ return;
78
+ }
79
+ if (req.method === 'GET' && url.pathname === '/state') {
80
+ json(res, 200, {ok: true, count: readStore().length, status: readStatus()});
81
+ return;
82
+ }
83
+ json(res, 404, {ok: false, error: 'not found'});
84
+ });
85
+
86
+ server.listen(PORT, '127.0.0.1', () => {
87
+ process.stdout.write(`ui-inspector bridge listening on http://127.0.0.1:${PORT}\n`);
88
+ });
package/cli.mjs CHANGED
@@ -3,15 +3,22 @@
3
3
  * ui-inspector CLI。发布到 npm 后全局安装即可获得 `ui-inspector` 命令:
4
4
  * npm i -g comate-ui-inspector
5
5
  * ui-inspector install # 注册 native host + 写个人级 hook + 打印 MCP 配置与扩展目录
6
+ * ui-inspector install --remote # 远程开发机:装 hook/MCP 并后台起 bridge(跳过无浏览器的 native host)
7
+ * ui-inspector link <remote> --port <devport> # 本地:SSH 把 bridge 端口 + dev 端口转发到本地 127.0.0.1
8
+ * ui-inspector doctor <remote> --port <devport> # 本地:自检远程链路断在哪层并打印恢复命令
6
9
  * ui-inspector uninstall # 移除 native host 清单 + 个人级 hook(--purge 连 ~/.ui-inspector 一起删)
7
10
  * ui-inspector status # 查看安装状态
11
+ * ui-inspector bridge # 启动 HTTP bridge(远程开发用,--port 默认 8917)
8
12
  *
9
13
  * 全部按「整机一份、个人级」接入,不绑定任何仓库。仅 macOS / Linux。
14
+ * 目标域名与传输通路(native/http)在扩展弹窗里配置,不写死在代码库。
10
15
  */
11
- import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync} from 'node:fs';
16
+ import {chmodSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, rmSync} from 'node:fs';
12
17
  import {dirname, join, resolve} from 'node:path';
13
18
  import {fileURLToPath} from 'node:url';
14
19
  import {homedir, platform} from 'node:os';
20
+ import {spawn} from 'node:child_process';
21
+ import http from 'node:http';
15
22
 
16
23
  const HOST_NAME = 'com.oreate.ui_inspector';
17
24
  const EXT_ID = 'akpiagdjfeeaioifjepbmonpallececo';
@@ -53,7 +60,25 @@ function readHooks() {
53
60
  function out(text) {
54
61
  process.stdout.write(`${text}\n`);
55
62
  }
56
- function install() {
63
+ function installHookAndMcp() {
64
+ const hookCommand = `node "${hookPath}"`;
65
+ const config = readHooks() || {hooks: {}};
66
+ config.hooks = config.hooks || {};
67
+ const list = Array.isArray(config.hooks.UserPromptSubmit) ? config.hooks.UserPromptSubmit : [];
68
+ if (!JSON.stringify(list).includes('inject-ui-inspector')) {
69
+ list.push({hooks: [{type: 'command', command: hookCommand, timeout: 10}]});
70
+ config.hooks.UserPromptSubmit = list;
71
+ mkdirSync(dirname(hooksFile), {recursive: true});
72
+ writeFileSync(hooksFile, `${JSON.stringify(config, null, 4)}\n`);
73
+ out(`hook installed -> ${hooksFile}`);
74
+ } else {
75
+ out('hook already present, skip');
76
+ }
77
+ out('\n== 把下面加进 Comate 的 MCP 配置(stdio),然后 reload Comate ==');
78
+ out(JSON.stringify({mcpServers: {'ui-inspector': {command: 'node', args: [serverPath]}}}, null, 4));
79
+ }
80
+
81
+ function installNativeHost() {
57
82
  chmodSync(hostPath, 0o755);
58
83
  // 用壳脚本硬编码 node 绝对路径:Chrome 拉起 native host 时 PATH 极简(不含 nvm 等目录),
59
84
  // 直接 `#!/usr/bin/env node` 会找不到 node 导致 host 起不来;壳脚本规避这点。
@@ -76,26 +101,63 @@ function install() {
76
101
  out(`skip ${dir}: ${String(error)}`);
77
102
  }
78
103
  }
104
+ }
79
105
 
80
- const hookCommand = `node "${hookPath}"`;
81
- const config = readHooks() || {hooks: {}};
82
- config.hooks = config.hooks || {};
83
- const list = Array.isArray(config.hooks.UserPromptSubmit) ? config.hooks.UserPromptSubmit : [];
84
- if (!JSON.stringify(list).includes('inject-ui-inspector')) {
85
- list.push({hooks: [{type: 'command', command: hookCommand, timeout: 10}]});
86
- config.hooks.UserPromptSubmit = list;
87
- mkdirSync(dirname(hooksFile), {recursive: true});
88
- writeFileSync(hooksFile, `${JSON.stringify(config, null, 4)}\n`);
89
- out(`hook installed -> ${hooksFile}`);
90
- } else {
91
- out('hook already present, skip');
92
- }
106
+ // 后台拉起 bridge 并与本进程脱钩,install --remote 用:装完 hook/MCP 顺手把 bridge 跑起来。
107
+ function startBridgeDetached(port) {
108
+ mkdirSync(pendingDir, {recursive: true});
109
+ const logFile = resolve(pendingDir, 'bridge.log');
110
+ const fd = openSync(logFile, 'a');
111
+ const child = spawn(process.execPath, [resolve(pkgRoot, 'bridge/server.mjs')], {
112
+ detached: true,
113
+ stdio: ['ignore', fd, fd],
114
+ env: {...process.env, UI_INSPECTOR_BRIDGE_PORT: String(port)},
115
+ });
116
+ child.unref();
117
+ out(`bridge 已后台启动(pid ${child.pid},端口 ${port}),日志:${logFile}`);
118
+ out(` 停止:kill ${child.pid}`);
119
+ }
93
120
 
94
- out('\n== 把下面加进 Comate 的 MCP 配置(stdio),然后 reload Comate ==');
95
- out(JSON.stringify({mcpServers: {'ui-inspector': {command: 'node', args: [serverPath]}}}, null, 4));
121
+ function install({remote, port}) {
122
+ installHookAndMcp();
123
+ if (remote) {
124
+ out('\n== 远程开发机:跳过 native host/扩展(这台没浏览器),后台启动 bridge ==');
125
+ startBridgeDetached(port);
126
+ out(`\n本地笔记本上执行:ui-inspector link <这台机器的 ssh 目标> --port <dev端口>`);
127
+ return;
128
+ }
129
+ installNativeHost();
96
130
  out('\n== 加载浏览器扩展(chrome://extensions → 开发者模式 → 加载已解压 → 选下面这个目录) ==');
97
131
  out(extensionDir);
98
- out('\n完成后:按住 Alt 点元素多选 → 回 Comate 写修改意见。');
132
+ out('\n加载后点扩展图标打开弹窗,配置「生效域名」(如 https://local.oreateai.com)与「传输通路」。');
133
+ out('完成后:按住 Alt 点元素多选 → 回 Comate 写修改意见。');
134
+ }
135
+
136
+ // 本地笔记本:把远程 bridge 端口 + dev server 端口都转发到本地 127.0.0.1。
137
+ function link({remote, devPort, bridgePort}) {
138
+ if (!remote) {
139
+ out('用法:ui-inspector link <remote-ssh-目标> --port <dev端口> [--bridge-port 8917]');
140
+ out('例: ui-inspector link dev-box --port 5173');
141
+ return;
142
+ }
143
+ const args = ['-N', '-L', `${bridgePort}:127.0.0.1:${bridgePort}`];
144
+ if (devPort) {
145
+ args.push('-L', `${devPort}:127.0.0.1:${devPort}`);
146
+ }
147
+ args.push(remote);
148
+ out(`转发中(Ctrl-C 断开):ssh ${args.join(' ')}`);
149
+ out(` bridge → 127.0.0.1:${bridgePort}${devPort ? `,dev → 127.0.0.1:${devPort}` : ''}`);
150
+ const child = spawn('ssh', args, {stdio: 'inherit'});
151
+ child.on('exit', code => process.exit(code ?? 0));
152
+ }
153
+
154
+ function bridge() {
155
+ const i = process.argv.indexOf('--port');
156
+ const port = i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : '8917';
157
+ process.env.UI_INSPECTOR_BRIDGE_PORT = String(port);
158
+ out(`启动 HTTP bridge(远程开发传输):127.0.0.1:${port}`);
159
+ out('本地浏览器经 SSH 端口转发访问:ssh -L ' + port + ':127.0.0.1:' + port + ' <remote>');
160
+ import(resolve(pkgRoot, 'bridge/server.mjs'));
99
161
  }
100
162
 
101
163
  function uninstall({purge}) {
@@ -147,20 +209,105 @@ function status() {
147
209
  out(` 扩展目录:${extensionDir}`);
148
210
  }
149
211
 
212
+ function getOpt(name, def) {
213
+ const i = process.argv.indexOf(name);
214
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : def;
215
+ }
216
+
217
+ function parseLink() {
218
+ const rest = process.argv.slice(3);
219
+ let remote;
220
+ let devPort;
221
+ let bridgePort = '8917';
222
+ for (let i = 0; i < rest.length; i++) {
223
+ const a = rest[i];
224
+ if (a === '--port') {
225
+ devPort = rest[++i];
226
+ } else if (a === '--bridge-port') {
227
+ bridgePort = rest[++i];
228
+ } else if (!a.startsWith('-')) {
229
+ remote = a;
230
+ }
231
+ }
232
+ return {remote, devPort, bridgePort};
233
+ }
234
+
235
+ // 探测本地某端口的 http 可达性(2s 超时)。用 node:http 而非 fetch,兼容 node 18+。
236
+ function probe(port, path) {
237
+ return new Promise(resolve => {
238
+ const req = http.get({host: '127.0.0.1', port: Number(port), path, timeout: 2000}, res => {
239
+ let body = '';
240
+ res.on('data', chunk => (body += chunk));
241
+ res.on('end', () => resolve({state: 'ok', code: res.statusCode, body}));
242
+ });
243
+ req.on('timeout', () => {
244
+ req.destroy();
245
+ resolve({state: 'timeout'});
246
+ });
247
+ req.on('error', err => resolve({state: err.code === 'ECONNREFUSED' ? 'refused' : 'error', err: String(err)}));
248
+ });
249
+ }
250
+
251
+ // 一键自检:定位远程开发链路(本地隧道 → 远程 bridge → 远程 dev server)断在哪层,并打印恢复命令。
252
+ async function doctor() {
253
+ const {remote, devPort, bridgePort} = parseLink();
254
+ const rm = remote || '<remote>';
255
+ const dp = devPort || '<dev端口>';
256
+ out('ui-inspector doctor(远程开发链路自检):');
257
+
258
+ const b = await probe(bridgePort, '/state');
259
+ if (b.state === 'ok') {
260
+ out(` [1] bridge ✓ 127.0.0.1:${bridgePort} 可达——本地→隧道→远程 bridge 全通`);
261
+ } else if (b.state === 'refused') {
262
+ out(` [1] bridge ✗ 本地 127.0.0.1:${bridgePort} 无人监听 → SSH 隧道断了(或没起)`);
263
+ out(` 恢复:ui-inspector link ${rm} --port ${dp}`);
264
+ } else {
265
+ out(` [1] bridge ✗ 隧道在、但远程 bridge 无响应(${b.state})`);
266
+ out(` 恢复:登远程机跑 ui-inspector bridge(或 install --remote),看 ~/.ui-inspector/bridge.log`);
267
+ }
268
+
269
+ if (devPort) {
270
+ const d = await probe(devPort, '/');
271
+ if (d.state === 'refused') {
272
+ out(` [2] dev ✗ 本地 127.0.0.1:${devPort} 不可达 → 隧道没转发该端口,或远程 dev server 没起`);
273
+ out(` 恢复:① 远程起 dev server;② 本地 ui-inspector link ${rm} --port ${devPort}`);
274
+ } else if (d.state === 'timeout') {
275
+ out(` [2] dev ✗ 127.0.0.1:${devPort} 超时 → 远程 dev server 可能挂了`);
276
+ out(` 恢复:远程重启 dev server`);
277
+ } else {
278
+ out(` [2] dev ✓ 127.0.0.1:${devPort} 可达(HTTP ${d.code})`);
279
+ }
280
+ } else {
281
+ out(' [2] dev - 未传 --port,跳过 dev server 检查');
282
+ }
283
+
284
+ out('\n恢复顺序:先确保远程 bridge 活 → 再接本地隧道(link)→ 再看远程 dev server。');
285
+ }
286
+
150
287
  function help() {
151
- out('用法:ui-inspector <install|uninstall|status>');
152
- out(' install 注册 native host + 个人级 hook,并打印 MCP 配置与扩展目录');
153
- out(' uninstall 移除上述注册(加 --purge 连 ~/.ui-inspector 数据一起删)');
154
- out(' status 查看安装状态');
288
+ out('用法:ui-inspector <install|uninstall|status|bridge|link|doctor>');
289
+ out(' install 注册 native host + 个人级 hook,并打印 MCP 配置与扩展目录');
290
+ out(' install --remote 远程开发机:装 hook/MCP 并后台起 bridge(跳过 native host,--port 默认 8917)');
291
+ out(' link <remote> 本地:SSH 把 bridge 端口 + dev 端口转发到本地(--port <dev端口> [--bridge-port 8917])');
292
+ out(' doctor <remote> 本地:自检远程开发链路断在哪层并打印恢复命令(--port <dev端口> [--bridge-port 8917])');
293
+ out(' uninstall 移除上述注册(加 --purge 连 ~/.ui-inspector 数据一起删)');
294
+ out(' status 查看安装状态');
295
+ out(' bridge 启动 HTTP bridge(远程开发用,--port 默认 8917)');
155
296
  }
156
297
 
157
298
  const cmd = process.argv[2] || 'help';
158
299
  if (cmd === 'install') {
159
- install();
300
+ install({remote: process.argv.includes('--remote'), port: getOpt('--port', '8917')});
301
+ } else if (cmd === 'link') {
302
+ link(parseLink());
303
+ } else if (cmd === 'doctor') {
304
+ doctor();
160
305
  } else if (cmd === 'uninstall') {
161
306
  uninstall({purge: process.argv.includes('--purge')});
162
307
  } else if (cmd === 'status') {
163
308
  status();
309
+ } else if (cmd === 'bridge') {
310
+ bridge();
164
311
  } else {
165
312
  help();
166
313
  }
@@ -1,36 +1,139 @@
1
1
  /*
2
2
  * ui-inspector 扩展的 background service worker。
3
3
  *
4
- * 改用 Native Messaging:不再 fetch 本地 HTTP(免去混合内容问题与手动起 server),
5
- * 由 chrome.runtime.sendNativeMessage 触发浏览器**按需拉起**本地 node host(host.mjs),
6
- * host 读写 .ui-inspector/pending.json 后退出。每次请求一问一答。
4
+ * 两件事:
5
+ * 1) 传输适配:把内容脚本的 ingest/state 转发给本地后端。
6
+ * - native(默认,本地开发):chrome.runtime.sendNativeMessage 按需拉起 host.mjs。
7
+ * - http(远程开发):fetch 配置的 bridgeUrl(/ingest、/state),远程机上跑 bridge/server.mjs。
8
+ * 2) 动态注册内容脚本:目标域名不再硬编码在 manifest,改由弹窗(popup)写入 storage,
9
+ * 这里按配置用 chrome.scripting 注册(MAIN + ISOLATED 两份)。
7
10
  */
8
11
  const HOST = 'com.oreate.ui_inspector';
9
12
 
10
- chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
11
- if (!msg || typeof msg.type !== 'string') {
12
- return;
13
- }
13
+ // localhost / 127.0.0.1 是通用开发地址,作默认种子;项目专属域名(如 local.oreateai.com)在弹窗里加。
14
+ const DEFAULTS = {
15
+ domains: ['http://localhost/*', 'http://127.0.0.1/*'],
16
+ transport: 'native',
17
+ bridgeUrl: 'http://127.0.0.1:8917',
18
+ };
14
19
 
15
- if (msg.type === 'ingest') {
16
- chrome.runtime.sendNativeMessage(HOST, {type: 'ingest', targets: msg.targets || []}, resp => {
20
+ function getConfig() {
21
+ return chrome.storage.local.get(DEFAULTS);
22
+ }
23
+
24
+ // ---------- 传输 ----------
25
+ function viaNative(message) {
26
+ return new Promise(resolve => {
27
+ chrome.runtime.sendNativeMessage(HOST, message, resp => {
17
28
  if (chrome.runtime.lastError) {
18
- sendResponse({ok: false, error: chrome.runtime.lastError.message});
29
+ resolve({ok: false, error: chrome.runtime.lastError.message});
19
30
  return;
20
31
  }
21
- sendResponse(resp);
32
+ resolve(resp);
22
33
  });
34
+ });
35
+ }
36
+
37
+ async function viaHttp(bridgeUrl, message) {
38
+ const base = String(bridgeUrl || '').replace(/\/+$/, '');
39
+ try {
40
+ if (message.type === 'ingest') {
41
+ const r = await fetch(`${base}/ingest`, {
42
+ method: 'POST',
43
+ headers: {'content-type': 'application/json'},
44
+ body: JSON.stringify({targets: message.targets || []}),
45
+ });
46
+ return await r.json();
47
+ }
48
+ const r = await fetch(`${base}/state`);
49
+ return await r.json();
50
+ } catch (error) {
51
+ return {ok: false, error: String(error)};
52
+ }
53
+ }
54
+
55
+ async function dispatch(message) {
56
+ const cfg = await getConfig();
57
+ return cfg.transport === 'http' ? viaHttp(cfg.bridgeUrl, message) : viaNative(message);
58
+ }
59
+
60
+ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
61
+ if (!msg || typeof msg.type !== 'string') {
62
+ return;
63
+ }
64
+ if (msg.type === 'ingest' || msg.type === 'state') {
65
+ dispatch(msg).then(sendResponse);
23
66
  return true; // 异步 sendResponse
24
67
  }
68
+ if (msg.type === 'sync-scripts') {
69
+ syncScripts().then(() => sendResponse({ok: true}));
70
+ return true;
71
+ }
72
+ });
25
73
 
26
- if (msg.type === 'state') {
27
- chrome.runtime.sendNativeMessage(HOST, {type: 'state'}, resp => {
28
- if (chrome.runtime.lastError) {
29
- sendResponse({ok: false, error: chrome.runtime.lastError.message});
30
- return;
74
+ // ---------- 动态内容脚本注册 ----------
75
+ const SCRIPTS = [
76
+ {id: 'ui-inspector-main', js: ['inspector-main.js'], world: 'MAIN'},
77
+ {id: 'ui-inspector-bridge', js: ['bridge-content.js']},
78
+ ];
79
+
80
+ async function syncScripts() {
81
+ const {domains} = await getConfig();
82
+ // 只注册已授予主机权限的域名;未授权的等弹窗 request 到权限后再同步。
83
+ const allowed = [];
84
+ for (const match of domains || []) {
85
+ try {
86
+ if (await chrome.permissions.contains({origins: [match]})) {
87
+ allowed.push(match);
31
88
  }
32
- sendResponse(resp); // {ok, count}
33
- });
34
- return true;
89
+ } catch {
90
+ // 非法 match 模式,跳过
91
+ }
92
+ }
93
+ try {
94
+ const existing = await chrome.scripting.getRegisteredContentScripts({ids: SCRIPTS.map(s => s.id)});
95
+ if (existing.length) {
96
+ await chrome.scripting.unregisterContentScripts({ids: existing.map(s => s.id)});
97
+ }
98
+ } catch {
99
+ // 没注册过,忽略
100
+ }
101
+ if (!allowed.length) {
102
+ return;
103
+ }
104
+ await chrome.scripting.registerContentScripts(
105
+ SCRIPTS.map(s => ({
106
+ id: s.id,
107
+ js: s.js,
108
+ matches: allowed,
109
+ world: s.world,
110
+ runAt: 'document_idle',
111
+ }))
112
+ );
113
+ }
114
+
115
+ async function ensureDefaults() {
116
+ const stored = await chrome.storage.local.get(Object.keys(DEFAULTS));
117
+ const patch = {};
118
+ for (const key of Object.keys(DEFAULTS)) {
119
+ if (stored[key] === undefined) {
120
+ patch[key] = DEFAULTS[key];
121
+ }
122
+ }
123
+ if (Object.keys(patch).length) {
124
+ await chrome.storage.local.set(patch);
125
+ }
126
+ }
127
+
128
+ chrome.runtime.onInstalled.addListener(async () => {
129
+ await ensureDefaults();
130
+ await syncScripts();
131
+ });
132
+ chrome.runtime.onStartup.addListener(() => {
133
+ syncScripts();
134
+ });
135
+ chrome.storage.onChanged.addListener((changes, area) => {
136
+ if (area === 'local' && changes.domains) {
137
+ syncScripts();
35
138
  }
36
139
  });
@@ -1,24 +1,18 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "ui-inspector bridge",
4
- "version": "0.0.1",
5
- "description": "按住 Alt 点元素多选,经 Native Messaging 交给本地 host,供 Comate 定位源码修改。仅开发用。",
4
+ "version": "0.1.0",
5
+ "description": "按住 Alt 点元素多选,交给本地 host / bridge,供 Comate 定位源码修改。目标域名与传输通路在扩展弹窗里配置。仅开发用。",
6
+ "minimum_chrome_version": "111",
6
7
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArHbA4zi22qf48xgfUtkMM6vXEtWiTtmhlppNAMlfG/uFjujAK9EEGssy6674woq5W9ugl+FlVE4rf477m9drGnGjnvaaFc9U8xCVqUoe/+vTmRyDWAtSPyu2aulAXd9P78WCia5ROSpecian7b0pG/iPyqvgZsyJfYk1qC3VyEBOnOqVeWawawKE23OaxUZJaci4wB10xNrWBHBIAztL8xmRDSkkByImwNshtlzbo2pE17LHZGr35FMbQXdRBKWxsdKlT1t0j7ezK73bCFjsYLWoAXVSpK+eAjltGTqKtOTvNxV94TXwr/j0v7ikgz80XD4ffzXV0qDbQ8HCxRSZMwIDAQAB",
7
- "permissions": ["nativeMessaging"],
8
+ "permissions": ["nativeMessaging", "scripting", "storage"],
9
+ "host_permissions": ["http://localhost/*", "http://127.0.0.1/*"],
10
+ "optional_host_permissions": ["*://*/*"],
8
11
  "background": {
9
12
  "service_worker": "background.js"
10
13
  },
11
- "content_scripts": [
12
- {
13
- "matches": ["https://local.oreateai.com/*", "http://localhost/*", "http://127.0.0.1/*"],
14
- "js": ["inspector-main.js"],
15
- "world": "MAIN",
16
- "run_at": "document_idle"
17
- },
18
- {
19
- "matches": ["https://local.oreateai.com/*", "http://localhost/*", "http://127.0.0.1/*"],
20
- "js": ["bridge-content.js"],
21
- "run_at": "document_idle"
22
- }
23
- ]
14
+ "action": {
15
+ "default_popup": "popup.html",
16
+ "default_title": "ui-inspector 配置"
17
+ }
24
18
  }
@@ -0,0 +1,169 @@
1
+ <!doctype html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta
6
+ name="viewport"
7
+ content="width=device-width, initial-scale=1"
8
+ />
9
+ <style>
10
+ body {
11
+ width: 320px;
12
+ margin: 0;
13
+ padding: 14px;
14
+ font:
15
+ 13px/1.5 -apple-system,
16
+ BlinkMacSystemFont,
17
+ 'Segoe UI',
18
+ sans-serif;
19
+ color: #1f2328;
20
+ }
21
+ h1 {
22
+ font-size: 13px;
23
+ margin: 0 0 10px;
24
+ }
25
+ section {
26
+ margin-bottom: 14px;
27
+ }
28
+ .label {
29
+ font-weight: 600;
30
+ margin-bottom: 6px;
31
+ }
32
+ .row {
33
+ display: flex;
34
+ gap: 6px;
35
+ align-items: center;
36
+ }
37
+ input[type='text'] {
38
+ flex: 1;
39
+ min-width: 0;
40
+ padding: 5px 8px;
41
+ border: 1px solid #d0d7de;
42
+ border-radius: 6px;
43
+ font: inherit;
44
+ }
45
+ button {
46
+ padding: 5px 10px;
47
+ border: 1px solid #d0d7de;
48
+ border-radius: 6px;
49
+ background: #f6f8fa;
50
+ cursor: pointer;
51
+ font: inherit;
52
+ }
53
+ button.primary {
54
+ background: #1f6feb;
55
+ color: #fff;
56
+ border-color: #1f6feb;
57
+ }
58
+ ul {
59
+ list-style: none;
60
+ margin: 8px 0 0;
61
+ padding: 0;
62
+ }
63
+ li {
64
+ display: flex;
65
+ justify-content: space-between;
66
+ align-items: center;
67
+ padding: 4px 0;
68
+ border-top: 1px solid #f0f1f3;
69
+ }
70
+ li code {
71
+ font-size: 12px;
72
+ word-break: break-all;
73
+ }
74
+ .x {
75
+ border: none;
76
+ background: none;
77
+ color: #cf222e;
78
+ cursor: pointer;
79
+ padding: 0 4px;
80
+ }
81
+ label.radio {
82
+ display: flex;
83
+ gap: 6px;
84
+ align-items: center;
85
+ margin: 2px 0;
86
+ font-weight: 400;
87
+ }
88
+ .hint {
89
+ color: #656d76;
90
+ font-size: 12px;
91
+ margin-top: 4px;
92
+ }
93
+ #msg {
94
+ min-height: 16px;
95
+ color: #1a7f37;
96
+ font-size: 12px;
97
+ }
98
+ </style>
99
+ </head>
100
+ <body>
101
+ <h1>ui-inspector 配置</h1>
102
+
103
+ <section>
104
+ <div class="label">生效域名</div>
105
+ <div class="row">
106
+ <input
107
+ id="domain-input"
108
+ type="text"
109
+ placeholder="如 https://local.oreateai.com"
110
+ />
111
+ <button
112
+ id="add-domain"
113
+ class="primary"
114
+ >
115
+ 添加
116
+ </button>
117
+ </div>
118
+ <ul id="domain-list"></ul>
119
+ <div class="hint">添加时浏览器会请求该域名的访问权限,允许后即在该域名注入选取脚本。</div>
120
+ </section>
121
+
122
+ <section>
123
+ <div class="label">传输通路</div>
124
+ <label class="radio">
125
+ <input
126
+ type="radio"
127
+ name="transport"
128
+ value="native"
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
+ >
145
+ <input
146
+ id="bridge-url"
147
+ type="text"
148
+ placeholder="http://127.0.0.1:8917"
149
+ />
150
+ </div>
151
+ <div class="hint">
152
+ 远程开发:在远程机跑
153
+ <code>ui-inspector bridge</code>
154
+ ,SSH 把端口转发到本地 127.0.0.1。
155
+ </div>
156
+ </div>
157
+ </section>
158
+
159
+ <button
160
+ id="save"
161
+ class="primary"
162
+ >
163
+ 保存传输设置
164
+ </button>
165
+ <div id="msg"></div>
166
+
167
+ <script src="popup.js"></script>
168
+ </body>
169
+ </html>
@@ -0,0 +1,136 @@
1
+ /* ui-inspector 弹窗(视图层配置):生效域名 + 传输通路,写入 chrome.storage.local。 */
2
+ const DEFAULTS = {
3
+ domains: ['http://localhost/*', 'http://127.0.0.1/*'],
4
+ transport: 'native',
5
+ bridgeUrl: 'http://127.0.0.1:8917',
6
+ };
7
+ const PROTECTED = new Set(DEFAULTS.domains); // 默认项不可删、不回收权限
8
+
9
+ const $ = id => document.getElementById(id);
10
+
11
+ function msg(text, ok = true) {
12
+ const el = $('msg');
13
+ el.textContent = text;
14
+ el.style.color = ok ? '#1a7f37' : '#cf222e';
15
+ if (text) {
16
+ setTimeout(() => (el.textContent = ''), 2600);
17
+ }
18
+ }
19
+
20
+ // 把用户输入规整成 match 模式:scheme://host/*(无 scheme 默认 https)
21
+ function toMatch(raw) {
22
+ let s = String(raw || '').trim();
23
+ if (!s) {
24
+ return null;
25
+ }
26
+ if (!/^[a-z]+:\/\//i.test(s)) {
27
+ s = `https://${s}`;
28
+ }
29
+ let u;
30
+ try {
31
+ u = new URL(s);
32
+ } catch {
33
+ return null;
34
+ }
35
+ return `${u.protocol}//${u.host}/*`;
36
+ }
37
+
38
+ async function getConfig() {
39
+ return chrome.storage.local.get(DEFAULTS);
40
+ }
41
+
42
+ async function renderDomains() {
43
+ const {domains} = await getConfig();
44
+ const ul = $('domain-list');
45
+ ul.innerHTML = '';
46
+ for (const m of domains) {
47
+ const li = document.createElement('li');
48
+ const code = document.createElement('code');
49
+ code.textContent = m;
50
+ li.appendChild(code);
51
+ if (!PROTECTED.has(m)) {
52
+ const x = document.createElement('button');
53
+ x.className = 'x';
54
+ x.textContent = '删除';
55
+ x.onclick = () => removeDomain(m);
56
+ li.appendChild(x);
57
+ }
58
+ ul.appendChild(li);
59
+ }
60
+ }
61
+
62
+ async function addDomain() {
63
+ const match = toMatch($('domain-input').value);
64
+ if (!match) {
65
+ msg('域名格式不对', false);
66
+ return;
67
+ }
68
+ const {domains} = await getConfig();
69
+ if (domains.includes(match)) {
70
+ msg('已存在');
71
+ return;
72
+ }
73
+ let granted = true;
74
+ try {
75
+ granted = await chrome.permissions.request({origins: [match]});
76
+ } catch (error) {
77
+ msg(String(error), false);
78
+ return;
79
+ }
80
+ if (!granted) {
81
+ msg('未授予权限', false);
82
+ return;
83
+ }
84
+ await chrome.storage.local.set({domains: [...domains, match]});
85
+ $('domain-input').value = '';
86
+ await renderDomains();
87
+ msg(`已添加 ${match}`);
88
+ }
89
+
90
+ async function removeDomain(match) {
91
+ const {domains} = await getConfig();
92
+ await chrome.storage.local.set({domains: domains.filter(m => m !== match)});
93
+ try {
94
+ await chrome.permissions.remove({origins: [match]});
95
+ } catch {
96
+ // 权限回收失败不影响功能
97
+ }
98
+ await renderDomains();
99
+ msg(`已删除 ${match}`);
100
+ }
101
+
102
+ // 「本地」模式不需要 bridge 地址,隐藏输入框;「远程」才显示。
103
+ function toggleBridge() {
104
+ const transport = document.querySelector('input[name="transport"]:checked')?.value || 'native';
105
+ $('bridge-config').style.display = transport === 'http' ? 'block' : 'none';
106
+ }
107
+
108
+ async function renderTransport() {
109
+ const {transport, bridgeUrl} = await getConfig();
110
+ for (const r of document.querySelectorAll('input[name="transport"]')) {
111
+ r.checked = r.value === transport;
112
+ }
113
+ $('bridge-url').value = bridgeUrl;
114
+ toggleBridge();
115
+ }
116
+
117
+ async function saveTransport() {
118
+ const transport = document.querySelector('input[name="transport"]:checked')?.value || 'native';
119
+ const bridgeUrl = $('bridge-url').value.trim() || DEFAULTS.bridgeUrl;
120
+ await chrome.storage.local.set({transport, bridgeUrl});
121
+ msg('已保存传输设置');
122
+ }
123
+
124
+ $('add-domain').onclick = addDomain;
125
+ $('save').onclick = saveTransport;
126
+ for (const r of document.querySelectorAll('input[name="transport"]')) {
127
+ r.addEventListener('change', toggleBridge);
128
+ }
129
+ $('domain-input').addEventListener('keydown', e => {
130
+ if (e.key === 'Enter') {
131
+ addDomain();
132
+ }
133
+ });
134
+
135
+ renderDomains();
136
+ renderTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comate-ui-inspector",
3
- "version": "0.1.2-alpha.1",
3
+ "version": "0.1.2-alpha.5",
4
4
  "description": "按住 Alt 点选页面元素,把「元素 + Vue 组件源码路径」交给 Comate 定位源码修改。浏览器扩展 + Native Messaging host + MCP + hook。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@
11
11
  "cli.mjs",
12
12
  "extension",
13
13
  "mcp",
14
+ "bridge",
14
15
  "hooks",
15
16
  "README.md"
16
17
  ],