figrelay 0.0.0-stage → 0.8.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/README.md +279 -2
- package/bin/darwin-amd64/figma-mcp +0 -0
- package/bin/darwin-arm64/figma-mcp +0 -0
- package/bin/linux-amd64/figma-mcp +0 -0
- package/bin/linux-arm64/figma-mcp +0 -0
- package/bin/plugin/code.js +1356 -0
- package/bin/plugin/manifest.json +14 -0
- package/bin/plugin/ui.html +559 -0
- package/bin/windows-amd64/figma-mcp.exe +0 -0
- package/bin/windows-arm64/figma-mcp.exe +0 -0
- package/package.json +34 -4
- package/run.js +60 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Figma Relay",
|
|
3
|
+
"id": "figrelay-dev",
|
|
4
|
+
"api": "1.0.0",
|
|
5
|
+
"main": "code.js",
|
|
6
|
+
"ui": "ui.html",
|
|
7
|
+
"editorType": ["figma"],
|
|
8
|
+
"documentAccess": "dynamic-page",
|
|
9
|
+
"networkAccess": {
|
|
10
|
+
"allowedDomains": ["none"],
|
|
11
|
+
"devAllowedDomains": ["ws://localhost:2000"],
|
|
12
|
+
"reasoning": "Connects to a local MCP bridge server so AI assistants can read and edit this file."
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
* {
|
|
3
|
+
box-sizing: border-box;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
html,
|
|
7
|
+
body {
|
|
8
|
+
height: 100%;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
body {
|
|
12
|
+
font:
|
|
13
|
+
12px/1.5 -apple-system,
|
|
14
|
+
BlinkMacSystemFont,
|
|
15
|
+
"Segoe UI",
|
|
16
|
+
sans-serif;
|
|
17
|
+
margin: 0;
|
|
18
|
+
padding: 10px;
|
|
19
|
+
color: #2c2c34;
|
|
20
|
+
background: linear-gradient(160deg, #f6f7fb 0%, #eef0f7 100%);
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.card {
|
|
26
|
+
background: #fff;
|
|
27
|
+
border: 1px solid #e6e8f0;
|
|
28
|
+
border-radius: 9px;
|
|
29
|
+
padding: 7px 9px;
|
|
30
|
+
box-shadow: 0 1px 2px rgba(30, 34, 60, 0.04);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#status-row {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
gap: 8px;
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
font-weight: 600;
|
|
39
|
+
font-size: 13px;
|
|
40
|
+
color: #1f2330;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#dot {
|
|
44
|
+
flex: none;
|
|
45
|
+
width: 9px;
|
|
46
|
+
height: 9px;
|
|
47
|
+
border-radius: 50%;
|
|
48
|
+
background: #e0555b;
|
|
49
|
+
box-shadow: 0 0 0 3px rgba(224, 85, 91, 0.15);
|
|
50
|
+
transition:
|
|
51
|
+
background 0.2s,
|
|
52
|
+
box-shadow 0.2s;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#dot.on {
|
|
56
|
+
background: #22a06b;
|
|
57
|
+
box-shadow: 0 0 0 3px rgba(34, 160, 107, 0.15);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#hint {
|
|
61
|
+
color: #6b6f7d;
|
|
62
|
+
margin-top: 5px;
|
|
63
|
+
font-size: 10px;
|
|
64
|
+
line-height: 1.35;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#result {
|
|
68
|
+
display: none;
|
|
69
|
+
margin-top: 10px;
|
|
70
|
+
border-top: 1px solid #eceef5;
|
|
71
|
+
padding-top: 9px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#result.show {
|
|
75
|
+
display: block;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.res-head {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
justify-content: space-between;
|
|
82
|
+
margin-bottom: 6px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.res-title {
|
|
86
|
+
font-weight: 600;
|
|
87
|
+
font-size: 11px;
|
|
88
|
+
color: #1f2330;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.res-list {
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
gap: 4px;
|
|
95
|
+
max-height: 190px;
|
|
96
|
+
overflow: auto;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.res-row {
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
gap: 6px;
|
|
103
|
+
padding: 5px 7px;
|
|
104
|
+
border: 1px solid #e6e8f0;
|
|
105
|
+
border-radius: 7px;
|
|
106
|
+
background: #fbfcfe;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.res-name {
|
|
110
|
+
flex: 1;
|
|
111
|
+
min-width: 0;
|
|
112
|
+
overflow: hidden;
|
|
113
|
+
text-overflow: ellipsis;
|
|
114
|
+
white-space: nowrap;
|
|
115
|
+
font-size: 11px;
|
|
116
|
+
color: #2c2c34;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.res-type {
|
|
120
|
+
flex: none;
|
|
121
|
+
font-size: 9px;
|
|
122
|
+
color: #a0a4b0;
|
|
123
|
+
text-transform: uppercase;
|
|
124
|
+
letter-spacing: 0.03em;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.copy-btn {
|
|
128
|
+
flex: none;
|
|
129
|
+
border: 1px solid #d6d9e6;
|
|
130
|
+
background: #fff;
|
|
131
|
+
color: #4b5168;
|
|
132
|
+
border-radius: 6px;
|
|
133
|
+
padding: 2px 8px;
|
|
134
|
+
font-size: 10px;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.copy-btn:hover {
|
|
139
|
+
background: #f0f2f8;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.copy-btn.done {
|
|
143
|
+
color: #22a06b;
|
|
144
|
+
border-color: #bfe6d3;
|
|
145
|
+
background: #f0faf5;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.copy-all {
|
|
149
|
+
padding: 1px 6px;
|
|
150
|
+
font-size: 10px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.filter-row {
|
|
154
|
+
display: flex;
|
|
155
|
+
gap: 4px;
|
|
156
|
+
margin-bottom: 8px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.filter-btn {
|
|
160
|
+
flex: none;
|
|
161
|
+
border: 1px solid #d6d9e6;
|
|
162
|
+
background: #fff;
|
|
163
|
+
color: #6b6f7d;
|
|
164
|
+
border-radius: 6px;
|
|
165
|
+
padding: 3px 9px;
|
|
166
|
+
font-size: 10px;
|
|
167
|
+
cursor: pointer;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.filter-btn:hover {
|
|
171
|
+
background: #f0f2f8;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.filter-btn.on {
|
|
175
|
+
background: #2c2c34;
|
|
176
|
+
border-color: #2c2c34;
|
|
177
|
+
color: #fff;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.res-empty {
|
|
181
|
+
font-size: 11px;
|
|
182
|
+
color: #a0a4b0;
|
|
183
|
+
padding: 6px 2px;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.key-prompt {
|
|
187
|
+
margin-bottom: 8px;
|
|
188
|
+
padding: 7px 8px;
|
|
189
|
+
border: 1px solid #ffe0a6;
|
|
190
|
+
background: #fff9ec;
|
|
191
|
+
border-radius: 7px;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.key-note {
|
|
195
|
+
font-size: 10px;
|
|
196
|
+
color: #8a6d3b;
|
|
197
|
+
line-height: 1.4;
|
|
198
|
+
margin-bottom: 5px;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.key-input-row {
|
|
202
|
+
display: flex;
|
|
203
|
+
gap: 4px;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.key-input {
|
|
207
|
+
flex: 1;
|
|
208
|
+
min-width: 0;
|
|
209
|
+
border: 1px solid #d6d9e6;
|
|
210
|
+
border-radius: 6px;
|
|
211
|
+
padding: 3px 6px;
|
|
212
|
+
font-size: 10px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.key-save {
|
|
216
|
+
flex: none;
|
|
217
|
+
border: 1px solid #d6d9e6;
|
|
218
|
+
background: #fff;
|
|
219
|
+
border-radius: 6px;
|
|
220
|
+
padding: 3px 10px;
|
|
221
|
+
font-size: 10px;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.key-save:hover {
|
|
226
|
+
background: #f0f2f8;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#credit {
|
|
230
|
+
margin-top: auto;
|
|
231
|
+
padding-top: 7px;
|
|
232
|
+
text-align: center;
|
|
233
|
+
color: #a0a4b0;
|
|
234
|
+
font-size: 10px;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#version {
|
|
238
|
+
color: #8288a0;
|
|
239
|
+
margin-right: 4px;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#credit a {
|
|
243
|
+
color: #8288a0;
|
|
244
|
+
text-decoration: none;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#credit a:hover {
|
|
248
|
+
text-decoration: underline;
|
|
249
|
+
}
|
|
250
|
+
</style>
|
|
251
|
+
|
|
252
|
+
<div class="card">
|
|
253
|
+
<div id="status-row">
|
|
254
|
+
<span id="dot"></span><span id="status">Connecting…</span>
|
|
255
|
+
</div>
|
|
256
|
+
<div id="hint">Keep open while AI works · run it in every file.</div>
|
|
257
|
+
<div id="result"></div>
|
|
258
|
+
</div>
|
|
259
|
+
<div id="credit">
|
|
260
|
+
<span id="version"></span>
|
|
261
|
+
<a href="https://github.com/quannh287/figma-relay" target="_blank">@quannh287</a>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<script>
|
|
265
|
+
// Bridge UI: connect to the shared MCP bridge on localhost and relay
|
|
266
|
+
// frames between it and the plugin main thread.
|
|
267
|
+
//
|
|
268
|
+
// One bridge serves every plugin window and every MCP session, so there is
|
|
269
|
+
// a single well-known port (also the only one the manifest's
|
|
270
|
+
// devAllowedDomains permits). While disconnected we retry on a short loop;
|
|
271
|
+
// background tabs throttle timers, so a visibility change and a click on
|
|
272
|
+
// the status line also trigger an immediate attempt.
|
|
273
|
+
const PORT = 2000,
|
|
274
|
+
RETRY = 1000,
|
|
275
|
+
PROTOCOL = 4; // must match bridge.ProtocolVersion in the Go server
|
|
276
|
+
let ws = null; // open connection, null while disconnected
|
|
277
|
+
let attempt = null; // socket currently trying to connect
|
|
278
|
+
let reg = null; // {key, name} of this file, sent by the main thread
|
|
279
|
+
let protoWarning = null; // set when the server reports a newer protocol
|
|
280
|
+
|
|
281
|
+
function setStatus(connected, text) {
|
|
282
|
+
document.getElementById("dot").className = connected ? "on" : "";
|
|
283
|
+
document.getElementById("status").textContent = text;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function label() {
|
|
287
|
+
if (protoWarning) return protoWarning;
|
|
288
|
+
return reg ? "Connected · " + reg.name : "Connected (port " + PORT + ")";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function sendRegister() {
|
|
292
|
+
if (ws && ws.readyState === WebSocket.OPEN && reg) {
|
|
293
|
+
ws.send(JSON.stringify({ register: reg }));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function connect() {
|
|
298
|
+
if (ws || attempt) return;
|
|
299
|
+
let sock;
|
|
300
|
+
try {
|
|
301
|
+
sock = new WebSocket("ws://localhost:" + PORT + "/ws");
|
|
302
|
+
} catch (e) {
|
|
303
|
+
schedule();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
attempt = sock;
|
|
307
|
+
sock.onopen = () => {
|
|
308
|
+
attempt = null;
|
|
309
|
+
ws = sock;
|
|
310
|
+
sendRegister();
|
|
311
|
+
setStatus(true, label());
|
|
312
|
+
};
|
|
313
|
+
sock.onmessage = (e) => {
|
|
314
|
+
const msg = JSON.parse(e.data);
|
|
315
|
+
if (msg.hello) {
|
|
316
|
+
// Show which server build we are talking to; "dev" means a local
|
|
317
|
+
// go build rather than a release.
|
|
318
|
+
if (msg.hello.version) {
|
|
319
|
+
document.getElementById("version").textContent = "v" + msg.hello.version;
|
|
320
|
+
}
|
|
321
|
+
// Name the side that is behind: a stale server is just as common as
|
|
322
|
+
// a stale plugin, and blaming the plugin sends people re-importing
|
|
323
|
+
// a plugin that was already up to date.
|
|
324
|
+
protoWarning = null;
|
|
325
|
+
if (msg.hello.protocol && msg.hello.protocol !== PROTOCOL) {
|
|
326
|
+
// npx figrelay@latest only fetches the new server when the AI client restarts it.
|
|
327
|
+
protoWarning =
|
|
328
|
+
msg.hello.protocol < PROTOCOL
|
|
329
|
+
? "⚠ Server outdated — restart your AI client"
|
|
330
|
+
: "⚠ Plugin outdated — re-import from the latest release";
|
|
331
|
+
}
|
|
332
|
+
setStatus(true, label());
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// Relay server request to the plugin main thread.
|
|
336
|
+
parent.postMessage({ pluginMessage: msg }, "*");
|
|
337
|
+
};
|
|
338
|
+
sock.onclose = () => {
|
|
339
|
+
if (attempt === sock) attempt = null;
|
|
340
|
+
if (ws === sock) ws = null;
|
|
341
|
+
if (!ws) {
|
|
342
|
+
setStatus(false, "Searching for MCP server…");
|
|
343
|
+
schedule();
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
sock.onerror = () => {
|
|
347
|
+
try {
|
|
348
|
+
sock.close();
|
|
349
|
+
} catch (e) { }
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let retryTimer = null;
|
|
354
|
+
function schedule() {
|
|
355
|
+
if (retryTimer) return;
|
|
356
|
+
retryTimer = setTimeout(() => {
|
|
357
|
+
retryTimer = null;
|
|
358
|
+
connect();
|
|
359
|
+
}, RETRY);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---- live selection list (name + copyable link, filterable by type) ----
|
|
363
|
+
let selItems = []; // everything currently selected in Figma
|
|
364
|
+
let shownItems = []; // selItems after the active type filter
|
|
365
|
+
let selFilter = "all"; // all | frame | vector
|
|
366
|
+
let hasKey = false; // whether the main thread knows this file's key
|
|
367
|
+
|
|
368
|
+
// Pull the file key out of a pasted Figma URL (…/design/<key>/…), or accept
|
|
369
|
+
// a bare key. Returns "" when nothing usable is found.
|
|
370
|
+
function parseFileKey(s) {
|
|
371
|
+
s = (s || "").trim();
|
|
372
|
+
const m = s.match(/figma\.com\/(?:file|design|board|proto)\/([A-Za-z0-9]+)/i);
|
|
373
|
+
if (m) return m[1];
|
|
374
|
+
if (/^[A-Za-z0-9]{10,}$/.test(s)) return s;
|
|
375
|
+
return "";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const FILTERS = [
|
|
379
|
+
{ key: "all", label: "All" },
|
|
380
|
+
{ key: "frame", label: "Frame" },
|
|
381
|
+
{ key: "vector", label: "Vector" },
|
|
382
|
+
{ key: "instance", label: "Instance" },
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
function esc(s) {
|
|
386
|
+
return String(s)
|
|
387
|
+
.replace(/&/g, "&")
|
|
388
|
+
.replace(/</g, "<")
|
|
389
|
+
.replace(/>/g, ">")
|
|
390
|
+
.replace(/"/g, """);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function matches(it) {
|
|
394
|
+
if (selFilter === "frame") return it.type === "FRAME";
|
|
395
|
+
if (selFilter === "vector") return it.type === "VECTOR";
|
|
396
|
+
if (selFilter === "instance") return it.type === "INSTANCE";
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Frames copy as a link block; every other element copies its exported SVG.
|
|
401
|
+
function copyLabelFor(it) {
|
|
402
|
+
return it.type === "FRAME" ? "Copy" : "Copy SVG";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Copyable text for one item. Frames use the requested link format:
|
|
406
|
+
// # <name>:
|
|
407
|
+
// <link>
|
|
408
|
+
// Other elements return their SVG (or the export error, so the click still
|
|
409
|
+
// gives feedback instead of silently copying nothing).
|
|
410
|
+
function copyValueForItem(it) {
|
|
411
|
+
if (it.type === "FRAME") return "# " + it.name + ":\n" + (it.link || "");
|
|
412
|
+
return it.svg || it.svgError || "";
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function renderSelection(items) {
|
|
416
|
+
if (items) selItems = items;
|
|
417
|
+
drawSelection();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function drawSelection() {
|
|
421
|
+
const box = document.getElementById("result");
|
|
422
|
+
if (!selItems.length) {
|
|
423
|
+
box.className = "";
|
|
424
|
+
box.innerHTML = "";
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
shownItems = selItems.filter(matches);
|
|
428
|
+
|
|
429
|
+
// Only frames need a link (and therefore the file key); a pure vector /
|
|
430
|
+
// instance selection copies SVG, so don't nag for a URL then.
|
|
431
|
+
const needsKey = !hasKey && selItems.some((it) => it.type === "FRAME");
|
|
432
|
+
const keyPrompt = !needsKey
|
|
433
|
+
? ""
|
|
434
|
+
: '<div class="key-prompt">' +
|
|
435
|
+
'<div class="key-note">Links are empty: this file has no key yet. Paste the file URL once ' +
|
|
436
|
+
"(Share → Copy link, or the browser address bar):</div>" +
|
|
437
|
+
'<div class="key-input-row">' +
|
|
438
|
+
'<input class="key-input" id="key-input" placeholder="https://www.figma.com/design/…" />' +
|
|
439
|
+
'<button class="key-save" id="key-save">Save</button>' +
|
|
440
|
+
"</div></div>";
|
|
441
|
+
|
|
442
|
+
const filterBar =
|
|
443
|
+
'<div class="filter-row">' +
|
|
444
|
+
FILTERS.map(
|
|
445
|
+
(f) =>
|
|
446
|
+
'<button class="filter-btn' + (selFilter === f.key ? " on" : "") + '" data-f="' + f.key + '">' +
|
|
447
|
+
f.label + "</button>",
|
|
448
|
+
).join("") +
|
|
449
|
+
"</div>";
|
|
450
|
+
|
|
451
|
+
const head =
|
|
452
|
+
'<div class="res-head"><span class="res-title">Selected (' + shownItems.length + ")</span>" +
|
|
453
|
+
(shownItems.length > 1 ? '<button class="copy-btn copy-all" data-all="1">Copy all</button>' : "") +
|
|
454
|
+
"</div>";
|
|
455
|
+
|
|
456
|
+
const body = shownItems.length
|
|
457
|
+
? '<div class="res-list">' +
|
|
458
|
+
shownItems
|
|
459
|
+
.map(
|
|
460
|
+
(it, i) =>
|
|
461
|
+
'<div class="res-row">' +
|
|
462
|
+
'<span class="res-name" title="' + esc(it.name) + '">' + esc(it.name) + "</span>" +
|
|
463
|
+
'<span class="res-type">' + esc(it.type) + "</span>" +
|
|
464
|
+
'<button class="copy-btn" data-i="' + i + '">' + copyLabelFor(it) + "</button>" +
|
|
465
|
+
"</div>",
|
|
466
|
+
)
|
|
467
|
+
.join("") +
|
|
468
|
+
"</div>"
|
|
469
|
+
: '<div class="res-empty">No matching nodes selected.</div>';
|
|
470
|
+
|
|
471
|
+
box.innerHTML = keyPrompt + filterBar + head + body;
|
|
472
|
+
box.className = "show";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// What each button copies: a row copies its own value (link block for a
|
|
476
|
+
// frame, SVG otherwise); "Copy all" joins every shown item with a blank
|
|
477
|
+
// line between — filter to one type first for a homogeneous result.
|
|
478
|
+
function copyValueFor(btn) {
|
|
479
|
+
if (btn.hasAttribute("data-all")) {
|
|
480
|
+
return shownItems.map(copyValueForItem).join("\n\n");
|
|
481
|
+
}
|
|
482
|
+
const it = shownItems[+btn.getAttribute("data-i")];
|
|
483
|
+
return it ? copyValueForItem(it) : "";
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// navigator.clipboard is often unavailable in the plugin iframe, so use the
|
|
487
|
+
// textarea + execCommand fallback which works reliably here.
|
|
488
|
+
function copyText(text) {
|
|
489
|
+
const ta = document.createElement("textarea");
|
|
490
|
+
ta.value = text;
|
|
491
|
+
ta.style.position = "fixed";
|
|
492
|
+
ta.style.opacity = "0";
|
|
493
|
+
document.body.appendChild(ta);
|
|
494
|
+
ta.select();
|
|
495
|
+
try {
|
|
496
|
+
document.execCommand("copy");
|
|
497
|
+
} catch (e) { }
|
|
498
|
+
document.body.removeChild(ta);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
document.getElementById("result").addEventListener("click", (e) => {
|
|
502
|
+
if (e.target.id === "key-save") {
|
|
503
|
+
const inp = document.getElementById("key-input");
|
|
504
|
+
const key = parseFileKey(inp ? inp.value : "");
|
|
505
|
+
if (key) {
|
|
506
|
+
parent.postMessage({ pluginMessage: { setFileKey: key } }, "*");
|
|
507
|
+
} else if (inp) {
|
|
508
|
+
inp.value = "";
|
|
509
|
+
inp.placeholder = "Couldn't read a file key from that — paste the full URL";
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const fb = e.target.closest(".filter-btn");
|
|
514
|
+
if (fb) {
|
|
515
|
+
selFilter = fb.getAttribute("data-f");
|
|
516
|
+
drawSelection();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const btn = e.target.closest(".copy-btn");
|
|
520
|
+
if (!btn) return;
|
|
521
|
+
copyText(copyValueFor(btn));
|
|
522
|
+
const old = btn.textContent;
|
|
523
|
+
btn.textContent = "Copied";
|
|
524
|
+
btn.classList.add("done");
|
|
525
|
+
setTimeout(() => {
|
|
526
|
+
btn.textContent = old;
|
|
527
|
+
btn.classList.remove("done");
|
|
528
|
+
}, 1200);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Relay plugin main-thread frames: the register frame is cached here and
|
|
532
|
+
// re-sent on every reconnect, the selection frame drives the list above, and
|
|
533
|
+
// everything else goes to the server.
|
|
534
|
+
window.onmessage = (e) => {
|
|
535
|
+
const msg = e.data.pluginMessage;
|
|
536
|
+
if (!msg) return;
|
|
537
|
+
if (msg.register) {
|
|
538
|
+
reg = msg.register;
|
|
539
|
+
sendRegister();
|
|
540
|
+
if (ws) setStatus(true, label());
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (Array.isArray(msg.selection)) {
|
|
544
|
+
hasKey = !!msg.hasKey;
|
|
545
|
+
renderSelection(msg.selection);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
549
|
+
ws.send(JSON.stringify(msg));
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
document.getElementById("status-row").onclick = () => connect();
|
|
554
|
+
document.addEventListener("visibilitychange", () => {
|
|
555
|
+
if (!document.hidden && !ws) connect();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
connect();
|
|
559
|
+
</script>
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figrelay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
}
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"prepublishOnly": "cp ../README.md ./README.md"
|
|
6
|
+
},
|
|
7
|
+
"description": "Figma MCP server (fork of figma-mcp-console) for AI coding assistants — real-time read/write via a Figma Desktop plugin. No API token, no rate limits.",
|
|
8
|
+
"bin": {
|
|
9
|
+
"figrelay": "run.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"run.js",
|
|
13
|
+
"bin/"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/quannh287/figma-relay.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/quannh287/figma-relay#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/quannh287/figma-relay/issues"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"figma",
|
|
28
|
+
"mcp",
|
|
29
|
+
"model-context-protocol",
|
|
30
|
+
"claude",
|
|
31
|
+
"cursor",
|
|
32
|
+
"figma-mcp-console",
|
|
33
|
+
"design"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT"
|
|
36
|
+
}
|
package/run.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run the Go binary bundled in the npm package — NO network/GitHub download.
|
|
3
|
+
// stdout is the MCP transport → all wrapper logs go to stderr.
|
|
4
|
+
"use strict";
|
|
5
|
+
const { spawnSync } = require("child_process");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
const plat = process.platform === "win32" ? "windows" : process.platform; // darwin|linux|windows
|
|
11
|
+
const arch = { x64: "amd64", arm64: "arm64" }[process.arch];
|
|
12
|
+
function fail(m) {
|
|
13
|
+
process.stderr.write(`figrelay: ${m}\n`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
if (!arch || !["darwin", "linux", "windows"].includes(plat))
|
|
17
|
+
fail(`unsupported platform: ${process.platform}/${process.arch}`);
|
|
18
|
+
|
|
19
|
+
const exe = plat === "windows" ? ".exe" : "";
|
|
20
|
+
const binPath = path.join(
|
|
21
|
+
__dirname,
|
|
22
|
+
"bin",
|
|
23
|
+
`${plat}-${arch}`,
|
|
24
|
+
`figma-mcp${exe}`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Subcommand: install-plugin — extract the plugin into a VISIBLE folder (no leading dot)
|
|
28
|
+
// and open that folder so the user sees manifest.json right away.
|
|
29
|
+
if (process.argv[2] === "install-plugin") {
|
|
30
|
+
const srcDir = path.join(__dirname, "bin", "plugin");
|
|
31
|
+
const destDir = path.join(os.homedir(), "figrelay-plugin");
|
|
32
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
33
|
+
for (const f of ["code.js", "ui.html", "manifest.json"])
|
|
34
|
+
fs.copyFileSync(path.join(srcDir, f), path.join(destDir, f));
|
|
35
|
+
|
|
36
|
+
process.stdout.write(
|
|
37
|
+
"\n✅ Figma plugin ready.\n\n" +
|
|
38
|
+
"In Figma Desktop → Plugins → Development → Import plugin from manifest…\n" +
|
|
39
|
+
"and select this file:\n\n" +
|
|
40
|
+
" " +
|
|
41
|
+
path.join(destDir, "manifest.json") +
|
|
42
|
+
"\n\n",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Open the folder in the file manager (best-effort; ignore errors).
|
|
46
|
+
const opener =
|
|
47
|
+
plat === "darwin" ? "open" : plat === "windows" ? "explorer" : "xdg-open";
|
|
48
|
+
try {
|
|
49
|
+
spawnSync(opener, [destDir], { stdio: "ignore", detached: true });
|
|
50
|
+
} catch (_) {}
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!fs.existsSync(binPath)) fail(`binary not found for ${plat}-${arch}`);
|
|
55
|
+
try {
|
|
56
|
+
fs.chmodSync(binPath, 0o755);
|
|
57
|
+
} catch (_) {}
|
|
58
|
+
const r = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" });
|
|
59
|
+
if (r.error) fail(`failed to start server: ${r.error.message}`);
|
|
60
|
+
process.exit(r.status === null ? 1 : r.status);
|