@createlex/figgen 1.4.2
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 +164 -0
- package/bin/figgen.js +156 -0
- package/companion/bridge-server.cjs +786 -0
- package/companion/createlex-auth.cjs +364 -0
- package/companion/local-llm-generator.cjs +437 -0
- package/companion/login.mjs +189 -0
- package/companion/mcp-server.mjs +1365 -0
- package/companion/package.json +17 -0
- package/companion/server.js +65 -0
- package/companion/setup.cjs +309 -0
- package/companion/xcode-writer.cjs +516 -0
- package/package.json +50 -0
|
@@ -0,0 +1,786 @@
|
|
|
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
|
+
const { buildGenerationPrompt } = require('./local-llm-generator.cjs');
|
|
17
|
+
|
|
18
|
+
const DEFAULT_PORT = 7765;
|
|
19
|
+
const DEFAULT_HOST = 'localhost';
|
|
20
|
+
const BRIDGE_PROTOCOL_VERSION = 1;
|
|
21
|
+
const SUPPORTED_BRIDGE_ACTIONS = [
|
|
22
|
+
'ping',
|
|
23
|
+
'get_document_summary',
|
|
24
|
+
'get_metadata',
|
|
25
|
+
'get_design_context',
|
|
26
|
+
'get_screenshot',
|
|
27
|
+
'export_svg',
|
|
28
|
+
'get_viewport_context',
|
|
29
|
+
'get_selection_snapshot',
|
|
30
|
+
'get_page_snapshot',
|
|
31
|
+
'get_node_snapshot',
|
|
32
|
+
'find_nodes',
|
|
33
|
+
'dump_tree',
|
|
34
|
+
'get_asset_export_plan',
|
|
35
|
+
'extract_reusable_components',
|
|
36
|
+
'generate_swiftui',
|
|
37
|
+
'analyze_generation',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function normalizeLogger(logger = {}) {
|
|
41
|
+
return {
|
|
42
|
+
info: typeof logger.info === 'function' ? logger.info.bind(logger) : console.log,
|
|
43
|
+
warn: typeof logger.warn === 'function' ? logger.warn.bind(logger) : console.warn,
|
|
44
|
+
error: typeof logger.error === 'function' ? logger.error.bind(logger) : console.error,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function waitForExistingBridge({ host, port }) {
|
|
49
|
+
try {
|
|
50
|
+
const response = await fetch(`http://${host}:${port}/bridge/info`);
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new Error(`HTTP ${response.status}`);
|
|
53
|
+
}
|
|
54
|
+
const data = await response.json();
|
|
55
|
+
|
|
56
|
+
// Verify WebSocket is also healthy, not just HTTP
|
|
57
|
+
await new Promise((resolve, reject) => {
|
|
58
|
+
const testWs = new WebSocket(`ws://${host}:${port}/bridge`);
|
|
59
|
+
const timeout = setTimeout(() => {
|
|
60
|
+
testWs.terminate();
|
|
61
|
+
reject(new Error('WebSocket health check timed out'));
|
|
62
|
+
}, 3000);
|
|
63
|
+
testWs.on('open', () => {
|
|
64
|
+
clearTimeout(timeout);
|
|
65
|
+
testWs.close();
|
|
66
|
+
resolve();
|
|
67
|
+
});
|
|
68
|
+
testWs.on('error', (err) => {
|
|
69
|
+
clearTimeout(timeout);
|
|
70
|
+
reject(new Error(`WebSocket health check failed: ${err.message}`));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
alreadyRunning: true,
|
|
77
|
+
info: data,
|
|
78
|
+
};
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(`Port ${port} is already in use and does not look like a healthy Figma SwiftUI bridge (${error.message})`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function killStaleAndRetry({ host, port, logger }) {
|
|
85
|
+
const { execSync } = require('child_process');
|
|
86
|
+
try {
|
|
87
|
+
const lsofOutput = execSync(`lsof -ti :${port}`, { encoding: 'utf8' }).trim();
|
|
88
|
+
const pids = lsofOutput.split('\n').filter(Boolean);
|
|
89
|
+
if (pids.length > 0) {
|
|
90
|
+
logger.warn(`⚠️ Killing ${pids.length} stale process(es) on port ${port}: ${pids.join(', ')}`);
|
|
91
|
+
for (const pid of pids) {
|
|
92
|
+
try { process.kill(Number(pid), 'SIGTERM'); } catch (_) {}
|
|
93
|
+
}
|
|
94
|
+
// Wait for port to free up
|
|
95
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
96
|
+
}
|
|
97
|
+
} catch (_) {
|
|
98
|
+
// lsof returns non-zero if no matches — port is already free
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function startBridgeServer(options = {}) {
|
|
103
|
+
const port = Number(options.port || process.env.FIGMA_SWIFTUI_BRIDGE_PORT || DEFAULT_PORT);
|
|
104
|
+
const host = options.host || process.env.FIGMA_SWIFTUI_BRIDGE_HOST || DEFAULT_HOST;
|
|
105
|
+
const logger = normalizeLogger(options.logger);
|
|
106
|
+
const app = express();
|
|
107
|
+
const server = http.createServer(app);
|
|
108
|
+
|
|
109
|
+
let projectPath = options.projectPath ? setSavedProjectPath(options.projectPath) : getSavedProjectPath();
|
|
110
|
+
let pluginBridgeClient = null;
|
|
111
|
+
const agentBridgeClients = new Set();
|
|
112
|
+
const passivePluginClients = new Set();
|
|
113
|
+
const pendingBridgeRequests = new Map();
|
|
114
|
+
|
|
115
|
+
app.use(cors({ origin: '*' }));
|
|
116
|
+
app.use(express.json({ limit: '50mb' }));
|
|
117
|
+
|
|
118
|
+
// Auth state exposed to the plugin UI via /ping
|
|
119
|
+
let authRequired = false;
|
|
120
|
+
let authRequiredReason = null;
|
|
121
|
+
|
|
122
|
+
function setAuthRequired(reason) {
|
|
123
|
+
authRequired = true;
|
|
124
|
+
authRequiredReason = reason || 'Token expired';
|
|
125
|
+
logger.warn(`[figma-swiftui-bridge] Auth required: ${authRequiredReason}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
app.get('/ping', (req, res) => {
|
|
129
|
+
if (authRequired) {
|
|
130
|
+
return res.status(401).json({
|
|
131
|
+
ok: false,
|
|
132
|
+
authRequired: true,
|
|
133
|
+
authRequiredReason,
|
|
134
|
+
loginCommand: 'npx @createlex/figma-swiftui-mcp login',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
res.json({
|
|
138
|
+
ok: true,
|
|
139
|
+
projectPath: projectPath || null,
|
|
140
|
+
generatedRoot: projectPath ? path.join(projectPath, GENERATED_ROOT_DIRNAME) : null,
|
|
141
|
+
layoutVersion: GENERATED_LAYOUT_VERSION,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Graceful shutdown — called by the plugin UI "Disconnect MCP" to stop the runtime
|
|
146
|
+
app.post('/shutdown', (req, res) => {
|
|
147
|
+
res.json({ ok: true, message: 'Shutting down bridge server…' });
|
|
148
|
+
logger.info('[figma-swiftui-bridge] Shutdown requested by plugin UI');
|
|
149
|
+
setTimeout(() => process.exit(0), 400);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
app.get('/bridge/info', (req, res) => {
|
|
153
|
+
res.json({
|
|
154
|
+
ok: true,
|
|
155
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
156
|
+
wsUrl: `ws://${host}:${port}/bridge`,
|
|
157
|
+
pluginConnected: !!pluginBridgeClient,
|
|
158
|
+
connectedAgents: agentBridgeClients.size,
|
|
159
|
+
pendingRequests: pendingBridgeRequests.size,
|
|
160
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
app.post('/set-project', (req, res) => {
|
|
165
|
+
const { path: inputPath } = req.body;
|
|
166
|
+
if (!inputPath) return res.status(400).json({ error: 'path required' });
|
|
167
|
+
const resolved = resolveWritableProjectPath(inputPath);
|
|
168
|
+
if (!fs.existsSync(resolved)) {
|
|
169
|
+
return res.status(400).json({ error: `Path does not exist: ${resolved}` });
|
|
170
|
+
}
|
|
171
|
+
projectPath = setSavedProjectPath(resolved);
|
|
172
|
+
logger.info(`✅ Project path set: ${projectPath}`);
|
|
173
|
+
res.json({ ok: true, projectPath });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
app.post('/write', (req, res) => {
|
|
177
|
+
const { code, structName, images, customPath } = req.body;
|
|
178
|
+
|
|
179
|
+
const target = customPath ? resolveWritableProjectPath(customPath) : projectPath;
|
|
180
|
+
|
|
181
|
+
if (!target) {
|
|
182
|
+
return res.status(400).json({
|
|
183
|
+
error: 'No project path set. Pass --project /path/to/MyApp when starting the MCP server, or set it from the plugin UI/MCP.',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!fs.existsSync(target)) {
|
|
188
|
+
return res.status(400).json({ error: `Project path does not exist: ${target}` });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
projectPath = target;
|
|
192
|
+
if (customPath && path.resolve(customPath) !== target) {
|
|
193
|
+
logger.info(`📍 Resolved project path: ${path.resolve(customPath)} → ${target}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
projectPath = setSavedProjectPath(target);
|
|
197
|
+
const writeResult = writeSwiftUIScreen({
|
|
198
|
+
targetDir: target,
|
|
199
|
+
code,
|
|
200
|
+
structName,
|
|
201
|
+
images: Array.isArray(images) ? images : [],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (!writeResult.ok) {
|
|
205
|
+
return res.status(207).json({
|
|
206
|
+
ok: false,
|
|
207
|
+
results: writeResult.results,
|
|
208
|
+
projectPath: writeResult.projectPath,
|
|
209
|
+
generatedRoot: writeResult.generatedRoot,
|
|
210
|
+
layoutVersion: GENERATED_LAYOUT_VERSION,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
res.json({
|
|
215
|
+
ok: true,
|
|
216
|
+
results: writeResult.results,
|
|
217
|
+
projectPath: writeResult.projectPath,
|
|
218
|
+
generatedRoot: writeResult.generatedRoot,
|
|
219
|
+
layoutVersion: GENERATED_LAYOUT_VERSION,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
app.post('/choose-project', (req, res) => {
|
|
224
|
+
if (process.platform !== 'darwin') {
|
|
225
|
+
return res.status(501).json({
|
|
226
|
+
error: 'Native project folder chooser is only implemented for macOS. Enter the path manually in the plugin UI or set it from MCP.',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const script = [
|
|
231
|
+
'set chosenFolder to choose folder with prompt "Select the Xcode project source folder"',
|
|
232
|
+
'POSIX path of chosenFolder',
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
execFile('osascript', script.flatMap((line) => ['-e', line]), (err, stdout, stderr) => {
|
|
236
|
+
if (err) {
|
|
237
|
+
const details = `${stderr || ''} ${err.message || ''}`.trim();
|
|
238
|
+
if (details.includes('User canceled') || details.includes('(-128)')) {
|
|
239
|
+
return res.json({ ok: false, canceled: true });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return res.status(500).json({
|
|
243
|
+
error: `Failed to choose a project folder: ${details || 'Unknown error'}`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const resolved = path.resolve(stdout.trim());
|
|
248
|
+
const writeTarget = resolveWritableProjectPath(resolved);
|
|
249
|
+
if (!fs.existsSync(writeTarget)) {
|
|
250
|
+
return res.status(400).json({ error: `Chosen path does not exist: ${writeTarget}` });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
projectPath = setSavedProjectPath(writeTarget);
|
|
254
|
+
logger.info(`✅ Project path set: ${projectPath}`);
|
|
255
|
+
res.json({ ok: true, projectPath });
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// GET /design-context — returns current selection's design context as JSON.
|
|
260
|
+
// Lets any AI tool (Windsurf, a script, a browser) fetch the live Figma context
|
|
261
|
+
// via a simple HTTP GET without needing MCP protocol or copy-paste.
|
|
262
|
+
// Usage: GET http://localhost:7765/design-context?maxDepth=4
|
|
263
|
+
app.get('/design-context', async (req, res) => {
|
|
264
|
+
if (!pluginBridgeClient || pluginBridgeClient.readyState !== WebSocket.OPEN) {
|
|
265
|
+
return res.status(503).json({
|
|
266
|
+
ok: false,
|
|
267
|
+
error: 'No Figma plugin is connected to the bridge. Open the plugin in Figma first.',
|
|
268
|
+
hint: 'npx @createlex/figma-swiftui-mcp start --project ./MyApp',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const maxDepth = Math.min(parseInt(req.query.maxDepth, 10) || 4, 8);
|
|
273
|
+
const requestId = `http-ctx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
274
|
+
|
|
275
|
+
const response = await new Promise((resolve) => {
|
|
276
|
+
const timeout = setTimeout(() => {
|
|
277
|
+
pendingBridgeRequests.delete(requestId);
|
|
278
|
+
resolve({ ok: false, error: 'Timed out waiting for design context from plugin' });
|
|
279
|
+
}, 20000);
|
|
280
|
+
|
|
281
|
+
pendingBridgeRequests.set(requestId, {
|
|
282
|
+
origin: null,
|
|
283
|
+
action: 'get_design_context',
|
|
284
|
+
resolveCallback: (msg) => {
|
|
285
|
+
clearTimeout(timeout);
|
|
286
|
+
resolve(msg);
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
sendBridgeMessage(pluginBridgeClient, {
|
|
291
|
+
type: 'bridge-request',
|
|
292
|
+
requestId,
|
|
293
|
+
action: 'get_design_context',
|
|
294
|
+
params: { maxDepth, includeScreenshot: false },
|
|
295
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
296
|
+
timestamp: new Date().toISOString(),
|
|
297
|
+
});
|
|
298
|
+
broadcastBridgeStatus();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (!response.ok) {
|
|
302
|
+
return res.status(502).json({ ok: false, error: response.error || 'Plugin returned an error' });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const data = response.data || {};
|
|
306
|
+
|
|
307
|
+
// Build the full generation prompt so AI tools without MCP access
|
|
308
|
+
// can still generate proper native SwiftUI with individual elements.
|
|
309
|
+
let generationPrompt = null;
|
|
310
|
+
try {
|
|
311
|
+
generationPrompt = buildGenerationPrompt(data);
|
|
312
|
+
} catch (_) {
|
|
313
|
+
// Non-fatal — prompt is a bonus, not required
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const assetCount = data.assetExportPlan?.candidates?.length ?? 0;
|
|
317
|
+
const aiWorkflow = {
|
|
318
|
+
IF_YOU_HAVE_MCP_ACCESS: {
|
|
319
|
+
step1: 'Call the MCP tool `write_selection_to_xcode` with generationMode="editable" (the default). It exports individual PNG assets with correct blendMode info and generates Image("name").blendMode() calls automatically.',
|
|
320
|
+
step2: 'After write_selection_to_xcode returns, ONLY refine: add GeometryReader for responsiveness, wire @State interactions. DO NOT rewrite Image("name") calls, DO NOT switch to fidelity mode unless the whole frame is a single photo with no text.',
|
|
321
|
+
mcpTool: 'write_selection_to_xcode',
|
|
322
|
+
mcpServer: 'figma-swiftui',
|
|
323
|
+
},
|
|
324
|
+
IF_NO_MCP_ACCESS: {
|
|
325
|
+
instruction: 'Use _generationPrompt.systemPrompt as the system message and _generationPrompt.userMessage as the user message to generate native SwiftUI code. Output individual SwiftUI views — Text(), VStack, HStack, ZStack, Image("name") — NOT a single composite Image().',
|
|
326
|
+
rule1: 'Every asset in assetExportPlan.candidates MUST become Image("assetName").resizable() — never Rectangle() or Color.',
|
|
327
|
+
rule2: 'Text nodes become Text("content") with .font(.system(size: X, weight: .bold)) — NEVER custom font names.',
|
|
328
|
+
rule3: 'Output a full SwiftUI View struct with individual elements positioned using the node geometry in the design context.',
|
|
329
|
+
rule4: 'End with #Preview { StructName() }.',
|
|
330
|
+
},
|
|
331
|
+
assetCandidateCount: assetCount,
|
|
332
|
+
warning: 'DO NOT generate a single Image() wrapping the whole frame. Generate individual SwiftUI elements for each node.',
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
res.json({ ok: true, ...data, _generationPrompt: generationPrompt, _aiWorkflow: aiWorkflow });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
function sendBridgeMessage(ws, payload) {
|
|
339
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
ws.send(JSON.stringify(payload));
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function broadcastBridgeStatus() {
|
|
348
|
+
const payload = {
|
|
349
|
+
type: 'bridge-status',
|
|
350
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
351
|
+
pluginConnected: !!pluginBridgeClient,
|
|
352
|
+
connectedAgents: agentBridgeClients.size,
|
|
353
|
+
pendingRequests: pendingBridgeRequests.size,
|
|
354
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
355
|
+
timestamp: new Date().toISOString(),
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
if (pluginBridgeClient) {
|
|
359
|
+
sendBridgeMessage(pluginBridgeClient, payload);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (const client of agentBridgeClients) {
|
|
363
|
+
sendBridgeMessage(client, payload);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const client of passivePluginClients) {
|
|
367
|
+
sendBridgeMessage(client, payload);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function releasePendingRequestsForSocket(socket, reason) {
|
|
372
|
+
for (const [requestId, pending] of pendingBridgeRequests.entries()) {
|
|
373
|
+
if (pending.origin !== socket) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
pendingBridgeRequests.delete(requestId);
|
|
378
|
+
sendBridgeMessage(socket, {
|
|
379
|
+
type: 'bridge-response',
|
|
380
|
+
requestId,
|
|
381
|
+
action: pending.action,
|
|
382
|
+
ok: false,
|
|
383
|
+
error: reason,
|
|
384
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
385
|
+
timestamp: new Date().toISOString(),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function failAllPendingBridgeRequests(reason) {
|
|
391
|
+
for (const [requestId, pending] of pendingBridgeRequests.entries()) {
|
|
392
|
+
sendBridgeMessage(pending.origin, {
|
|
393
|
+
type: 'bridge-response',
|
|
394
|
+
requestId,
|
|
395
|
+
action: pending.action,
|
|
396
|
+
ok: false,
|
|
397
|
+
error: reason,
|
|
398
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
399
|
+
timestamp: new Date().toISOString(),
|
|
400
|
+
});
|
|
401
|
+
pendingBridgeRequests.delete(requestId);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function attachBridgeRole(socket, role) {
|
|
406
|
+
socket.bridgeRole = role;
|
|
407
|
+
|
|
408
|
+
if (role === 'plugin-ui') {
|
|
409
|
+
if (pluginBridgeClient && pluginBridgeClient !== socket && pluginBridgeClient.readyState === WebSocket.OPEN) {
|
|
410
|
+
socket.bridgeRole = 'plugin-ui-passive';
|
|
411
|
+
agentBridgeClients.delete(socket);
|
|
412
|
+
passivePluginClients.add(socket);
|
|
413
|
+
|
|
414
|
+
sendBridgeMessage(socket, {
|
|
415
|
+
type: 'hello-ack',
|
|
416
|
+
role: 'plugin-ui-passive',
|
|
417
|
+
accepted: false,
|
|
418
|
+
reason: 'Another Figma plugin window is already attached to the bridge',
|
|
419
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
420
|
+
pluginConnected: true,
|
|
421
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
422
|
+
timestamp: new Date().toISOString(),
|
|
423
|
+
});
|
|
424
|
+
broadcastBridgeStatus();
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
pluginBridgeClient = socket;
|
|
429
|
+
passivePluginClients.delete(socket);
|
|
430
|
+
agentBridgeClients.delete(socket);
|
|
431
|
+
} else {
|
|
432
|
+
passivePluginClients.delete(socket);
|
|
433
|
+
agentBridgeClients.add(socket);
|
|
434
|
+
if (pluginBridgeClient === socket) {
|
|
435
|
+
pluginBridgeClient = null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
broadcastBridgeStatus();
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function demoteActivePlugin(reason = 'Another Figma plugin window took the bridge') {
|
|
444
|
+
if (!pluginBridgeClient || pluginBridgeClient.readyState !== WebSocket.OPEN) {
|
|
445
|
+
pluginBridgeClient = null;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const previous = pluginBridgeClient;
|
|
450
|
+
pluginBridgeClient = null;
|
|
451
|
+
previous.bridgeRole = 'plugin-ui-passive';
|
|
452
|
+
passivePluginClients.add(previous);
|
|
453
|
+
|
|
454
|
+
sendBridgeMessage(previous, {
|
|
455
|
+
type: 'hello-ack',
|
|
456
|
+
role: 'plugin-ui-passive',
|
|
457
|
+
accepted: false,
|
|
458
|
+
reason,
|
|
459
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
460
|
+
pluginConnected: true,
|
|
461
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
462
|
+
timestamp: new Date().toISOString(),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const bridgeWss = new WebSocketServer({ server, path: '/bridge' });
|
|
467
|
+
|
|
468
|
+
bridgeWss.on('error', (error) => {
|
|
469
|
+
if (error && error.code === 'EADDRINUSE') {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
logger.error('[figma-swiftui-bridge] WebSocket server error:', error);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
bridgeWss.on('connection', (socket) => {
|
|
476
|
+
socket.bridgeRole = 'agent';
|
|
477
|
+
agentBridgeClients.add(socket);
|
|
478
|
+
|
|
479
|
+
sendBridgeMessage(socket, {
|
|
480
|
+
type: 'bridge-status',
|
|
481
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
482
|
+
pluginConnected: !!pluginBridgeClient,
|
|
483
|
+
connectedAgents: agentBridgeClients.size,
|
|
484
|
+
pendingRequests: pendingBridgeRequests.size,
|
|
485
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
486
|
+
timestamp: new Date().toISOString(),
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
socket.on('message', (rawMessage) => {
|
|
490
|
+
let message;
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
message = JSON.parse(rawMessage.toString());
|
|
494
|
+
} catch (err) {
|
|
495
|
+
sendBridgeMessage(socket, {
|
|
496
|
+
type: 'bridge-error',
|
|
497
|
+
error: 'Invalid JSON message',
|
|
498
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
499
|
+
timestamp: new Date().toISOString(),
|
|
500
|
+
});
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (message.type === 'hello') {
|
|
505
|
+
if (message.role === 'plugin-ui' && message.takeover === true) {
|
|
506
|
+
demoteActivePlugin('Another Figma plugin window took the bridge');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const attached = attachBridgeRole(socket, message.role === 'plugin-ui' ? 'plugin-ui' : 'agent');
|
|
510
|
+
if (attached === false) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
sendBridgeMessage(socket, {
|
|
514
|
+
type: 'hello-ack',
|
|
515
|
+
role: socket.bridgeRole,
|
|
516
|
+
accepted: true,
|
|
517
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
518
|
+
pluginConnected: !!pluginBridgeClient,
|
|
519
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
520
|
+
timestamp: new Date().toISOString(),
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (message.type === 'bridge-control') {
|
|
526
|
+
if (message.action === 'takeover-plugin-ui') {
|
|
527
|
+
if (socket.bridgeRole !== 'plugin-ui-passive') {
|
|
528
|
+
sendBridgeMessage(socket, {
|
|
529
|
+
type: 'bridge-error',
|
|
530
|
+
error: 'Only a passive Figma plugin window can request takeover',
|
|
531
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
532
|
+
timestamp: new Date().toISOString(),
|
|
533
|
+
});
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
demoteActivePlugin('Another Figma plugin window took the bridge');
|
|
538
|
+
passivePluginClients.delete(socket);
|
|
539
|
+
pluginBridgeClient = socket;
|
|
540
|
+
socket.bridgeRole = 'plugin-ui';
|
|
541
|
+
sendBridgeMessage(socket, {
|
|
542
|
+
type: 'hello-ack',
|
|
543
|
+
role: 'plugin-ui',
|
|
544
|
+
accepted: true,
|
|
545
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
546
|
+
pluginConnected: true,
|
|
547
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
548
|
+
timestamp: new Date().toISOString(),
|
|
549
|
+
});
|
|
550
|
+
broadcastBridgeStatus();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
sendBridgeMessage(socket, {
|
|
555
|
+
type: 'bridge-error',
|
|
556
|
+
error: `Unsupported bridge control action: ${message.action || 'unknown'}`,
|
|
557
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
558
|
+
timestamp: new Date().toISOString(),
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (message.type === 'bridge-request') {
|
|
564
|
+
const requestId = typeof message.requestId === 'string' && message.requestId
|
|
565
|
+
? message.requestId
|
|
566
|
+
: `bridge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
567
|
+
|
|
568
|
+
if (!pluginBridgeClient || pluginBridgeClient.readyState !== WebSocket.OPEN) {
|
|
569
|
+
sendBridgeMessage(socket, {
|
|
570
|
+
type: 'bridge-response',
|
|
571
|
+
requestId,
|
|
572
|
+
action: message.action || 'unknown',
|
|
573
|
+
ok: false,
|
|
574
|
+
error: 'No plugin session is connected to the bridge',
|
|
575
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
576
|
+
timestamp: new Date().toISOString(),
|
|
577
|
+
});
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
pendingBridgeRequests.set(requestId, {
|
|
582
|
+
origin: socket,
|
|
583
|
+
action: message.action || 'unknown',
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
sendBridgeMessage(pluginBridgeClient, {
|
|
587
|
+
type: 'bridge-request',
|
|
588
|
+
requestId,
|
|
589
|
+
action: message.action,
|
|
590
|
+
params: message.params || {},
|
|
591
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
592
|
+
timestamp: new Date().toISOString(),
|
|
593
|
+
});
|
|
594
|
+
broadcastBridgeStatus();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (message.type === 'bridge-response') {
|
|
599
|
+
const pending = pendingBridgeRequests.get(message.requestId);
|
|
600
|
+
if (!pending) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
pendingBridgeRequests.delete(message.requestId);
|
|
605
|
+
if (typeof pending.resolveCallback === 'function') {
|
|
606
|
+
pending.resolveCallback(message);
|
|
607
|
+
} else {
|
|
608
|
+
sendBridgeMessage(pending.origin, {
|
|
609
|
+
...message,
|
|
610
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
611
|
+
timestamp: new Date().toISOString(),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
broadcastBridgeStatus();
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (message.type === 'bridge-event') {
|
|
619
|
+
const payload = {
|
|
620
|
+
...message,
|
|
621
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
622
|
+
timestamp: message.timestamp || new Date().toISOString(),
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
for (const client of agentBridgeClients) {
|
|
626
|
+
if (client !== socket) {
|
|
627
|
+
sendBridgeMessage(client, payload);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
sendBridgeMessage(socket, {
|
|
634
|
+
type: 'bridge-error',
|
|
635
|
+
error: `Unsupported bridge message type: ${message.type || 'unknown'}`,
|
|
636
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
637
|
+
timestamp: new Date().toISOString(),
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
socket.on('close', () => {
|
|
642
|
+
if (pluginBridgeClient === socket) {
|
|
643
|
+
pluginBridgeClient = null;
|
|
644
|
+
failAllPendingBridgeRequests('Plugin session disconnected from bridge');
|
|
645
|
+
const nextPlugin = Array.from(passivePluginClients).find((client) => client.readyState === WebSocket.OPEN) || null;
|
|
646
|
+
if (nextPlugin) {
|
|
647
|
+
passivePluginClients.delete(nextPlugin);
|
|
648
|
+
pluginBridgeClient = nextPlugin;
|
|
649
|
+
nextPlugin.bridgeRole = 'plugin-ui';
|
|
650
|
+
sendBridgeMessage(nextPlugin, {
|
|
651
|
+
type: 'hello-ack',
|
|
652
|
+
role: 'plugin-ui',
|
|
653
|
+
accepted: true,
|
|
654
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
655
|
+
pluginConnected: true,
|
|
656
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
657
|
+
timestamp: new Date().toISOString(),
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
} else if (passivePluginClients.has(socket)) {
|
|
661
|
+
passivePluginClients.delete(socket);
|
|
662
|
+
} else {
|
|
663
|
+
agentBridgeClients.delete(socket);
|
|
664
|
+
releasePendingRequestsForSocket(socket, 'Agent disconnected from bridge');
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
broadcastBridgeStatus();
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
return new Promise((resolve, reject) => {
|
|
672
|
+
server.once('error', async (error) => {
|
|
673
|
+
if (error && error.code === 'EADDRINUSE') {
|
|
674
|
+
try {
|
|
675
|
+
const running = await waitForExistingBridge({ host, port });
|
|
676
|
+
logger.warn(`ℹ️ Using existing healthy bridge at http://${host}:${port}`);
|
|
677
|
+
resolve({
|
|
678
|
+
app,
|
|
679
|
+
server: null,
|
|
680
|
+
bridgeWss: null,
|
|
681
|
+
port,
|
|
682
|
+
host,
|
|
683
|
+
alreadyRunning: true,
|
|
684
|
+
projectPath,
|
|
685
|
+
bridgeInfo: running.info,
|
|
686
|
+
getProjectPath: () => projectPath,
|
|
687
|
+
getBridgeInfo: () => running.info,
|
|
688
|
+
});
|
|
689
|
+
return;
|
|
690
|
+
} catch (existingError) {
|
|
691
|
+
// Existing bridge is unhealthy — kill stale processes and start fresh
|
|
692
|
+
logger.warn(`⚠️ ${existingError.message}`);
|
|
693
|
+
try {
|
|
694
|
+
await killStaleAndRetry({ host, port, logger });
|
|
695
|
+
// Re-attempt listen after killing stale processes
|
|
696
|
+
server.listen(port, host, () => {
|
|
697
|
+
logger.info(`🚀 Figma SwiftUI bridge running at http://${host}:${port} (recovered from stale)`);
|
|
698
|
+
if (projectPath) {
|
|
699
|
+
logger.info(`📂 Project path: ${projectPath}`);
|
|
700
|
+
}
|
|
701
|
+
logger.info(`🌉 Bridge ready at ws://${host}:${port}/bridge`);
|
|
702
|
+
resolve({
|
|
703
|
+
app,
|
|
704
|
+
server,
|
|
705
|
+
bridgeWss,
|
|
706
|
+
port,
|
|
707
|
+
host,
|
|
708
|
+
alreadyRunning: false,
|
|
709
|
+
getProjectPath: () => projectPath,
|
|
710
|
+
setAuthRequired,
|
|
711
|
+
getBridgeInfo: () => ({
|
|
712
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
713
|
+
pluginConnected: !!pluginBridgeClient,
|
|
714
|
+
connectedAgents: agentBridgeClients.size,
|
|
715
|
+
pendingRequests: pendingBridgeRequests.size,
|
|
716
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
717
|
+
}),
|
|
718
|
+
close: () => new Promise((closeResolve, closeReject) => {
|
|
719
|
+
bridgeWss.close(() => {
|
|
720
|
+
server.close((closeError) => {
|
|
721
|
+
if (closeError) closeReject(closeError);
|
|
722
|
+
else closeResolve();
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
}),
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
return;
|
|
729
|
+
} catch (retryError) {
|
|
730
|
+
reject(new Error(`Failed to recover bridge: ${retryError.message}`));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
reject(error);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
server.listen(port, host, () => {
|
|
740
|
+
logger.info(`🚀 Figma SwiftUI bridge running at http://${host}:${port}`);
|
|
741
|
+
if (projectPath) {
|
|
742
|
+
logger.info(`📂 Project path: ${projectPath}`);
|
|
743
|
+
} else {
|
|
744
|
+
logger.warn('⚠️ No project path set. Use --project /path/to/MyApp/MyApp or set it from MCP/plugin UI.');
|
|
745
|
+
}
|
|
746
|
+
logger.info(`🌉 Bridge ready at ws://${host}:${port}/bridge`);
|
|
747
|
+
|
|
748
|
+
resolve({
|
|
749
|
+
app,
|
|
750
|
+
server,
|
|
751
|
+
bridgeWss,
|
|
752
|
+
port,
|
|
753
|
+
host,
|
|
754
|
+
alreadyRunning: false,
|
|
755
|
+
getProjectPath: () => projectPath,
|
|
756
|
+
setAuthRequired,
|
|
757
|
+
getBridgeInfo: () => ({
|
|
758
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
759
|
+
pluginConnected: !!pluginBridgeClient,
|
|
760
|
+
connectedAgents: agentBridgeClients.size,
|
|
761
|
+
pendingRequests: pendingBridgeRequests.size,
|
|
762
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
763
|
+
}),
|
|
764
|
+
close: () => new Promise((closeResolve, closeReject) => {
|
|
765
|
+
bridgeWss.close(() => {
|
|
766
|
+
server.close((closeError) => {
|
|
767
|
+
if (closeError) {
|
|
768
|
+
closeReject(closeError);
|
|
769
|
+
} else {
|
|
770
|
+
closeResolve();
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
}),
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
module.exports = {
|
|
781
|
+
DEFAULT_HOST,
|
|
782
|
+
DEFAULT_PORT,
|
|
783
|
+
BRIDGE_PROTOCOL_VERSION,
|
|
784
|
+
SUPPORTED_BRIDGE_ACTIONS,
|
|
785
|
+
startBridgeServer,
|
|
786
|
+
};
|