@createlex/figma-swiftui-mcp 1.0.0
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 +384 -0
- package/bin/figma-swiftui-mcp.js +81 -0
- package/companion/bridge-server.cjs +599 -0
- package/companion/createlex-auth.cjs +364 -0
- package/companion/login.mjs +190 -0
- package/companion/mcp-server.mjs +788 -0
- package/companion/package.json +17 -0
- package/companion/server.js +64 -0
- package/companion/xcode-writer.cjs +429 -0
- package/package.json +39 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const cors = require('cors');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const { execFile } = require('child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { WebSocketServer, WebSocket } = require('ws');
|
|
8
|
+
const {
|
|
9
|
+
GENERATED_ROOT_DIRNAME,
|
|
10
|
+
GENERATED_LAYOUT_VERSION,
|
|
11
|
+
getSavedProjectPath,
|
|
12
|
+
resolveWritableProjectPath,
|
|
13
|
+
setSavedProjectPath,
|
|
14
|
+
writeSwiftUIScreen,
|
|
15
|
+
} = require('./xcode-writer.cjs');
|
|
16
|
+
|
|
17
|
+
const DEFAULT_PORT = 7765;
|
|
18
|
+
const DEFAULT_HOST = 'localhost';
|
|
19
|
+
const BRIDGE_PROTOCOL_VERSION = 1;
|
|
20
|
+
const SUPPORTED_BRIDGE_ACTIONS = [
|
|
21
|
+
'ping',
|
|
22
|
+
'get_document_summary',
|
|
23
|
+
'get_metadata',
|
|
24
|
+
'get_design_context',
|
|
25
|
+
'get_screenshot',
|
|
26
|
+
'export_svg',
|
|
27
|
+
'get_viewport_context',
|
|
28
|
+
'get_selection_snapshot',
|
|
29
|
+
'get_page_snapshot',
|
|
30
|
+
'get_node_snapshot',
|
|
31
|
+
'find_nodes',
|
|
32
|
+
'dump_tree',
|
|
33
|
+
'get_asset_export_plan',
|
|
34
|
+
'extract_reusable_components',
|
|
35
|
+
'generate_swiftui',
|
|
36
|
+
'analyze_generation',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function normalizeLogger(logger = {}) {
|
|
40
|
+
return {
|
|
41
|
+
info: typeof logger.info === 'function' ? logger.info.bind(logger) : console.log,
|
|
42
|
+
warn: typeof logger.warn === 'function' ? logger.warn.bind(logger) : console.warn,
|
|
43
|
+
error: typeof logger.error === 'function' ? logger.error.bind(logger) : console.error,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function waitForExistingBridge({ host, port }) {
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(`http://${host}:${port}/bridge/info`);
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`HTTP ${response.status}`);
|
|
52
|
+
}
|
|
53
|
+
const data = await response.json();
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
alreadyRunning: true,
|
|
57
|
+
info: data,
|
|
58
|
+
};
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new Error(`Port ${port} is already in use and does not look like a Figma SwiftUI bridge`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function startBridgeServer(options = {}) {
|
|
65
|
+
const port = Number(options.port || process.env.FIGMA_SWIFTUI_BRIDGE_PORT || DEFAULT_PORT);
|
|
66
|
+
const host = options.host || process.env.FIGMA_SWIFTUI_BRIDGE_HOST || DEFAULT_HOST;
|
|
67
|
+
const logger = normalizeLogger(options.logger);
|
|
68
|
+
const app = express();
|
|
69
|
+
const server = http.createServer(app);
|
|
70
|
+
|
|
71
|
+
let projectPath = options.projectPath ? setSavedProjectPath(options.projectPath) : getSavedProjectPath();
|
|
72
|
+
let pluginBridgeClient = null;
|
|
73
|
+
const agentBridgeClients = new Set();
|
|
74
|
+
const passivePluginClients = new Set();
|
|
75
|
+
const pendingBridgeRequests = new Map();
|
|
76
|
+
|
|
77
|
+
app.use(cors({ origin: '*' }));
|
|
78
|
+
app.use(express.json({ limit: '50mb' }));
|
|
79
|
+
|
|
80
|
+
app.get('/ping', (req, res) => {
|
|
81
|
+
res.json({
|
|
82
|
+
ok: true,
|
|
83
|
+
projectPath: projectPath || null,
|
|
84
|
+
generatedRoot: projectPath ? path.join(projectPath, GENERATED_ROOT_DIRNAME) : null,
|
|
85
|
+
layoutVersion: GENERATED_LAYOUT_VERSION,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
app.get('/bridge/info', (req, res) => {
|
|
90
|
+
res.json({
|
|
91
|
+
ok: true,
|
|
92
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
93
|
+
wsUrl: `ws://${host}:${port}/bridge`,
|
|
94
|
+
pluginConnected: !!pluginBridgeClient,
|
|
95
|
+
connectedAgents: agentBridgeClients.size,
|
|
96
|
+
pendingRequests: pendingBridgeRequests.size,
|
|
97
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
app.post('/set-project', (req, res) => {
|
|
102
|
+
const { path: inputPath } = req.body;
|
|
103
|
+
if (!inputPath) return res.status(400).json({ error: 'path required' });
|
|
104
|
+
const resolved = resolveWritableProjectPath(inputPath);
|
|
105
|
+
if (!fs.existsSync(resolved)) {
|
|
106
|
+
return res.status(400).json({ error: `Path does not exist: ${resolved}` });
|
|
107
|
+
}
|
|
108
|
+
projectPath = setSavedProjectPath(resolved);
|
|
109
|
+
logger.info(`â
Project path set: ${projectPath}`);
|
|
110
|
+
res.json({ ok: true, projectPath });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
app.post('/write', (req, res) => {
|
|
114
|
+
const { code, structName, images, customPath } = req.body;
|
|
115
|
+
|
|
116
|
+
const target = customPath ? resolveWritableProjectPath(customPath) : projectPath;
|
|
117
|
+
|
|
118
|
+
if (!target) {
|
|
119
|
+
return res.status(400).json({
|
|
120
|
+
error: 'No project path set. Pass --project /path/to/MyApp when starting the MCP server, or set it from the plugin UI/MCP.',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!fs.existsSync(target)) {
|
|
125
|
+
return res.status(400).json({ error: `Project path does not exist: ${target}` });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
projectPath = target;
|
|
129
|
+
if (customPath && path.resolve(customPath) !== target) {
|
|
130
|
+
logger.info(`đ Resolved project path: ${path.resolve(customPath)} â ${target}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
projectPath = setSavedProjectPath(target);
|
|
134
|
+
const writeResult = writeSwiftUIScreen({
|
|
135
|
+
targetDir: target,
|
|
136
|
+
code,
|
|
137
|
+
structName,
|
|
138
|
+
images: Array.isArray(images) ? images : [],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!writeResult.ok) {
|
|
142
|
+
return res.status(207).json({
|
|
143
|
+
ok: false,
|
|
144
|
+
results: writeResult.results,
|
|
145
|
+
projectPath: writeResult.projectPath,
|
|
146
|
+
generatedRoot: writeResult.generatedRoot,
|
|
147
|
+
layoutVersion: GENERATED_LAYOUT_VERSION,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
res.json({
|
|
152
|
+
ok: true,
|
|
153
|
+
results: writeResult.results,
|
|
154
|
+
projectPath: writeResult.projectPath,
|
|
155
|
+
generatedRoot: writeResult.generatedRoot,
|
|
156
|
+
layoutVersion: GENERATED_LAYOUT_VERSION,
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
app.post('/choose-project', (req, res) => {
|
|
161
|
+
if (process.platform !== 'darwin') {
|
|
162
|
+
return res.status(501).json({
|
|
163
|
+
error: 'Native project folder chooser is only implemented for macOS. Enter the path manually in the plugin UI or set it from MCP.',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const script = [
|
|
168
|
+
'set chosenFolder to choose folder with prompt "Select the Xcode project source folder"',
|
|
169
|
+
'POSIX path of chosenFolder',
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
execFile('osascript', script.flatMap((line) => ['-e', line]), (err, stdout, stderr) => {
|
|
173
|
+
if (err) {
|
|
174
|
+
const details = `${stderr || ''} ${err.message || ''}`.trim();
|
|
175
|
+
if (details.includes('User canceled') || details.includes('(-128)')) {
|
|
176
|
+
return res.json({ ok: false, canceled: true });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return res.status(500).json({
|
|
180
|
+
error: `Failed to choose a project folder: ${details || 'Unknown error'}`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const resolved = path.resolve(stdout.trim());
|
|
185
|
+
const writeTarget = resolveWritableProjectPath(resolved);
|
|
186
|
+
if (!fs.existsSync(writeTarget)) {
|
|
187
|
+
return res.status(400).json({ error: `Chosen path does not exist: ${writeTarget}` });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
projectPath = setSavedProjectPath(writeTarget);
|
|
191
|
+
logger.info(`â
Project path set: ${projectPath}`);
|
|
192
|
+
res.json({ ok: true, projectPath });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
function sendBridgeMessage(ws, payload) {
|
|
197
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
ws.send(JSON.stringify(payload));
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function broadcastBridgeStatus() {
|
|
206
|
+
const payload = {
|
|
207
|
+
type: 'bridge-status',
|
|
208
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
209
|
+
pluginConnected: !!pluginBridgeClient,
|
|
210
|
+
connectedAgents: agentBridgeClients.size,
|
|
211
|
+
pendingRequests: pendingBridgeRequests.size,
|
|
212
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
213
|
+
timestamp: new Date().toISOString(),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (pluginBridgeClient) {
|
|
217
|
+
sendBridgeMessage(pluginBridgeClient, payload);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const client of agentBridgeClients) {
|
|
221
|
+
sendBridgeMessage(client, payload);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const client of passivePluginClients) {
|
|
225
|
+
sendBridgeMessage(client, payload);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function releasePendingRequestsForSocket(socket, reason) {
|
|
230
|
+
for (const [requestId, pending] of pendingBridgeRequests.entries()) {
|
|
231
|
+
if (pending.origin !== socket) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
pendingBridgeRequests.delete(requestId);
|
|
236
|
+
sendBridgeMessage(socket, {
|
|
237
|
+
type: 'bridge-response',
|
|
238
|
+
requestId,
|
|
239
|
+
action: pending.action,
|
|
240
|
+
ok: false,
|
|
241
|
+
error: reason,
|
|
242
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
243
|
+
timestamp: new Date().toISOString(),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function failAllPendingBridgeRequests(reason) {
|
|
249
|
+
for (const [requestId, pending] of pendingBridgeRequests.entries()) {
|
|
250
|
+
sendBridgeMessage(pending.origin, {
|
|
251
|
+
type: 'bridge-response',
|
|
252
|
+
requestId,
|
|
253
|
+
action: pending.action,
|
|
254
|
+
ok: false,
|
|
255
|
+
error: reason,
|
|
256
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
257
|
+
timestamp: new Date().toISOString(),
|
|
258
|
+
});
|
|
259
|
+
pendingBridgeRequests.delete(requestId);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function attachBridgeRole(socket, role) {
|
|
264
|
+
socket.bridgeRole = role;
|
|
265
|
+
|
|
266
|
+
if (role === 'plugin-ui') {
|
|
267
|
+
if (pluginBridgeClient && pluginBridgeClient !== socket && pluginBridgeClient.readyState === WebSocket.OPEN) {
|
|
268
|
+
socket.bridgeRole = 'plugin-ui-passive';
|
|
269
|
+
agentBridgeClients.delete(socket);
|
|
270
|
+
passivePluginClients.add(socket);
|
|
271
|
+
|
|
272
|
+
sendBridgeMessage(socket, {
|
|
273
|
+
type: 'hello-ack',
|
|
274
|
+
role: 'plugin-ui-passive',
|
|
275
|
+
accepted: false,
|
|
276
|
+
reason: 'Another Figma plugin window is already attached to the bridge',
|
|
277
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
278
|
+
pluginConnected: true,
|
|
279
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
280
|
+
timestamp: new Date().toISOString(),
|
|
281
|
+
});
|
|
282
|
+
broadcastBridgeStatus();
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
pluginBridgeClient = socket;
|
|
287
|
+
passivePluginClients.delete(socket);
|
|
288
|
+
agentBridgeClients.delete(socket);
|
|
289
|
+
} else {
|
|
290
|
+
passivePluginClients.delete(socket);
|
|
291
|
+
agentBridgeClients.add(socket);
|
|
292
|
+
if (pluginBridgeClient === socket) {
|
|
293
|
+
pluginBridgeClient = null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
broadcastBridgeStatus();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function demoteActivePlugin(reason = 'Another Figma plugin window took the bridge') {
|
|
302
|
+
if (!pluginBridgeClient || pluginBridgeClient.readyState !== WebSocket.OPEN) {
|
|
303
|
+
pluginBridgeClient = null;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const previous = pluginBridgeClient;
|
|
308
|
+
pluginBridgeClient = null;
|
|
309
|
+
previous.bridgeRole = 'plugin-ui-passive';
|
|
310
|
+
passivePluginClients.add(previous);
|
|
311
|
+
|
|
312
|
+
sendBridgeMessage(previous, {
|
|
313
|
+
type: 'hello-ack',
|
|
314
|
+
role: 'plugin-ui-passive',
|
|
315
|
+
accepted: false,
|
|
316
|
+
reason,
|
|
317
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
318
|
+
pluginConnected: true,
|
|
319
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
320
|
+
timestamp: new Date().toISOString(),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const bridgeWss = new WebSocketServer({ server, path: '/bridge' });
|
|
325
|
+
|
|
326
|
+
bridgeWss.on('error', (error) => {
|
|
327
|
+
if (error && error.code === 'EADDRINUSE') {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
logger.error('[figma-swiftui-bridge] WebSocket server error:', error);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
bridgeWss.on('connection', (socket) => {
|
|
334
|
+
socket.bridgeRole = 'agent';
|
|
335
|
+
agentBridgeClients.add(socket);
|
|
336
|
+
|
|
337
|
+
sendBridgeMessage(socket, {
|
|
338
|
+
type: 'bridge-status',
|
|
339
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
340
|
+
pluginConnected: !!pluginBridgeClient,
|
|
341
|
+
connectedAgents: agentBridgeClients.size,
|
|
342
|
+
pendingRequests: pendingBridgeRequests.size,
|
|
343
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
344
|
+
timestamp: new Date().toISOString(),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
socket.on('message', (rawMessage) => {
|
|
348
|
+
let message;
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
message = JSON.parse(rawMessage.toString());
|
|
352
|
+
} catch (err) {
|
|
353
|
+
sendBridgeMessage(socket, {
|
|
354
|
+
type: 'bridge-error',
|
|
355
|
+
error: 'Invalid JSON message',
|
|
356
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
357
|
+
timestamp: new Date().toISOString(),
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (message.type === 'hello') {
|
|
363
|
+
if (message.role === 'plugin-ui' && message.takeover === true) {
|
|
364
|
+
demoteActivePlugin('Another Figma plugin window took the bridge');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const attached = attachBridgeRole(socket, message.role === 'plugin-ui' ? 'plugin-ui' : 'agent');
|
|
368
|
+
if (attached === false) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
sendBridgeMessage(socket, {
|
|
372
|
+
type: 'hello-ack',
|
|
373
|
+
role: socket.bridgeRole,
|
|
374
|
+
accepted: true,
|
|
375
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
376
|
+
pluginConnected: !!pluginBridgeClient,
|
|
377
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
378
|
+
timestamp: new Date().toISOString(),
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (message.type === 'bridge-control') {
|
|
384
|
+
if (message.action === 'takeover-plugin-ui') {
|
|
385
|
+
if (socket.bridgeRole !== 'plugin-ui-passive') {
|
|
386
|
+
sendBridgeMessage(socket, {
|
|
387
|
+
type: 'bridge-error',
|
|
388
|
+
error: 'Only a passive Figma plugin window can request takeover',
|
|
389
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
390
|
+
timestamp: new Date().toISOString(),
|
|
391
|
+
});
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
demoteActivePlugin('Another Figma plugin window took the bridge');
|
|
396
|
+
passivePluginClients.delete(socket);
|
|
397
|
+
pluginBridgeClient = socket;
|
|
398
|
+
socket.bridgeRole = 'plugin-ui';
|
|
399
|
+
sendBridgeMessage(socket, {
|
|
400
|
+
type: 'hello-ack',
|
|
401
|
+
role: 'plugin-ui',
|
|
402
|
+
accepted: true,
|
|
403
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
404
|
+
pluginConnected: true,
|
|
405
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
406
|
+
timestamp: new Date().toISOString(),
|
|
407
|
+
});
|
|
408
|
+
broadcastBridgeStatus();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
sendBridgeMessage(socket, {
|
|
413
|
+
type: 'bridge-error',
|
|
414
|
+
error: `Unsupported bridge control action: ${message.action || 'unknown'}`,
|
|
415
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
416
|
+
timestamp: new Date().toISOString(),
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (message.type === 'bridge-request') {
|
|
422
|
+
const requestId = typeof message.requestId === 'string' && message.requestId
|
|
423
|
+
? message.requestId
|
|
424
|
+
: `bridge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
425
|
+
|
|
426
|
+
if (!pluginBridgeClient || pluginBridgeClient.readyState !== WebSocket.OPEN) {
|
|
427
|
+
sendBridgeMessage(socket, {
|
|
428
|
+
type: 'bridge-response',
|
|
429
|
+
requestId,
|
|
430
|
+
action: message.action || 'unknown',
|
|
431
|
+
ok: false,
|
|
432
|
+
error: 'No plugin session is connected to the bridge',
|
|
433
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
434
|
+
timestamp: new Date().toISOString(),
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
pendingBridgeRequests.set(requestId, {
|
|
440
|
+
origin: socket,
|
|
441
|
+
action: message.action || 'unknown',
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
sendBridgeMessage(pluginBridgeClient, {
|
|
445
|
+
type: 'bridge-request',
|
|
446
|
+
requestId,
|
|
447
|
+
action: message.action,
|
|
448
|
+
params: message.params || {},
|
|
449
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
450
|
+
timestamp: new Date().toISOString(),
|
|
451
|
+
});
|
|
452
|
+
broadcastBridgeStatus();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (message.type === 'bridge-response') {
|
|
457
|
+
const pending = pendingBridgeRequests.get(message.requestId);
|
|
458
|
+
if (!pending) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
pendingBridgeRequests.delete(message.requestId);
|
|
463
|
+
sendBridgeMessage(pending.origin, {
|
|
464
|
+
...message,
|
|
465
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
466
|
+
timestamp: new Date().toISOString(),
|
|
467
|
+
});
|
|
468
|
+
broadcastBridgeStatus();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (message.type === 'bridge-event') {
|
|
473
|
+
const payload = {
|
|
474
|
+
...message,
|
|
475
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
476
|
+
timestamp: message.timestamp || new Date().toISOString(),
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
for (const client of agentBridgeClients) {
|
|
480
|
+
if (client !== socket) {
|
|
481
|
+
sendBridgeMessage(client, payload);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
sendBridgeMessage(socket, {
|
|
488
|
+
type: 'bridge-error',
|
|
489
|
+
error: `Unsupported bridge message type: ${message.type || 'unknown'}`,
|
|
490
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
491
|
+
timestamp: new Date().toISOString(),
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
socket.on('close', () => {
|
|
496
|
+
if (pluginBridgeClient === socket) {
|
|
497
|
+
pluginBridgeClient = null;
|
|
498
|
+
failAllPendingBridgeRequests('Plugin session disconnected from bridge');
|
|
499
|
+
const nextPlugin = Array.from(passivePluginClients).find((client) => client.readyState === WebSocket.OPEN) || null;
|
|
500
|
+
if (nextPlugin) {
|
|
501
|
+
passivePluginClients.delete(nextPlugin);
|
|
502
|
+
pluginBridgeClient = nextPlugin;
|
|
503
|
+
nextPlugin.bridgeRole = 'plugin-ui';
|
|
504
|
+
sendBridgeMessage(nextPlugin, {
|
|
505
|
+
type: 'hello-ack',
|
|
506
|
+
role: 'plugin-ui',
|
|
507
|
+
accepted: true,
|
|
508
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
509
|
+
pluginConnected: true,
|
|
510
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
511
|
+
timestamp: new Date().toISOString(),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
} else if (passivePluginClients.has(socket)) {
|
|
515
|
+
passivePluginClients.delete(socket);
|
|
516
|
+
} else {
|
|
517
|
+
agentBridgeClients.delete(socket);
|
|
518
|
+
releasePendingRequestsForSocket(socket, 'Agent disconnected from bridge');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
broadcastBridgeStatus();
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
return new Promise((resolve, reject) => {
|
|
526
|
+
server.once('error', async (error) => {
|
|
527
|
+
if (error && error.code === 'EADDRINUSE') {
|
|
528
|
+
try {
|
|
529
|
+
const running = await waitForExistingBridge({ host, port });
|
|
530
|
+
logger.warn(`âšī¸ Using existing bridge at http://${host}:${port}`);
|
|
531
|
+
resolve({
|
|
532
|
+
app,
|
|
533
|
+
server: null,
|
|
534
|
+
bridgeWss: null,
|
|
535
|
+
port,
|
|
536
|
+
host,
|
|
537
|
+
alreadyRunning: true,
|
|
538
|
+
projectPath,
|
|
539
|
+
bridgeInfo: running.info,
|
|
540
|
+
getProjectPath: () => projectPath,
|
|
541
|
+
getBridgeInfo: () => running.info,
|
|
542
|
+
});
|
|
543
|
+
return;
|
|
544
|
+
} catch (existingError) {
|
|
545
|
+
reject(existingError);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
reject(error);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
server.listen(port, host, () => {
|
|
554
|
+
logger.info(`đ Figma SwiftUI bridge running at http://${host}:${port}`);
|
|
555
|
+
if (projectPath) {
|
|
556
|
+
logger.info(`đ Project path: ${projectPath}`);
|
|
557
|
+
} else {
|
|
558
|
+
logger.warn('â ī¸ No project path set. Use --project /path/to/MyApp/MyApp or set it from MCP/plugin UI.');
|
|
559
|
+
}
|
|
560
|
+
logger.info(`đ Bridge ready at ws://${host}:${port}/bridge`);
|
|
561
|
+
|
|
562
|
+
resolve({
|
|
563
|
+
app,
|
|
564
|
+
server,
|
|
565
|
+
bridgeWss,
|
|
566
|
+
port,
|
|
567
|
+
host,
|
|
568
|
+
alreadyRunning: false,
|
|
569
|
+
getProjectPath: () => projectPath,
|
|
570
|
+
getBridgeInfo: () => ({
|
|
571
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
572
|
+
pluginConnected: !!pluginBridgeClient,
|
|
573
|
+
connectedAgents: agentBridgeClients.size,
|
|
574
|
+
pendingRequests: pendingBridgeRequests.size,
|
|
575
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
576
|
+
}),
|
|
577
|
+
close: () => new Promise((closeResolve, closeReject) => {
|
|
578
|
+
bridgeWss.close(() => {
|
|
579
|
+
server.close((closeError) => {
|
|
580
|
+
if (closeError) {
|
|
581
|
+
closeReject(closeError);
|
|
582
|
+
} else {
|
|
583
|
+
closeResolve();
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
}),
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
module.exports = {
|
|
594
|
+
DEFAULT_HOST,
|
|
595
|
+
DEFAULT_PORT,
|
|
596
|
+
BRIDGE_PROTOCOL_VERSION,
|
|
597
|
+
SUPPORTED_BRIDGE_ACTIONS,
|
|
598
|
+
startBridgeServer,
|
|
599
|
+
};
|