comate-ui-inspector 0.1.5 → 0.1.6-alpha.1

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
@@ -10,7 +10,7 @@
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, appendFileSync, mkdirSync} from 'node:fs';
13
+ import {readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync} from 'node:fs';
14
14
  import {dirname, resolve} from 'node:path';
15
15
  import {homedir} from 'node:os';
16
16
 
@@ -19,6 +19,8 @@ const statusFile = resolve(homedir(), '.ui-inspector/status.json');
19
19
  // 录制:元信息 + append-only 事件流分离,避免多请求读改写竞态把 active 盖回 true。
20
20
  const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
21
21
  const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
22
+ // Agent→浏览器命令队列:每条命令一个文件 commands/<id>.json。
23
+ const cmdDir = resolve(homedir(), '.ui-inspector/commands');
22
24
  const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
23
25
  // 默认只绑 127.0.0.1(走 SSH 隧道);直连模式(笔记本能直达开发机内网 IP)时用 0.0.0.0 暴露到网卡。
24
26
  const HOST = process.env.UI_INSPECTOR_BRIDGE_HOST || '127.0.0.1';
@@ -84,6 +86,64 @@ function recStop(endedAt) {
84
86
  writeMeta(cur);
85
87
  }
86
88
 
89
+ // 命令队列:浏览器轮询取 pending(标记 taken),写回结果。
90
+ function cmdId(id) {
91
+ return String(id || '').replace(/[^\w.-]/g, '');
92
+ }
93
+ function cmdPoll() {
94
+ const out = [];
95
+ let files = [];
96
+ try {
97
+ files = readdirSync(cmdDir);
98
+ } catch {
99
+ return out;
100
+ }
101
+ for (const f of files) {
102
+ if (!f.endsWith('.json')) {
103
+ continue;
104
+ }
105
+ const fp = resolve(cmdDir, f);
106
+ let c;
107
+ try {
108
+ c = JSON.parse(readFileSync(fp, 'utf8'));
109
+ } catch {
110
+ continue;
111
+ }
112
+ if (c && c.status === 'pending') {
113
+ c.status = 'taken';
114
+ try {
115
+ writeFileSync(fp, JSON.stringify(c), 'utf8');
116
+ } catch {
117
+ // 忽略
118
+ }
119
+ out.push({id: c.id, type: c.type, params: c.params || {}});
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+ function cmdResult(id, result, error) {
125
+ const fp = resolve(cmdDir, `${cmdId(id)}.json`);
126
+ let c;
127
+ try {
128
+ c = JSON.parse(readFileSync(fp, 'utf8'));
129
+ } catch {
130
+ return;
131
+ }
132
+ c.status = error ? 'error' : 'done';
133
+ if (error) {
134
+ c.error = String(error);
135
+ } else {
136
+ c.result = result;
137
+ }
138
+ c.doneAt = Date.now();
139
+ try {
140
+ mkdirSync(cmdDir, {recursive: true});
141
+ writeFileSync(fp, JSON.stringify(c), 'utf8');
142
+ } catch {
143
+ // 忽略
144
+ }
145
+ }
146
+
87
147
  function readBody(req, cb) {
88
148
  let body = '';
89
149
  req.on('data', chunk => (body += chunk));
@@ -166,6 +226,20 @@ const server = createServer((req, res) => {
166
226
  });
167
227
  return;
168
228
  }
229
+ if (req.method === 'GET' && url.pathname === '/cmd/poll') {
230
+ json(res, 200, {ok: true, commands: cmdPoll()});
231
+ return;
232
+ }
233
+ if (req.method === 'POST' && url.pathname === '/cmd/result') {
234
+ readBody(req, (data, err) => {
235
+ if (err) {
236
+ return json(res, 400, {ok: false, error: String(err)});
237
+ }
238
+ cmdResult(data.id, data.result, data.error);
239
+ json(res, 200, {ok: true});
240
+ });
241
+ return;
242
+ }
169
243
  json(res, 404, {ok: false, error: 'not found'});
170
244
  });
171
245
 
package/cli.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  openSync,
21
21
  readdirSync,
22
22
  readFileSync,
23
+ statSync,
23
24
  writeFileSync,
24
25
  rmSync,
25
26
  } from 'node:fs';
@@ -196,7 +197,7 @@ function availableEnhancements() {
196
197
  }
197
198
  }
198
199
 
199
- function addEnhancement(name) {
200
+ async function addEnhancement(name) {
200
201
  if (!name) {
201
202
  out(`用法:ui-inspector add <名>。可用:${availableEnhancements().join(', ') || '(无)'}`);
202
203
  return;
@@ -215,11 +216,45 @@ function addEnhancement(name) {
215
216
  return;
216
217
  }
217
218
  const id = descriptor.id || name;
218
- cpSync(src, join(enhanceDstDir, id), {recursive: true});
219
+ const dst = join(enhanceDstDir, id);
220
+ cpSync(src, dst, {recursive: true});
221
+ // 按需下载大文件(如 figma 引擎):主包不内置以保持精简,首次 add 时拉取。
222
+ // 本地源目录已带该文件(开发/仓库场景)则跳过,离线可用。
223
+ for (const d of descriptor.download || []) {
224
+ const target = join(dst, d.file);
225
+ let haveLocal = false;
226
+ try {
227
+ haveLocal = existsSync(target) && statSync(target).size >= (d.minBytes || 1);
228
+ } catch {
229
+ haveLocal = false;
230
+ }
231
+ if (haveLocal) {
232
+ continue;
233
+ }
234
+ out(`按需下载 ${d.file} …(${d.url})`);
235
+ try {
236
+ const resp = await fetch(d.url);
237
+ if (!resp.ok) {
238
+ throw new Error(`HTTP ${resp.status}`);
239
+ }
240
+ const buf = Buffer.from(await resp.arrayBuffer());
241
+ if (d.minBytes && buf.length < d.minBytes) {
242
+ throw new Error(`文件过小(${buf.length}B),疑似下载失败`);
243
+ }
244
+ mkdirSync(dirname(target), {recursive: true});
245
+ writeFileSync(target, buf);
246
+ out(` 已写入 ${d.file}(${buf.length}B)`);
247
+ } catch (error) {
248
+ rmSync(dst, {recursive: true, force: true});
249
+ out(`下载 ${d.file} 失败:${String(error)}`);
250
+ out(`增强包「${id}」安装已回滚。请检查网络后重试,或手动把该文件放到 ${target}。`);
251
+ return;
252
+ }
253
+ }
219
254
  const list = readRegistry().filter(p => p.id !== id);
220
255
  list.push(descriptor);
221
256
  writeRegistry(list);
222
- out(`已安装增强包「${descriptor.name || id}」(${id}) -> ${join(enhanceDstDir, id)}`);
257
+ out(`已安装增强包「${descriptor.name || id}」(${id}) -> ${dst}`);
223
258
  out('在 chrome://extensions 点「刷新」重新加载扩展后生效:弹窗出现对应「模式」tab。');
224
259
  }
225
260
 
@@ -0,0 +1,46 @@
1
+ /*
2
+ * Figma 命令中继(ISOLATED world,figma.com):轮询 background 要 Agent 下发的命令,转给 MAIN 的
3
+ * figma-exec 执行;把执行结果回传 background(写回命令队列,供 MCP server 取回)。
4
+ * 只在 figma.com 运行,故命令只在真正能执行的页面被取走。
5
+ */
6
+ (function () {
7
+ const alive = () => !!(chrome.runtime && chrome.runtime.id);
8
+
9
+ // MAIN 执行完的结果 → background → 命令队列
10
+ window.addEventListener('message', event => {
11
+ if (event.source !== window || !event.data || !event.data.__uiInspectorCmdResult) {
12
+ return;
13
+ }
14
+ if (!alive()) {
15
+ return;
16
+ }
17
+ const r = event.data.__uiInspectorCmdResult;
18
+ try {
19
+ chrome.runtime.sendMessage(
20
+ {type: 'cmd-result', id: r.id, result: r.result, error: r.error},
21
+ () => void chrome.runtime.lastError
22
+ );
23
+ } catch {
24
+ // 上下文失效
25
+ }
26
+ });
27
+
28
+ const timer = setInterval(() => {
29
+ if (!alive()) {
30
+ clearInterval(timer);
31
+ return;
32
+ }
33
+ try {
34
+ chrome.runtime.sendMessage({type: 'cmd-poll'}, resp => {
35
+ if (chrome.runtime.lastError || !resp || !resp.ok || !Array.isArray(resp.commands)) {
36
+ return;
37
+ }
38
+ for (const cmd of resp.commands) {
39
+ window.postMessage({__uiInspectorCmd: cmd}, '*');
40
+ }
41
+ });
42
+ } catch {
43
+ clearInterval(timer);
44
+ }
45
+ }, 1200);
46
+ })();
@@ -0,0 +1,83 @@
1
+ /*
2
+ * Figma 命令执行器(MAIN world,figma.com):收 figma-bridge 转来的命令,调用页面内已解锁的
3
+ * window.figma / window.F2CConverter 完成导出,再把结果 postMessage 回 figma-bridge。
4
+ * 注意:window.figma / F2CConverter 的具体形态由 rewrite.js + browser.global.js 解锁后提供,
5
+ * 这里按官方扩展观察到的 API 名调用(convertFigmaNodeToHtml / exportAsync),真机可能需微调。
6
+ */
7
+ (function () {
8
+ if (window.__oreateFigmaExec) {
9
+ return;
10
+ }
11
+ window.__oreateFigmaExec = true;
12
+
13
+ function reply(id, result, error) {
14
+ window.postMessage({__uiInspectorCmdResult: {id: id, result: result, error: error ? String(error) : undefined}}, '*');
15
+ }
16
+
17
+ function resolveNode(params) {
18
+ var fig = window.figma;
19
+ if (!fig) {
20
+ throw new Error('window.figma 不可用(请确认已在 figma.com 设计稿页,且扩展已解锁)');
21
+ }
22
+ if (params && params.nodeId && typeof fig.getNodeById === 'function') {
23
+ var n = fig.getNodeById(params.nodeId);
24
+ if (n) {
25
+ return n;
26
+ }
27
+ }
28
+ var sel = fig.currentPage && fig.currentPage.selection;
29
+ if (sel && sel.length) {
30
+ return sel[0];
31
+ }
32
+ throw new Error('未找到目标节点:传入 nodeId 或先在画布选中一个节点');
33
+ }
34
+
35
+ function toBase64(bytes) {
36
+ var bin = '';
37
+ var arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
38
+ for (var i = 0; i < arr.length; i++) {
39
+ bin += String.fromCharCode(arr[i]);
40
+ }
41
+ return btoa(bin);
42
+ }
43
+
44
+ async function exec(cmd) {
45
+ var type = cmd.type;
46
+ var params = cmd.params || {};
47
+ if (type === 'figma_export_node_html') {
48
+ if (!window.F2CConverter || typeof window.F2CConverter.convertFigmaNodeToHtml !== 'function') {
49
+ throw new Error('window.F2CConverter.convertFigmaNodeToHtml 不可用');
50
+ }
51
+ var node = resolveNode(params);
52
+ var html = await window.F2CConverter.convertFigmaNodeToHtml(node, params.options || {});
53
+ return {html: typeof html === 'string' ? html : JSON.stringify(html)};
54
+ }
55
+ if (type === 'figma_export_node_image') {
56
+ var n = resolveNode(params);
57
+ if (!n || typeof n.exportAsync !== 'function') {
58
+ throw new Error('该节点不支持 exportAsync 导出');
59
+ }
60
+ var format = (params.format || 'PNG').toUpperCase();
61
+ var bytes = await n.exportAsync({format: format});
62
+ return {base64: toBase64(bytes), format: format};
63
+ }
64
+ throw new Error('未知命令类型:' + type);
65
+ }
66
+
67
+ window.addEventListener('message', function (ev) {
68
+ if (ev.source !== window || !ev.data || !ev.data.__uiInspectorCmd) {
69
+ return;
70
+ }
71
+ var cmd = ev.data.__uiInspectorCmd;
72
+ Promise.resolve()
73
+ .then(function () {
74
+ return exec(cmd);
75
+ })
76
+ .then(function (result) {
77
+ reply(cmd.id, result, null);
78
+ })
79
+ .catch(function (err) {
80
+ reply(cmd.id, null, err);
81
+ });
82
+ });
83
+ })();
@@ -0,0 +1 @@
1
+ var figma=(function(){function e(e){return e==null||typeof e==`function`?{main:e}:e}let t=[{markers:[`.appModel.isReadOnly`],replacements:[{pattern:`.appModel.isReadOnly`,replacer:`.appModel.__isReadOnly__`}]},{markers:[`const __html__ = (() => {`],replacements:[{pattern:/([A-Za-z_$][A-Za-z0-9_$]*)=\(0,([A-Za-z_$][A-Za-z0-9_$]*\.[A-Za-z_$][A-Za-z0-9_$]*)\)\(\)\|\|([A-Za-z_$][A-Za-z0-9_$]*);if\(!\1\)/,replacer:`$1=(0,$2)()||$3;if(false)`},{pattern:/if\(\(0,([A-Za-z_$][A-Za-z0-9_$]*\.[A-Za-z_$][A-Za-z0-9_$]*)\)\(\)\)return;([A-Za-z_$][A-Za-z0-9_$]*)&&/,replacer:`if((0,$1)())return;true&&`}]},{markers:[`dispnf.fyufotjpo;00`,`np{.fyufotjpo;00`],replacements:[{pattern:/dispnf\.fyufotjpo;00|np{\.fyufotjpo;00/g,replacer:`FIGMA_PLEASE_STOP`}]},{markers:[`{type:"global",closePluginFunc:`],replacements:[{pattern:/{type:"global",closePluginFunc:[A-Za-z_$][A-Za-z0-9_$]*}/,replacer:`{type:"global",closePluginFunc:()=>{}}`}]},{markers:[`ext_init_wdf`],replacements:[{pattern:`ext_init_wdf`,replacer:`__ext_init_wdf__`}]},{markers:[`let{canRunExtensions:`],replacements:[{pattern:/let\{canRunExtensions:([A-Za-z_$][A-Za-z0-9_$]*),canAccessFullDevMode:([A-Za-z_$][A-Za-z0-9_$]*)\}=([A-Za-z_$][A-Za-z0-9_$]*).openFile;/,replacer:`let{canRunExtensions:$1,canAccessFullDevMode:$2}=$3.openFile;$1=true;`}]}];var n=`[tempad-dev]`,r=e=>{if(!e.length)return[n];let[t,...r]=e;return typeof t==`string`?t.startsWith(n)?e:[`${n} ${t}`,...r]:[n,...e]};let i={log:(...e)=>{console.log(...r(e))},warn:(...e)=>{console.warn(...r(e))},error:(...e)=>{console.error(...r(e))},debug:(...e)=>{}};function a(e,t){return(t.markers||[]).every(t=>e.includes(t))}function o(e,t){let n=e,r=[],o=[];for(let[e,s]of t.entries()){if(!a(n,s))continue;r.push(e);let t=!1;for(let{pattern:e,replacer:r}of s.replacements){let a=n;n=typeof e==`string`?n.replaceAll(e,r):n.replace(e,r),n===a?i.warn(`Replacement had no effect: ${e} -> ${r}`):(t=!0,i.log(`Applied replacement: ${e} -> ${r}`))}t&&o.push(e)}return{content:n,changed:n!==e,matchedGroups:r,rewrittenGroups:o}}var s=e(()=>{async function e(){let e=document.currentScript,n=e?.src;if(!e||!n)return;let r=Object.getOwnPropertyDescriptor(Document.prototype,`currentScript`);function a(t){let n=document.createElement(`script`);n.src=t,n.defer=!0,e.replaceWith(n)}try{let{content:r,changed:a}=o(await(await fetch(n)).text(),t);a&&i.log(`Rewrote script: ${n}`);let s=r.replaceAll(`delete window.figma`,`window.figma = undefined`);Object.defineProperty(document,`currentScript`,{configurable:!0,get(){return e}}),Function(s)()}catch(e){i.error(e),a(`${n}?fallback`)}finally{r?Object.defineProperty(document,`currentScript`,r):delete document.currentScript}}e()}),c={debug:(...e)=>(console.debug,[...e],void 0),log:(...e)=>(console.log,[...e],void 0),warn:(...e)=>(console.warn,[...e],void 0),error:(...e)=>(console.error,[...e],void 0)};return(()=>{let e;try{e=s.main(),e instanceof Promise&&(e=e.catch(e=>{throw c.error(`The unlisted script "figma" crashed on startup!`,e),e}))}catch(e){throw c.error(`The unlisted script "figma" crashed on startup!`,e),e}return e})()})();figma;
@@ -0,0 +1,9 @@
1
+ <div class="hint">在 <code>www.figma.com</code> 打开设计稿后,本模式会在页面注入 F2C 引擎(解锁 Dev Mode / 插件能力)。</div>
2
+ <div
3
+ id="figma-status"
4
+ class="hint"
5
+ style="margin-top: 6px"
6
+ ></div>
7
+ <div class="hint" style="margin-top: 6px">
8
+ 需先在上方「设置 → 生效域名」添加 <code>https://www.figma.com</code> 并授权,然后重载扩展。
9
+ </div>
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Figma 增强包弹窗面板:只做状态提示。真正的注入在 figma.com 页面内由 rewrite.js + browser.global.js
3
+ * (MAIN world,document_start)完成;导出能力(节点→HTML/图片)由后续的命令通道 + MCP 工具提供。
4
+ */
5
+ (function () {
6
+ const el = document.getElementById('figma-status');
7
+ if (!el) {
8
+ return;
9
+ }
10
+ chrome.permissions.contains({origins: ['https://www.figma.com/*']}, granted => {
11
+ if (chrome.runtime.lastError) {
12
+ return;
13
+ }
14
+ el.textContent = granted ? '✓ 已授权 figma.com,打开设计稿即生效' : '✗ 尚未授权 figma.com';
15
+ el.style.color = granted ? '#1a7f37' : '#cf222e';
16
+ });
17
+ })();
@@ -0,0 +1,43 @@
1
+ {
2
+ "id": "figma",
3
+ "name": "Figma 转代码",
4
+ "scripts": [
5
+ {
6
+ "id": "rewrite",
7
+ "js": ["rewrite.js"],
8
+ "matches": ["https://www.figma.com/*"],
9
+ "world": "MAIN",
10
+ "runAt": "document_start"
11
+ },
12
+ {
13
+ "id": "converter",
14
+ "js": ["browser.global.js"],
15
+ "matches": ["https://www.figma.com/*"],
16
+ "world": "MAIN",
17
+ "runAt": "document_start"
18
+ },
19
+ {
20
+ "id": "exec",
21
+ "js": ["figma-exec.js"],
22
+ "matches": ["https://www.figma.com/*"],
23
+ "world": "MAIN",
24
+ "runAt": "document_idle"
25
+ },
26
+ {
27
+ "id": "bridge",
28
+ "js": ["figma-bridge.js"],
29
+ "matches": ["https://www.figma.com/*"],
30
+ "world": "ISOLATED",
31
+ "runAt": "document_idle"
32
+ }
33
+ ],
34
+ "pane": "pane.html",
35
+ "paneJs": "pane.js",
36
+ "download": [
37
+ {
38
+ "file": "browser.global.js",
39
+ "url": "https://cdn.jsdelivr.net/npm/comate-ui-inspector-figma@latest/browser.global.js",
40
+ "minBytes": 100000
41
+ }
42
+ ]
43
+ }
@@ -0,0 +1,2 @@
1
+ var rewrite=function(){"use strict";function j(r){return r}const b={matches:["https://www.figma.com/*"],runAt:"document_start",world:"MAIN",main(){const r="https://ecomfe.github.io/tempad-dev/figma.json",v=[{markers:[".appModel.isReadOnly"],replacements:[{pattern:".appModel.isReadOnly",replacer:".appModel.__isReadOnly__"}]},{markers:['{type:"global",closePluginFunc:'],replacements:[{pattern:/{type:"global",closePluginFunc:[A-Za-z_$][A-Za-z0-9_$]*}/,replacer:'{type:"global",closePluginFunc:()=>{}}'}]},{markers:["let{canRunExtensions:"],replacements:[{pattern:/let\{canRunExtensions:([A-Za-z_$][A-Za-z0-9_$]*),canAccessFullDevMode:([A-Za-z_$][A-Za-z0-9_$]*)\}=([A-Za-z_$][A-Za-z0-9_$]*).openFile;/,replacer:"let{canRunExtensions:$1,canAccessFullDevMode:$2}=$3.openFile;$1=true;"}]}],L=[{id:2,priority:1,action:{type:"redirect",redirect:{extensionPath:"/figma.js"}},condition:{regexFilter:"/webpack-artifacts/assets/(?:figma_app[^.]+|[0-9a-zA-Z-]+)\\.min\\.js(?:\\.br)?$",resourceTypes:["script"]}}];function U(e,t){return(t.markers||[]).every(c=>e.includes(c))}function F(e,t){let n=e;const c=[],o=[];for(const[l,i]of t.entries()){if(!U(n,i))continue;c.push(l);let E=!1;for(const{pattern:f,replacer:A}of i.replacements){const S=n;typeof f=="string"?n=n.replaceAll(f,A):n=n.replace(f,A),n!==S&&(E=!0)}E&&o.push(l)}return{content:n,changed:n!==e,matchedGroups:c,rewrittenGroups:o}}function d(e){var t;try{const n=e.find(c=>c.id===2);return(t=n==null?void 0:n.condition)!=null&&t.regexFilter?new RegExp(n.condition.regexFilter,"i"):null}catch{return null}}async function k(e){try{const t=await fetch(e,{credentials:"omit",cache:"no-cache"});return t.ok?d(await t.json()):null}catch{return null}}let g;Reflect.defineProperty(window,"figma",{set(e){e!==void 0&&(g=e)},get(){return g}});let u=d(L);k(r).then(e=>{e&&(u=e)});const{appendChild:h,insertBefore:m}=Element.prototype,y=new WeakSet,s=new Map;function z(e){return!!u&&u.test(e)}function w(e){return e instanceof HTMLScriptElement&&typeof e.src=="string"&&e.src.length>0}function R(e,t){const n=s.get(e);if(!(!n||n.url!==t)&&(n.ref-=1,n.ref<=0)){try{URL.revokeObjectURL(n.url)}catch{}s.delete(e)}}async function M(e){const t=s.get(e);if(t)return t.ref+=1,{url:t.url,release:()=>R(e,t.url)};const c=await(await fetch(e,{credentials:"include",cache:"force-cache"})).text(),{content:o}=F(c,v),l=new Blob([o],{type:"application/javascript; charset=utf-8"}),i=URL.createObjectURL(l);return s.set(e,{url:i,ref:1}),{url:i,release:()=>R(e,i)}}function p(e,t,n){n?m.call(e,t,n):h.call(e,t)}async function _(e,t,n){if(y.has(t)){p(e,t,n);return}if(y.add(t),!z(t.src)){p(e,t,n);return}try{const{url:c,release:o}=await M(t.src);t.removeAttribute("integrity"),t.addEventListener("load",o,{once:!0}),t.addEventListener("error",o,{once:!0}),t.src=c}catch{}p(e,t,n)}const Z=function(e){return w(e)?(_(this,e,null),e):(h.call(this,e),e)},I=function(e,t){return w(e)?(_(this,e,t),e):(m.call(this,e,t),e)};Element.prototype.appendChild=Z,Element.prototype.insertBefore=I}};function P(){}function a(r,...$){}const x={debug:(...r)=>a(console.debug,...r),log:(...r)=>a(console.log,...r),warn:(...r)=>a(console.warn,...r),error:(...r)=>a(console.error,...r)};return(async()=>{try{return await b.main()}catch(r){throw x.error('The content script "rewrite" crashed on startup!',r),r}})()}();
2
+ rewrite;
@@ -83,6 +83,18 @@ async function viaHttp(target, message) {
83
83
  });
84
84
  return await r.json();
85
85
  }
86
+ if (message.type === 'cmd-poll') {
87
+ const r = await fetch(`${base}/cmd/poll`);
88
+ return await r.json();
89
+ }
90
+ if (message.type === 'cmd-result') {
91
+ const r = await fetch(`${base}/cmd/result`, {
92
+ method: 'POST',
93
+ headers: {'content-type': 'application/json'},
94
+ body: JSON.stringify({id: message.id, result: message.result, error: message.error}),
95
+ });
96
+ return await r.json();
97
+ }
86
98
  const r = await fetch(`${base}/state`);
87
99
  return await r.json();
88
100
  } catch (error) {
@@ -104,7 +116,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
104
116
  msg.type === 'state' ||
105
117
  msg.type === 'rec-start' ||
106
118
  msg.type === 'rec-event' ||
107
- msg.type === 'rec-stop'
119
+ msg.type === 'rec-stop' ||
120
+ msg.type === 'cmd-poll' ||
121
+ msg.type === 'cmd-result'
108
122
  ) {
109
123
  dispatch(msg).then(sendResponse);
110
124
  return true; // 异步 sendResponse
@@ -137,48 +151,76 @@ async function readEnhancements() {
137
151
  async function buildScripts() {
138
152
  const plugins = await readEnhancements();
139
153
  const mainJs = [...BASE_MAIN_JS];
154
+ const extra = []; // 插件专用脚本:各自 matches/world/runAt(如 figma 的 figma.com + document_start + MAIN)
140
155
  for (const p of plugins) {
141
156
  for (const file of p.main || []) {
142
157
  mainJs.push(`enhancements/${p.id}/${file}`);
143
158
  }
159
+ for (const s of p.scripts || []) {
160
+ extra.push({
161
+ id: `enh-${p.id}-${s.id}`,
162
+ js: (s.js || []).map(f => `enhancements/${p.id}/${f}`),
163
+ matches: s.matches || [],
164
+ world: s.world || 'ISOLATED',
165
+ runAt: s.runAt || 'document_idle',
166
+ });
167
+ }
144
168
  }
145
- return [{id: 'ui-inspector-main', js: mainJs, world: 'MAIN'}, BRIDGE_SCRIPT];
169
+ const base = [
170
+ {id: 'ui-inspector-main', js: mainJs, world: 'MAIN', runAt: 'document_idle'},
171
+ {id: BRIDGE_SCRIPT.id, js: BRIDGE_SCRIPT.js, world: 'ISOLATED', runAt: 'document_idle'},
172
+ ];
173
+ return {base, extra};
146
174
  }
147
175
 
148
- async function syncScripts() {
149
- const {domains} = await getConfig();
150
- const scripts = await buildScripts();
151
- // 只注册已授予主机权限的域名;未授权的等弹窗 request 到权限后再同步。
152
- const allowed = [];
153
- for (const match of domains || []) {
176
+ // 过滤出已授予主机权限的 match(未授权的等弹窗 request 后再同步)。
177
+ async function filterGranted(matches) {
178
+ const out = [];
179
+ for (const match of matches || []) {
154
180
  try {
155
181
  if (await chrome.permissions.contains({origins: [match]})) {
156
- allowed.push(match);
182
+ out.push(match);
157
183
  }
158
184
  } catch {
159
185
  // 非法 match 模式,跳过
160
186
  }
161
187
  }
188
+ return out;
189
+ }
190
+
191
+ async function syncScripts() {
192
+ const {domains} = await getConfig();
193
+ const {base, extra} = await buildScripts();
194
+ const grantedPopup = await filterGranted(domains);
195
+
196
+ const regs = [];
197
+ // 基础脚本(选取 + 录制 main + bridge)→ 弹窗配置的生效域名
198
+ if (grantedPopup.length) {
199
+ for (const s of base) {
200
+ regs.push({id: s.id, js: s.js, matches: grantedPopup, world: s.world, runAt: s.runAt});
201
+ }
202
+ }
203
+ // 插件专用脚本 → 各自声明的 matches(按授权过滤)
204
+ for (const s of extra) {
205
+ const m = await filterGranted(s.matches);
206
+ if (m.length) {
207
+ regs.push({id: s.id, js: s.js, matches: m, world: s.world, runAt: s.runAt});
208
+ }
209
+ }
210
+
211
+ // 先清掉我们注册过的所有脚本(前缀 ui-inspector- / enh-),再按最新结果注册。
162
212
  try {
163
- const existing = await chrome.scripting.getRegisteredContentScripts({ids: scripts.map(s => s.id)});
164
- if (existing.length) {
165
- await chrome.scripting.unregisterContentScripts({ids: existing.map(s => s.id)});
213
+ const existing = await chrome.scripting.getRegisteredContentScripts();
214
+ const ours = existing.filter(s => /^(ui-inspector-|enh-)/.test(s.id)).map(s => s.id);
215
+ if (ours.length) {
216
+ await chrome.scripting.unregisterContentScripts({ids: ours});
166
217
  }
167
218
  } catch {
168
219
  // 没注册过,忽略
169
220
  }
170
- if (!allowed.length) {
171
- return;
221
+ if (regs.length) {
222
+ await chrome.scripting.registerContentScripts(regs);
172
223
  }
173
- await chrome.scripting.registerContentScripts(
174
- scripts.map(s => ({
175
- id: s.id,
176
- js: s.js,
177
- matches: allowed,
178
- world: s.world,
179
- runAt: 'document_idle',
180
- }))
181
- );
182
224
  }
183
225
 
184
226
  async function ensureDefaults() {
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * 数据文件与 MCP server / hook 共用:统一在用户主目录 ~/.ui-inspector/pending.json(与仓库位置无关)。
13
13
  */
14
- import {readFileSync, writeFileSync, appendFileSync, mkdirSync} from 'node:fs';
14
+ import {readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync} from 'node:fs';
15
15
  import {dirname, resolve} from 'node:path';
16
16
  import {homedir} from 'node:os';
17
17
 
@@ -22,6 +22,9 @@ const statusFile = resolve(homedir(), '.ui-inspector/status.json');
22
22
  // 分开是为了避免「每条事件读改写整份 JSON」的多进程竞态把 active 盖回 true——事件只追加,不碰 active。
23
23
  const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
24
24
  const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
25
+ // Agent→浏览器命令队列:每条命令一个文件 commands/<id>.json(避免单文件读改写竞态)。
26
+ // MCP server 写入 pending;浏览器轮询取走执行;host 写回 done/error 结果。
27
+ const cmdDir = resolve(homedir(), '.ui-inspector/commands');
25
28
 
26
29
  function readStore() {
27
30
  try {
@@ -85,6 +88,64 @@ function recStop(endedAt) {
85
88
  writeMeta(cur); // 只有 start/stop 动 active,事件追加不碰它 → stop 后 active 不会被盖回
86
89
  }
87
90
 
91
+ // 命令队列:浏览器轮询取 pending(取走即标记 taken,避免重复执行);host 写回结果。
92
+ function cmdId(id) {
93
+ return String(id || '').replace(/[^\w.-]/g, ''); // 防路径穿越
94
+ }
95
+ function cmdPoll() {
96
+ const out = [];
97
+ let files = [];
98
+ try {
99
+ files = readdirSync(cmdDir);
100
+ } catch {
101
+ return out;
102
+ }
103
+ for (const f of files) {
104
+ if (!f.endsWith('.json')) {
105
+ continue;
106
+ }
107
+ const fp = resolve(cmdDir, f);
108
+ let c;
109
+ try {
110
+ c = JSON.parse(readFileSync(fp, 'utf8'));
111
+ } catch {
112
+ continue;
113
+ }
114
+ if (c && c.status === 'pending') {
115
+ c.status = 'taken';
116
+ try {
117
+ writeFileSync(fp, JSON.stringify(c), 'utf8');
118
+ } catch {
119
+ // 忽略
120
+ }
121
+ out.push({id: c.id, type: c.type, params: c.params || {}});
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+ function cmdResult(id, result, error) {
127
+ const fp = resolve(cmdDir, `${cmdId(id)}.json`);
128
+ let c;
129
+ try {
130
+ c = JSON.parse(readFileSync(fp, 'utf8'));
131
+ } catch {
132
+ return;
133
+ }
134
+ c.status = error ? 'error' : 'done';
135
+ if (error) {
136
+ c.error = String(error);
137
+ } else {
138
+ c.result = result;
139
+ }
140
+ c.doneAt = Date.now();
141
+ try {
142
+ mkdirSync(cmdDir, {recursive: true});
143
+ writeFileSync(fp, JSON.stringify(c), 'utf8');
144
+ } catch {
145
+ // 忽略
146
+ }
147
+ }
148
+
88
149
  function send(obj) {
89
150
  const body = Buffer.from(JSON.stringify(obj), 'utf8');
90
151
  const header = Buffer.alloc(4);
@@ -110,6 +171,11 @@ function handle(msg) {
110
171
  } else if (msg && msg.type === 'rec-stop') {
111
172
  recStop(msg.endedAt);
112
173
  resp = {ok: true};
174
+ } else if (msg && msg.type === 'cmd-poll') {
175
+ resp = {ok: true, commands: cmdPoll()};
176
+ } else if (msg && msg.type === 'cmd-result') {
177
+ cmdResult(msg.id, msg.result, msg.error);
178
+ resp = {ok: true};
113
179
  } else {
114
180
  resp = {ok: false, error: 'unknown message'};
115
181
  }
package/mcp/server.mjs CHANGED
@@ -16,6 +16,8 @@ import {createInterface} from 'node:readline';
16
16
 
17
17
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
18
18
  const statusFile = resolve(homedir(), '.ui-inspector/status.json');
19
+ // Agent→浏览器命令队列(每条一个文件)。MCP 写 pending,浏览器 figma-bridge 取走执行并写回结果。
20
+ const cmdDir = resolve(homedir(), '.ui-inspector/commands');
19
21
 
20
22
  function readStore() {
21
23
  try {
@@ -41,6 +43,41 @@ function writeStatus(message, level) {
41
43
  return status;
42
44
  }
43
45
 
46
+ // 下发一条命令到队列,返回 id。
47
+ function enqueueCmd(type, params) {
48
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
49
+ mkdirSync(cmdDir, {recursive: true});
50
+ writeFileSync(resolve(cmdDir, `${id}.json`), JSON.stringify({id, type, params: params || {}, status: 'pending', ts: Date.now()}), 'utf8');
51
+ return id;
52
+ }
53
+
54
+ // 轮询命令结果,直到 done/error 或超时。浏览器需在 figma.com 页打开才会有执行者。
55
+ function waitCmd(id, timeoutMs = 30000) {
56
+ const fp = resolve(cmdDir, `${id}.json`);
57
+ const deadline = Date.now() + timeoutMs;
58
+ return new Promise((res, rej) => {
59
+ const tick = () => {
60
+ let c = null;
61
+ try {
62
+ c = JSON.parse(readFileSync(fp, 'utf8'));
63
+ } catch {
64
+ c = null;
65
+ }
66
+ if (c && c.status === 'done') {
67
+ return res(c.result);
68
+ }
69
+ if (c && c.status === 'error') {
70
+ return rej(new Error(c.error || 'command failed'));
71
+ }
72
+ if (Date.now() > deadline) {
73
+ return rej(new Error('命令超时(确认已在 figma.com 设计稿页打开,且扩展已授权 figma.com 并重载)'));
74
+ }
75
+ setTimeout(tick, 400);
76
+ };
77
+ tick();
78
+ });
79
+ }
80
+
44
81
  const TOOLS = [
45
82
  {
46
83
  name: 'list_pending_edits',
@@ -69,6 +106,32 @@ const TOOLS = [
69
106
  required: ['message'],
70
107
  },
71
108
  },
109
+ {
110
+ name: 'figma_export_node_html',
111
+ description:
112
+ '把 figma.com 当前设计稿里的某个节点导出为 HTML(F2C),写入本地文件。需浏览器已在 figma.com 设计稿页、扩展安装 figma 增强包并授权 figma.com。',
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ nodeId: {type: 'string', description: '目标节点 id;省略则用画布当前选中节点'},
117
+ outputPath: {type: 'string', description: '导出 HTML 写入的本地绝对路径'},
118
+ },
119
+ required: ['outputPath'],
120
+ },
121
+ },
122
+ {
123
+ name: 'figma_export_node_image',
124
+ description: '把 figma.com 当前设计稿里的某个节点导出为图片(PNG/JPG/SVG),写入本地文件。前置条件同上。',
125
+ inputSchema: {
126
+ type: 'object',
127
+ properties: {
128
+ nodeId: {type: 'string', description: '目标节点 id;省略则用画布当前选中节点'},
129
+ outputPath: {type: 'string', description: '导出图片写入的本地绝对路径'},
130
+ format: {type: 'string', enum: ['PNG', 'JPG', 'SVG'], description: '图片格式,默认 PNG'},
131
+ },
132
+ required: ['outputPath'],
133
+ },
134
+ },
72
135
  ];
73
136
 
74
137
  function callTool(name, args) {
@@ -85,6 +148,32 @@ function callTool(name, args) {
85
148
  if (name === 'notify_browser') {
86
149
  return writeStatus(args?.message, args?.level);
87
150
  }
151
+ if (name === 'figma_export_node_html') {
152
+ const outputPath = args?.outputPath;
153
+ if (!outputPath) {
154
+ throw new Error('outputPath 必填');
155
+ }
156
+ const id = enqueueCmd('figma_export_node_html', {nodeId: args?.nodeId});
157
+ return waitCmd(id).then(result => {
158
+ const html = (result && result.html) || '';
159
+ mkdirSync(dirname(outputPath), {recursive: true});
160
+ writeFileSync(outputPath, html, 'utf8');
161
+ return {ok: true, path: outputPath, bytes: Buffer.byteLength(html)};
162
+ });
163
+ }
164
+ if (name === 'figma_export_node_image') {
165
+ const outputPath = args?.outputPath;
166
+ if (!outputPath) {
167
+ throw new Error('outputPath 必填');
168
+ }
169
+ const id = enqueueCmd('figma_export_node_image', {nodeId: args?.nodeId, format: args?.format});
170
+ return waitCmd(id).then(result => {
171
+ const base64 = (result && result.base64) || '';
172
+ mkdirSync(dirname(outputPath), {recursive: true});
173
+ writeFileSync(outputPath, Buffer.from(base64, 'base64'));
174
+ return {ok: true, path: outputPath, format: (result && result.format) || 'PNG'};
175
+ });
176
+ }
88
177
  throw new Error('unknown tool: ' + name);
89
178
  }
90
179
 
@@ -113,16 +202,16 @@ function handle(msg) {
113
202
  return;
114
203
  }
115
204
  if (msg.method === 'tools/call') {
116
- try {
117
- const result = callTool(msg.params?.name, msg.params?.arguments);
118
- send({
119
- jsonrpc: '2.0',
120
- id: msg.id,
121
- result: {content: [{type: 'text', text: JSON.stringify(result, null, 4)}]},
122
- });
123
- } catch (error) {
124
- send({jsonrpc: '2.0', id: msg.id, error: {code: -32000, message: String(error)}});
125
- }
205
+ Promise.resolve()
206
+ .then(() => callTool(msg.params?.name, msg.params?.arguments))
207
+ .then(result =>
208
+ send({
209
+ jsonrpc: '2.0',
210
+ id: msg.id,
211
+ result: {content: [{type: 'text', text: JSON.stringify(result, null, 4)}]},
212
+ })
213
+ )
214
+ .catch(error => send({jsonrpc: '2.0', id: msg.id, error: {code: -32000, message: String(error)}}));
126
215
  return;
127
216
  }
128
217
  if (msg.id !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comate-ui-inspector",
3
- "version": "0.1.5",
3
+ "version": "0.1.6-alpha.1",
4
4
  "description": "按住 Alt 点选页面元素,把「元素 + Vue 组件源码路径」交给 Comate 定位源码修改。浏览器扩展 + Native Messaging host + MCP + hook。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,7 +14,11 @@
14
14
  "mcp",
15
15
  "bridge",
16
16
  "hooks",
17
- "README.md"
17
+ "README.md",
18
+ "!extension/enhancements",
19
+ "!extension/enhancements.json",
20
+ "!extension/native-host/host-wrapper.sh",
21
+ "!enhancements/figma/browser.global.js"
18
22
  ],
19
23
  "engines": {
20
24
  "node": ">=18"