From c1767bbbdfcd97e08d21c5dcc1278525b9841229 Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 12:19:04 +0530 Subject: [PATCH 01/38] fix(plugin): finish dynamic-page migration Under manifest documentAccess: dynamic-page, synchronous figma.getNodeById throws and non-current pages' children are not loaded. Replace the 6 sync getNodeById call sites with await getNodeByIdAsync, and load all pages once at startup (gating the message handler) so cross-page traversal is valid. --- plugin/src/main.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/plugin/src/main.ts b/plugin/src/main.ts index 81dc818..cb148c7 100644 --- a/plugin/src/main.ts +++ b/plugin/src/main.ts @@ -28,6 +28,15 @@ const WINDOW_PRESETS: Record = { figma.showUI(__html__, { width: WINDOW_PRESETS.M.w, height: WINDOW_PRESETS.M.h, themeColors: true }); +// Under manifest "documentAccess": "dynamic-page", reading a non-current page's +// .children / findAll throws unless its pages are loaded first. Cross-page +// traversal (findMaster, parkMaster, listFrameRecords, upgradeAllListMasters, +// the COMPONENTS_PAGE fallbacks) all run downstream of a UI message or MCP +// request, so we load every page once at startup and gate the message handler +// on that promise. (Loading does NOT make figma.getNodeById legal — those call +// sites use getNodeByIdAsync.) +const pagesLoaded: Promise = figma.loadAllPagesAsync(); + // Per-session UI state we need to remember between messages. const excluded = new Set(); const merged = new Set(); @@ -85,6 +94,9 @@ function clampDimension(value: unknown, min: number, max: number, fallback: numb // UI → main // --------------------------------------------------------------------------- figma.ui.onmessage = async (msg: { type: string; [k: string]: unknown }) => { + // All cross-page traversal runs from here (directly or via handleMcp), so wait + // for every page to be loaded before handling any message (see startup note). + await pagesLoaded; switch (msg.type) { case 'reload': await pushSelection(); @@ -129,7 +141,7 @@ figma.ui.onmessage = async (msg: { type: string; [k: string]: unknown }) => { } case 'highlight-element': { - const node = figma.getNodeById(msg.nodeId as string) as SceneNode | null; + const node = await figma.getNodeByIdAsync(msg.nodeId as string) as SceneNode | null; if (node) { figma.currentPage.selection = [node]; figma.viewport.scrollAndZoomIntoView([node]); @@ -373,7 +385,7 @@ function exportSetsWithConfigs(configs: ElementConfig[] | undefined) { } async function sendPreview(nodeId: string) { - const node = figma.getNodeById(nodeId) as SceneNode | null; + const node = await figma.getNodeByIdAsync(nodeId) as SceneNode | null; if (!node || !('exportAsync' in node)) return; try { const bytes = await (node as unknown as { @@ -802,7 +814,7 @@ async function handleMcp(req: McpRequest) { break; case 'get_node': { const id = (req.params?.nodeId as string) || req.nodeIds?.[0]; - const node = id ? figma.getNodeById(id) : null; + const node = id ? await figma.getNodeByIdAsync(id) : null; response.data = node ? await summarize(node, 3) : null; if (!node) response.error = `Node not found: ${id}`; break; @@ -821,7 +833,7 @@ async function handleMcp(req: McpRequest) { } case 'get_node_details': { const id = (req.params?.nodeId as string) || req.nodeIds?.[0]; - const node = id ? figma.getNodeById(id) : null; + const node = id ? await figma.getNodeByIdAsync(id) : null; response.data = node ? await nodeDetails(node) : null; if (!node) response.error = `Node not found: ${id}`; break; @@ -840,7 +852,7 @@ async function handleMcp(req: McpRequest) { // accepts both forms during the transition. const shots: { nodeId: string; data: string }[] = []; for (const id of ids) { - const node = figma.getNodeById(id) as SceneNode | null; + const node = await figma.getNodeByIdAsync(id) as SceneNode | null; if (node && 'exportAsync' in node) { const bytes = await (node as unknown as { exportAsync: (s: ExportSettings) => Promise; @@ -869,7 +881,7 @@ async function handleMcp(req: McpRequest) { const exportSets = exportSetsWithConfigs(req.params?.elementConfigs as ElementConfig[] | undefined); const exports: unknown[] = []; for (const id of ids) { - const node = figma.getNodeById(id) as SceneNode | null; + const node = await figma.getNodeByIdAsync(id) as SceneNode | null; if (node && 'exportAsync' in node) { const result = await exportDesign( node, From 53f2d89187d6974a4d022eeed3ebf765554f326a Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 12:19:04 +0530 Subject: [PATCH 02/38] fix(server): per-tool request timeout so large exports don't false-timeout Bridge.send armed a single 30s timer for every tool, falsely failing large export_unity/export_project_unity round-trips on a healthy plugin connection. Add an optional per-call timeout (10 min for exports, 30s default for queries), thread it through the PluginSender implementations, and raise the follower /rpc abort above the export ceiling. --- server/src/bridge.ts | 4 ++-- server/src/election.ts | 8 ++++---- server/src/follower.ts | 13 ++++++++++--- server/src/leader.ts | 15 +++++++++++---- server/src/tools.ts | 9 ++++++--- server/src/types.ts | 2 +- server/src/version.ts | 5 +++++ 7 files changed, 39 insertions(+), 17 deletions(-) diff --git a/server/src/bridge.ts b/server/src/bridge.ts index 748b902..fa22429 100644 --- a/server/src/bridge.ts +++ b/server/src/bridge.ts @@ -81,7 +81,7 @@ export class Bridge { return `req-${hhmmss}-${++this.counter}`; } - send(tool: string, nodeIds?: string[], params?: Record): Promise { + send(tool: string, nodeIds?: string[], params?: Record, timeoutMs = REQUEST_TIMEOUT_MS): Promise { if (!this.connected || !this.socket) { return Promise.resolve({ error: 'Figma plugin is not connected to the bridge.' }); } @@ -91,7 +91,7 @@ export class Bridge { const timer = setTimeout(() => { this.pending.delete(requestId); resolve({ error: `Timed out waiting for plugin (${tool}).` }); - }, REQUEST_TIMEOUT_MS); + }, timeoutMs); this.pending.set(requestId, { resolve, timer }); this.socket!.send(JSON.stringify(payload)); }); diff --git a/server/src/election.ts b/server/src/election.ts index 727b386..70fd3b9 100644 --- a/server/src/election.ts +++ b/server/src/election.ts @@ -69,18 +69,18 @@ export class FigForgeNode implements PluginSender { } } - async send(tool: string, nodeIds?: string[], params?: Record): Promise { + async send(tool: string, nodeIds?: string[], params?: Record, timeoutMs?: number): Promise { if (this.role === 'leader' && this.leader) { - return this.leader.send(tool, nodeIds, params); + return this.leader.send(tool, nodeIds, params, timeoutMs); } // Follower path. If the leader has vanished, try to take over once. const alive = await this.follower.ping(); if (!alive) { await this.tryBecomeLeader(); if (this.role === 'leader' && this.leader) { - return this.leader.send(tool, nodeIds, params); + return this.leader.send(tool, nodeIds, params, timeoutMs); } } - return this.follower.send(tool, nodeIds, params); + return this.follower.send(tool, nodeIds, params, timeoutMs); } } diff --git a/server/src/follower.ts b/server/src/follower.ts index 44ae4b5..2b413c9 100644 --- a/server/src/follower.ts +++ b/server/src/follower.ts @@ -3,7 +3,11 @@ // ============================================================================= import type { PluginSender, RpcRequest, RpcResponse } from './types.js'; -import { BRIDGE_PORT } from './version.js'; +import { BRIDGE_PORT, EXPORT_TIMEOUT_MS } from './version.js'; + +// Sit above the leader's longest per-tool budget (export round-trips) so the +// proxy fetch never aborts before the leader's export actually completes. +const RPC_FETCH_TIMEOUT_MS = EXPORT_TIMEOUT_MS + 30_000; export class Follower implements PluginSender { private base = `http://127.0.0.1:${BRIDGE_PORT}`; @@ -17,14 +21,17 @@ export class Follower implements PluginSender { } } - async send(tool: string, nodeIds?: string[], params?: Record): Promise { + // timeoutMs is accepted for the PluginSender contract; the leader derives the + // per-tool plugin budget from the tool name, so the follower only needs its + // proxy fetch to outlast the longest of those budgets. + async send(tool: string, nodeIds?: string[], params?: Record, _timeoutMs?: number): Promise { const body: RpcRequest = { tool, nodeIds, params }; try { const r = await fetch(`${this.base}/rpc`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), - signal: AbortSignal.timeout(35_000), + signal: AbortSignal.timeout(RPC_FETCH_TIMEOUT_MS), }); if (!r.ok) return { error: `Leader returned ${r.status}` }; return (await r.json()) as RpcResponse; diff --git a/server/src/leader.ts b/server/src/leader.ts index 094b6d5..66c338e 100644 --- a/server/src/leader.ts +++ b/server/src/leader.ts @@ -11,7 +11,11 @@ import { WebSocketServer } from 'ws'; import { Bridge } from './bridge.js'; import { rpcRequestSchema } from './schema.js'; import type { PluginSender, RpcResponse } from './types.js'; -import { BRIDGE_PORT, VERSION } from './version.js'; +import { BRIDGE_PORT, EXPORT_TIMEOUT_MS, VERSION } from './version.js'; + +// Tools that ship large base64 payloads and need the long round-trip budget +// when proxied through a follower (which carries no explicit timeout over /rpc). +const EXPORT_TOOLS = new Set(['export_unity', 'export_project_unity', 'get_screenshot']); export const RPC_MAX_BODY_BYTES = 1_048_576; @@ -149,8 +153,8 @@ export class Leader implements PluginSender { }); } - send(tool: string, nodeIds?: string[], params?: Record): Promise { - return this.bridge.send(tool, nodeIds, params); + send(tool: string, nodeIds?: string[], params?: Record, timeoutMs?: number): Promise { + return this.bridge.send(tool, nodeIds, params, timeoutMs); } private onRequest(req: http.IncomingMessage, res: http.ServerResponse): void { @@ -178,7 +182,10 @@ export class Leader implements PluginSender { throw new Error(`Invalid RPC request (${where}): ${issue?.message ?? 'bad shape'}`); } const rpc = parsed.data; - result = await this.bridge.send(rpc.tool, rpc.nodeIds, rpc.params); + // A follower proxying a heavy export carries no timeout over /rpc; give + // the plugin the same long budget the leader would use for these tools. + const timeoutMs = EXPORT_TOOLS.has(rpc.tool) ? EXPORT_TIMEOUT_MS : undefined; + result = await this.bridge.send(rpc.tool, rpc.nodeIds, rpc.params, timeoutMs); } catch (e) { if (e instanceof RpcBodyTooLargeError) { res.writeHead(413, { 'content-type': 'application/json' }); diff --git a/server/src/tools.ts b/server/src/tools.ts index 07d68f6..dc4f208 100644 --- a/server/src/tools.ts +++ b/server/src/tools.ts @@ -21,6 +21,7 @@ import { screenshotInput, validateManifestContractInput, } from './schema.js'; +import { EXPORT_TIMEOUT_MS } from './version.js'; function ok(data: unknown): ToolResult { return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; @@ -124,7 +125,7 @@ export async function executeExportUnity( params: ExportUnityParams = {} ): Promise<{ outDir: string; screenName?: string; manifestPath: string; assetCount: number; elementCount: number }> { const resolvedDir = resolveAndValidateOutputPath(outDir, workspaceRoot); - const resp = await sender.send('export_unity', [nodeId], params as Record); + const resp = await sender.send('export_unity', [nodeId], params as Record, EXPORT_TIMEOUT_MS); if (resp.error) throw new Error(resp.error); const data = resp.data as { exports?: UnityExport[] } | undefined; @@ -172,7 +173,7 @@ export async function executeExportProjectUnity( screens: Array<{ name?: string; role: string; section: string; manifestPath: string; assetCount: number }>; }> { const resolvedDir = resolveAndValidateOutputPath(outDir, workspaceRoot); - const resp = await sender.send('export_project_unity', undefined, params as Record); + const resp = await sender.send('export_project_unity', undefined, params as Record, EXPORT_TIMEOUT_MS); if (resp.error) throw new Error(resp.error); const data = resp.data as ProjectExport | undefined; @@ -526,7 +527,9 @@ export function registerTools(server: McpServer, sender: PluginSender, workspace saveScreenshotsInput, async ({ items, scale }) => { const ids = items.map((i) => i.nodeId); - const r = await sender.send('get_screenshot', ids, { scale }); + // Bulk screenshot saves can return many large base64 PNGs at once, so + // give them the same long round-trip budget as exports. + const r = await sender.send('get_screenshot', ids, { scale }, EXPORT_TIMEOUT_MS); if (r.error) return fail(r.error); const shots = (r.data as { screenshots?: { nodeId: string; data: string | number[] }[] })?.screenshots || []; const byId = new Map(shots.map((s) => [s.nodeId, s.data])); diff --git a/server/src/types.ts b/server/src/types.ts index 9f01846..90cf399 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -36,5 +36,5 @@ export type ToolResult = { /** Anything that can send a tool request to the plugin and await a response. */ export interface PluginSender { - send(tool: string, nodeIds?: string[], params?: Record): Promise; + send(tool: string, nodeIds?: string[], params?: Record, timeoutMs?: number): Promise; } diff --git a/server/src/version.ts b/server/src/version.ts index 85f9ac3..83bfd5d 100644 --- a/server/src/version.ts +++ b/server/src/version.ts @@ -5,3 +5,8 @@ // Bump both together on every release. export const VERSION = '1.0.41'; export const BRIDGE_PORT = 1994; + +// Long round-trip budget for tools that ship large base64 payloads over the +// wire (export_unity / export_project_unity, and screenshot saves). Cheap query +// tools stay on Bridge's 30s default; only these heavy exports need the bump. +export const EXPORT_TIMEOUT_MS = 10 * 60_000; From 1714a969ee2bba3ba10f97d5ce1aeb14dcdc8309 Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 12:19:04 +0530 Subject: [PATCH 03/38] perf(unity): drop per-sprite SaveAssets in ProceduralSprites Each cache Get() called AssetDatabase.SaveAssets() (flushes ALL dirty assets) once per generated sprite, stalling the build. Rely on the importer's existing end-of-build SaveAssets in Build/BuildPageProject finally blocks instead; the in-memory sprite is returned directly, not reloaded. --- unity/Editor/ProceduralSprites.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/unity/Editor/ProceduralSprites.cs b/unity/Editor/ProceduralSprites.cs index 2891cd7..a73e310 100644 --- a/unity/Editor/ProceduralSprites.cs +++ b/unity/Editor/ProceduralSprites.cs @@ -66,7 +66,10 @@ public static Sprite Get(int radius) AssetDatabase.CreateAsset(sprite, path); AssetDatabase.AddObjectToAsset(tex, sprite); - AssetDatabase.SaveAssets(); + // No per-Get SaveAssets — it flushes ALL dirty assets, so calling it + // once per generated sprite stalls the build. The in-memory `sprite` is + // returned directly (not reloaded), and the importer's end-of-build + // finally flushes everything created this pass. _mem[radius] = sprite; return sprite; @@ -125,7 +128,7 @@ public static Sprite Get(int radius, int blur) sprite.name = $"Soft_{key}"; AssetDatabase.CreateAsset(sprite, path); AssetDatabase.AddObjectToAsset(tex, sprite); - AssetDatabase.SaveAssets(); + // No per-Get SaveAssets — flushed once at end-of-build (see RoundedRectSpriteCache). _mem[key] = sprite; return sprite; } @@ -181,7 +184,7 @@ public static Sprite Get(int radius, int thickness) sprite.name = $"Outline_{key}"; AssetDatabase.CreateAsset(sprite, path); AssetDatabase.AddObjectToAsset(tex, sprite); - AssetDatabase.SaveAssets(); + // No per-Get SaveAssets — flushed once at end-of-build (see RoundedRectSpriteCache). _mem[key] = sprite; return sprite; } @@ -233,7 +236,7 @@ public static Sprite Get(Fill fill) sprite.name = $"Grad_{key}"; AssetDatabase.CreateAsset(sprite, path); AssetDatabase.AddObjectToAsset(tex, sprite); - AssetDatabase.SaveAssets(); + // No per-Get SaveAssets — flushed once at end-of-build (see RoundedRectSpriteCache). _mem[key] = sprite; return sprite; @@ -333,7 +336,7 @@ public static Sprite Get(Fill fill, int radius, int w, int h, float[] strokeColo sprite.name = $"RG_{key}"; AssetDatabase.CreateAsset(sprite, path); AssetDatabase.AddObjectToAsset(tex, sprite); - AssetDatabase.SaveAssets(); + // No per-Get SaveAssets — flushed once at end-of-build (see RoundedRectSpriteCache). _mem[key] = sprite; return sprite; } From ad66fb8e4b59278be0feabd3baebc7d7153099e0 Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 12:19:04 +0530 Subject: [PATCH 04/38] fix(unity): make styled List/Table rows selectable Styled (no-rowTemplate) rows added a Button but never wired selection or a row component, so clicks did nothing and ApplySelectionVisual painted no highlight. Attach a FigForgeListRow/FigForgeTableRow (single graphic writer, owns all states) bound to the row background, mirroring the template path. --- unity/Runtime/FigForgeList.cs | 27 +++++++++++++++++---------- unity/Runtime/FigForgeTable.cs | 27 +++++++++++++++++---------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/unity/Runtime/FigForgeList.cs b/unity/Runtime/FigForgeList.cs index 24a5d91..48d661d 100644 --- a/unity/Runtime/FigForgeList.cs +++ b/unity/Runtime/FigForgeList.cs @@ -301,32 +301,39 @@ void CreateStyledRow(int index, FigForgeListItem item, int count) btn.transition = Selectable.Transition.None; Graphic rowBg; + FigForgeFill rowRegularFill; if (itemStyle != null && itemStyle.enabled) { if (row.GetComponent() == null) row.AddComponent(); var rr = row.AddComponent(); ApplyStyleToLayeredRect(rr, itemStyle); - // AddComponent fires Awake/OnEnable -> Apply() synchronously, while the state - // colours are still default white — that would overwrite itemStyle.fill with - // white at rest. Disable first so Apply() doesn't run on add, set the colours, - // then re-enable so OnEnable -> Apply() paints the correct normal fill. - var states = row.AddComponent(); - states.enabled = false; - states.normal = itemStyle.fill; - states.highlighted = hasItemRollover ? FigForgeFill.Solid(itemRollover) : itemStyle.fill; - states.pressed = hasItemRollover ? FigForgeFill.Solid(itemRollover) : itemStyle.fill; - states.enabled = true; rowBg = rr; + rowRegularFill = itemStyle.fill; } else { var img = row.AddComponent(); img.color = new Color(1, 1, 1, 0); rowBg = img; + rowRegularFill = FigForgeFill.Solid(new Color(1, 1, 1, 0)); } btn.targetGraphic = rowBg; ApplyRowCorners(rowBg, index, count); + // Wire selection so styled rows behave like template rows: a FigForgeListRow + // bound to the same background recolours per state AND lets ApplySelectionVisual + // paint the highlight, with its OnPointerClick driving single-select. This row + // component now owns ALL states (it replaces FigForgeButtonStateColors here), + // so there's a single writer to the graphic — rollover/pressed/selected all use + // the rollover colour, matching the previous flat/styled visual output. + var rolloverFill = hasItemRollover ? FigForgeFill.Solid(itemRollover) : rowRegularFill; + var fr = row.AddComponent(); + fr.owner = this; fr.index = index; + fr.regular = rowRegularFill; + fr.rollover = rolloverFill; fr.pressed = rolloverFill; fr.selected = rolloverFill; + fr.hasRollover = hasItemRollover; fr.hasPressed = hasItemRollover; fr.hasSelected = hasItemRollover; + fr.Bind(rowBg); + var lblGo = NewRect("Label", row.transform); var lrt = lblGo.GetComponent(); lrt.anchorMin = Vector2.zero; diff --git a/unity/Runtime/FigForgeTable.cs b/unity/Runtime/FigForgeTable.cs index d53c368..46f9c1e 100644 --- a/unity/Runtime/FigForgeTable.cs +++ b/unity/Runtime/FigForgeTable.cs @@ -240,6 +240,7 @@ void CreateStyledRow(int index, List cells) btn.transition = Selectable.Transition.None; Graphic rowBg; + FigForgeFill rowRegularFill; if (itemStyle != null && itemStyle.enabled) { if (row.GetComponent() == null) row.AddComponent(); @@ -254,26 +255,32 @@ void CreateStyledRow(int index, List cells) shadowBlur = itemStyle.shadowBlur, shadowSpread = itemStyle.shadowSpread, }); - // AddComponent fires Awake/OnEnable -> Apply() synchronously, while the state - // colours are still default white — that would overwrite itemStyle.fill with - // white at rest. Disable first so Apply() doesn't run on add, set the colours, - // then re-enable so OnEnable -> Apply() paints the correct normal fill. - var states = row.AddComponent(); - states.enabled = false; - states.normal = itemStyle.fill; - states.highlighted = hasItemRollover ? FigForgeFill.Solid(itemRollover) : itemStyle.fill; - states.pressed = hasItemRollover ? FigForgeFill.Solid(itemRollover) : itemStyle.fill; - states.enabled = true; rowBg = rr; + rowRegularFill = itemStyle.fill; } else { var img = row.AddComponent(); img.color = new Color(1, 1, 1, 0); rowBg = img; + rowRegularFill = FigForgeFill.Solid(new Color(1, 1, 1, 0)); } btn.targetGraphic = rowBg; + // Wire selection so styled rows behave like template rows: a FigForgeTableRow + // bound to the same background recolours per state AND lets ApplySelectionVisual + // paint the highlight, with its OnPointerClick driving single-select. This row + // component now owns ALL states (it replaces FigForgeButtonStateColors here), + // so there's a single writer to the graphic — rollover/pressed/selected all use + // the rollover colour, matching the previous flat/styled visual output. + var rolloverFill = hasItemRollover ? FigForgeFill.Solid(itemRollover) : rowRegularFill; + var fr = row.AddComponent(); + fr.owner = this; fr.index = index; + fr.regular = rowRegularFill; + fr.rollover = rolloverFill; fr.pressed = rolloverFill; fr.selected = rolloverFill; + fr.hasRollover = hasItemRollover; fr.hasPressed = hasItemRollover; fr.hasSelected = hasItemRollover; + fr.Bind(rowBg); + int cols = Mathf.Max(1, columns); for (int c = 0; c < cols; c++) { From a2a55cb068391d82e3d15324ca6676bcf8aa5932 Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 12:19:04 +0530 Subject: [PATCH 05/38] ci: gate manifest version + Manifest field parity; scaffold Unity job The contract gate only compared one CANONICAL_SCHEMA integer. Also assert the plugin MANIFEST_VERSION is accepted by the importer and that the TS Manifest and C# Manifest top-level field sets match. Document the check's shallowness, and add a disabled game-ci scaffold (needs UNITY_LICENSE) so the largest subsystem can be compiled in CI later. --- .github/workflows/ci.yml | 33 +++++- scripts/check-canonical-schema.mjs | 176 +++++++++++++++++++++++++---- 2 files changed, 185 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57131c7..5107dc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,10 @@ jobs: # type stripping landed in Node 22.6). node-version: 22 - - name: Contract — canonical schema in lockstep + - name: Contract — schema + manifest version + field parity in lockstep + # Guards three plugin<->importer contract points: the canonical-schema + # generation, the manifest wire-format version (importer hard-fails on an + # unsupported one), and best-effort top-level Manifest field-name parity. run: node scripts/check-canonical-schema.mjs - name: Plugin — typecheck + build @@ -46,3 +49,31 @@ jobs: # a real compile job would also require a UNITY_LICENSE secret that is # unavailable on fork PRs. Adding it now would either hard-fail or block the # pipeline, so it is deferred. See the CI concern noted in the rollout. +# +# Future path forward (DISABLED — do NOT enable without the prerequisites below): +# spin up a throwaway Unity project that references this package via the local +# manifest, then run game-ci/unity-test-runner to compile + run EditMode tests. +# Prerequisites before un-commenting: +# 1. A UNITY_LICENSE (+ UNITY_EMAIL / UNITY_PASSWORD) repo secret. These are +# NOT available to fork PRs, so the job must be guarded so fork PRs skip it +# cleanly rather than fail. +# 2. A minimal Unity project under e.g. ci/unity-project/ whose Packages/ +# manifest.json points "com.figforge.importer" at "file:../../unity". +# +# unity-compile: +# runs-on: ubuntu-latest +# # Skip on fork PRs: secrets (UNITY_LICENSE) are not exposed there, so the job +# # cannot authenticate and would otherwise hard-fail. Same-repo pushes/PRs run it. +# if: github.event.pull_request.head.repo.full_name == github.repository +# steps: +# - uses: actions/checkout@v4 +# with: +# lfs: true +# - uses: game-ci/unity-test-runner@v4 +# env: +# UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} +# UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} +# UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} +# with: +# projectPath: ci/unity-project # throwaway project referencing unity/ via file: dependency +# testMode: EditMode diff --git a/scripts/check-canonical-schema.mjs b/scripts/check-canonical-schema.mjs index 578ce01..9d5c8e9 100644 --- a/scripts/check-canonical-schema.mjs +++ b/scripts/check-canonical-schema.mjs @@ -1,44 +1,174 @@ #!/usr/bin/env node -// Asserts the canonical-control capture generation stays in lockstep between the -// plugin (emitter) and the Unity importer (parser). The importer only WARNS at -// import time when they differ, so without this gate the two silently drift and -// every export trips the degradation warning. Counterparts: -// plugin/src/types.ts export const CANONICAL_SCHEMA = N -// unity/Editor/HierarchyBuilder.cs internal const int CanonicalSchema = N +// Asserts the manifest CONTRACT stays in lockstep between the plugin (emitter) +// and the Unity importer (parser). The importer only WARNS (or, for the version, +// hard-fails at import time) when they differ, so without this gate the two +// silently drift. Three checks, each with its counterpart pair: +// 1. Canonical-control capture generation: +// plugin/src/types.ts export const CANONICAL_SCHEMA = N +// unity/Editor/HierarchyBuilder.cs internal const int CanonicalSchema = N +// 2. Manifest wire-format version — the importer hard-fails on a version it +// doesn't accept, so whatever the plugin emits MUST be in the supported set: +// plugin/src/types.ts export const MANIFEST_VERSION = 'X' +// unity/Editor/ManifestParser.cs static readonly string[] SupportedManifestVersions = { … } +// 3. Best-effort top-level field-name parity between the two contract types: +// plugin/src/types.ts export interface Manifest { … } +// unity/Editor/Data/ManifestData.cs public class Manifest { … } +// +// NOTE on (3): this is a REGEX-based, structural check, not a real parser. It only +// looks at the immediate field names of the two Manifest types — it does NOT +// compare nested types, field types, optionality, or ordering. It exists to catch +// the obvious "someone added a top-level field on one side and forgot the other" +// divergence; a deep contract verification still needs a round-trip integration +// test. Treat a parity failure as a loud signal to look, not as proof of (in)correctness. import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); -function extract(relPath, regex, label) { - const text = readFileSync(join(repoRoot, relPath), 'utf8'); +let failed = false; +function fail(msg) { + console.error(msg); + failed = true; +} + +function read(relPath) { + return readFileSync(join(repoRoot, relPath), 'utf8'); +} + +function matchOrExit(text, regex, label, relPath) { const m = text.match(regex); if (!m) { console.error(`✗ could not find ${label} in ${relPath}`); process.exit(1); } - return { value: Number(m[1]), relPath }; + return m; } -const plugin = extract( - 'plugin/src/types.ts', - /export const CANONICAL_SCHEMA\s*=\s*(\d+)/, - 'CANONICAL_SCHEMA', +// --------------------------------------------------------------------------- +// (1) Canonical schema generation — both sides must carry the same integer. +// --------------------------------------------------------------------------- +const typesTs = read('plugin/src/types.ts'); +const hierarchyCs = read('unity/Editor/HierarchyBuilder.cs'); + +const pluginSchema = Number( + matchOrExit(typesTs, /export const CANONICAL_SCHEMA\s*=\s*(\d+)/, 'CANONICAL_SCHEMA', 'plugin/src/types.ts')[1], ); -const unity = extract( - 'unity/Editor/HierarchyBuilder.cs', - /const int CanonicalSchema\s*=\s*(\d+)/, - 'CanonicalSchema', +const unitySchema = Number( + matchOrExit(hierarchyCs, /const int CanonicalSchema\s*=\s*(\d+)/, 'CanonicalSchema', 'unity/Editor/HierarchyBuilder.cs')[1], ); -if (plugin.value !== unity.value) { - console.error( - `✗ canonical schema drift: plugin CANONICAL_SCHEMA = ${plugin.value} ` + - `(${plugin.relPath}) != Unity CanonicalSchema = ${unity.value} (${unity.relPath}).\n` + +if (pluginSchema !== unitySchema) { + fail( + `✗ canonical schema drift: plugin CANONICAL_SCHEMA = ${pluginSchema} ` + + `(plugin/src/types.ts) != Unity CanonicalSchema = ${unitySchema} (unity/Editor/HierarchyBuilder.cs).\n` + ` Bump both in lockstep so exports don't trip the importer degradation warning.`, ); - process.exit(1); +} else { + console.log(`✓ canonical schema in lockstep: ${pluginSchema}`); +} + +// --------------------------------------------------------------------------- +// (2) Manifest version — the plugin emits exactly one version; the importer +// hard-fails on anything outside SupportedManifestVersions. So the plugin's +// MANIFEST_VERSION MUST appear in the importer's supported list (the list may +// be a superset — it can still accept older manifests). +// --------------------------------------------------------------------------- +const parserCs = read('unity/Editor/ManifestParser.cs'); + +const pluginVersion = matchOrExit( + typesTs, + /export const MANIFEST_VERSION\s*=\s*'([^']+)'/, + 'MANIFEST_VERSION', + 'plugin/src/types.ts', +)[1]; + +const supportedDecl = matchOrExit( + parserCs, + /SupportedManifestVersions\s*=\s*\{([^}]*)\}/, + 'SupportedManifestVersions', + 'unity/Editor/ManifestParser.cs', +)[1]; +const supportedVersions = [...supportedDecl.matchAll(/"([^"]+)"/g)].map((m) => m[1]); + +if (!supportedVersions.includes(pluginVersion)) { + fail( + `✗ manifest version drift: plugin emits MANIFEST_VERSION = '${pluginVersion}' ` + + `(plugin/src/types.ts) but the importer's SupportedManifestVersions = ` + + `[${supportedVersions.map((v) => `'${v}'`).join(', ')}] (unity/Editor/ManifestParser.cs) ` + + `does NOT include it — every import would hard-fail with an "unsupported version" abort.\n` + + ` Add '${pluginVersion}' to SupportedManifestVersions (and keep older entries for back-compat).`, + ); +} else { + console.log( + `✓ manifest version accepted: plugin emits '${pluginVersion}', ` + + `importer supports [${supportedVersions.map((v) => `'${v}'`).join(', ')}]`, + ); +} + +// --------------------------------------------------------------------------- +// (3) Best-effort top-level field-name parity of the two Manifest contract types. +// Regex-scraped, intentionally shallow — see the NOTE at the top of the file. +// --------------------------------------------------------------------------- + +// TS: pull the `export interface Manifest { … }` body, then each `name:` key. +function tsManifestFields(text) { + const body = matchOrExit( + text, + /export interface Manifest\s*\{([\s\S]*?)\n\}/, + 'export interface Manifest', + 'plugin/src/types.ts', + )[1]; + const fields = new Set(); + for (const line of body.split('\n')) { + // Strip line comments so a `// foo: bar` note can't masquerade as a field. + const code = line.replace(/\/\/.*$/, ''); + const m = code.match(/^\s*([A-Za-z_]\w*)\??\s*:/); + if (m) fields.add(m[1]); + } + return fields; +} + +// C#: pull the `public class Manifest { … }` body, then each `public name` +// field declaration (drops a leading [JsonProperty(...)] attribute if present). +function csManifestFields(text) { + const body = matchOrExit( + text, + /public class Manifest\s*\{([\s\S]*?)\n\s{4}\}/, + 'public class Manifest', + 'unity/Editor/Data/ManifestData.cs', + )[1]; + const fields = new Set(); + for (const line of body.split('\n')) { + const code = line.replace(/\/\/.*$/, ''); + // public [= …]; — capture the last identifier + // before the terminator (= or ;). + const m = code.match(/^\s*public\s+.+?\b([A-Za-z_]\w*)\s*(?:=|;)/); + if (m) fields.add(m[1]); + } + return fields; +} + +const manifestDataCs = read('unity/Editor/Data/ManifestData.cs'); +const tsFields = tsManifestFields(typesTs); +const csFields = csManifestFields(manifestDataCs); + +const onlyTs = [...tsFields].filter((f) => !csFields.has(f)); +const onlyCs = [...csFields].filter((f) => !tsFields.has(f)); + +if (onlyTs.length || onlyCs.length) { + // Loud warning, then fail: a top-level field on one side but not the other is + // the exact "added a field, forgot the other side" divergence this guards. + fail( + `✗ Manifest top-level field parity mismatch between the TS interface ` + + `(plugin/src/types.ts) and the C# class (unity/Editor/Data/ManifestData.cs):\n` + + (onlyTs.length ? ` only in TS: ${onlyTs.join(', ')}\n` : '') + + (onlyCs.length ? ` only in C#: ${onlyCs.join(', ')}\n` : '') + + ` Add the missing field to the other side so the wire contract stays mirrored.\n` + + ` (This is a shallow regex check — top-level field NAMES only, not types/nesting.)`, + ); +} else { + console.log(`✓ Manifest top-level fields in parity (${tsFields.size}): ${[...tsFields].join(', ')}`); } -console.log(`✓ canonical schema in lockstep: ${plugin.value}`); +process.exit(failed ? 1 : 0); From 350b8ab5f9d3c0e792d12611aa41f5769d36417e Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 12:23:00 +0530 Subject: [PATCH 06/38] docs: correct stale class, tool, field, and dependency references - README/architecture: ScreenManager/BaseScreen -> FrameManager/FigForgeScreen (the classes that actually exist), and bridge timeout note is now per-tool. - README MCP tool table: list all 15 tools (was missing 7). - architecture: export sandbox is FIGFORGE_WORKSPACE (defaults to cwd), not 'server cwd'. - plugin-guide: add the missing manifest root fields (canonicalSchema, vanilla, settings). - README/unity README: document the com.unity.inputsystem dependency. - README git-URL install example bumped v1.0.1 -> v1.0.57. --- README.md | 20 ++++++++++++-------- docs/architecture.md | 7 ++++--- docs/plugin-guide.md | 5 +++-- unity/README.md | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b383c1d..553a97d 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ importer reads. Change one side, change the other. Everything else is detail. | ✎ Vector / icon | rasterized PNG, hash-deduped | | 🅣 Text | `TextMeshProUGUI` + per-family/style font mapping | | 🔘 `Btn__` / `Inp__` layer | a real **canonical prefab** instance | -| 🗂 Several frames | one navigable scene — `BaseScreen` pages under a `ScreenManager` | +| 🗂 Several frames | one navigable scene — `FigForgeScreen` frames under a `FrameManager` | | 👻 Empty/placeholder paint, failed export | falls back to the fill colour — **no junk PNG, no white box** | Plus, in the plugin itself: exclude layers, merge a container to one PNG, @@ -128,7 +128,7 @@ Follow a single frame through the machine; every capability shows up along the w > > **5 · Unity rebuilds it.** Anchored hierarchy under a `Canvas`, fonts mapped to > `TMP_FontAsset`s, canonical layers swapped for prefab instances, and each frame -> parented under a `ScreenManager` as one `BaseScreen` — many frames → one +> shown by a `FrameManager` as one `FigForgeScreen` — many frames → one > navigable, multi-page scene. --- @@ -181,15 +181,15 @@ From a release instead: unzip `figforge-bridge-.zip`, then `npm install --o ```text Package Manager ▸ Add package from git URL… - https://github.com/havokentity/FigForge.git?path=unity#v1.0.1 + https://github.com/havokentity/FigForge.git?path=unity#v1.0.57 Package Manager ▸ Add package from tarball… figforge-unity-importer-.tgz (from a release) Package Manager ▸ Add package from disk… unity/package.json ``` -Pin the git URL to a tag (`#v1.0.1`) so upgrades stay deliberate. Deps — uGUI, -TextMeshPro, Newtonsoft JSON, 2D Sprite — resolve automatically. +Pin the git URL to a tag (`#v1.0.57`) so upgrades stay deliberate. Deps — uGUI, +TextMeshPro, Newtonsoft JSON, 2D Sprite, Input System — resolve automatically. > [!IMPORTANT] @@ -229,10 +229,14 @@ With the bridge running, an MCP client can drive the whole thing: | Tool | Does | |:--|:--| | `get_metadata` | file name, pages, current page | -| `get_document` / `get_selection` / `get_node` | read the tree, the selection, or one node | -| `get_design_context` | a summarized design tree | -| `get_screenshot` / `save_screenshots` | render node(s) to PNG (returned, or written to disk) | +| `get_document` / `get_selection` / `get_node` / `get_node_details` | read the tree, the selection, or one node (deep) | +| `get_design_context` | a layout-aware, summarized design tree | +| `list_frames` / `list_screens` | enumerate top-level frames / export-eligible screens | +| `get_screenshot` / `save_screenshots` | render node(s) to PNG (returned base64, or written to disk) | +| `create_canonical` / `create_shell` | scaffold a canonical control / app-shell frame in the document | | **`export_unity`** | run the real exporter and write `manifest.json` + PNGs to a folder | +| **`export_project_unity`** | export the page as a connected multi-page project bundle | +| `validate_manifest_contract` | check a manifest/project JSON against the importer contract | `export_unity` is sandboxed to the workspace root. The plugin's header **MCP toggle** dials out to `ws://127.0.0.1:1994/ws` (and auto-reconnects while on); diff --git a/docs/architecture.md b/docs/architecture.md index 6524a45..e13bacf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,16 +44,17 @@ WebSocket to the single Figma plugin can exist — so processes elect a **leader rest are **followers** proxying over `/rpc`. If the leader dies, a follower's next call triggers a takeover. -- `bridge.ts` — request/response correlation over the plugin socket (30s timeout). +- `bridge.ts` — request/response correlation over the plugin socket (per-tool timeout: 30s for queries, minutes for exports). - `leader.ts` / `follower.ts` / `election.ts` — the role machinery. - `tools.ts` / `schema.ts` — MCP tool definitions (Zod-validated). `export_unity` - and `save_screenshots` validate that output paths stay within the server cwd. + and `save_screenshots` validate that output paths stay within the workspace root + (`FIGFORGE_WORKSPACE`, defaulting to the launch cwd). ## Importer (`unity/`) - **Editor** — `FigForgeImporterWindow` (UI), `ManifestParser`, `TextureImportHelper`, `SpriteAtlasHelper`, `HierarchyBuilder`, procedural sprite caches. -- **Runtime** — `ScreenManager`, `BaseScreen`, `CanonicalLibrary` (so built scenes +- **Runtime** — `FrameManager`, `FigForgeScreen`, `CanonicalLibrary` (so built scenes and prefabs work at runtime without the editor assembly). ## The contract diff --git a/docs/plugin-guide.md b/docs/plugin-guide.md index c583ac3..a3c3dca 100644 --- a/docs/plugin-guide.md +++ b/docs/plugin-guide.md @@ -48,8 +48,9 @@ deduplicated by content hash. ## Manifest field reference -Root: `schema`, `version`, `generator`, `exportedAt`, `screen`, `elements[]`, -`assets[]`, `fonts[]`, `diagnostics`, `canonicalRefs[]`. +Root: `schema`, `version`, `canonicalSchema`, `generator`, `exportedAt`, `vanilla`, +`screen`, `elements[]`, `assets[]`, `fonts[]`, `diagnostics`, `settings`, +`canonicalRefs[]`. `screen`: `{ id, name, figmaSize{w,h}, referenceResolution{w,h}, exportScale }`. diff --git a/unity/README.md b/unity/README.md index 615569c..e7a8086 100644 --- a/unity/README.md +++ b/unity/README.md @@ -7,7 +7,7 @@ Imports a FigForge export (`manifest.json` + PNGs) into a Unity uGUI hierarchy. - **Package Manager → + → Add package from disk…** and pick this folder's `package.json`, or - copy the folder into your project's `Packages/`. -Dependencies (auto-resolved): uGUI, TextMeshPro, Newtonsoft JSON, 2D Sprite. +Dependencies (auto-resolved): uGUI, TextMeshPro, Newtonsoft JSON, 2D Sprite, Input System. ## Use From 9427bdc767be0b3bbc4f7004ccee745671dad64f Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 12:23:00 +0530 Subject: [PATCH 07/38] ci(release): use Node 22 and run contract + typecheck/tests before packaging Release built artifacts on Node 20 (CI and package.json engines require >=22.6) and shipped them without typechecking or testing. Bump to Node 22 and run the contract gate, plugin/server typecheck, server tests, and builds before packaging. --- .github/workflows/release.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 872660b..a42c036 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,23 +25,31 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + # Match CI and package.json engines (>=22.6) — the server tests run .ts + # files directly via `node --test`, which needs Node 22.6+. + node-version: 22 - name: Resolve version id: ver run: echo "version=${GITHUB_REF_NAME:-${{ github.event.inputs.tag }}}" >> "$GITHUB_OUTPUT" + - name: Contract — schema + manifest version + field parity in lockstep + run: node scripts/check-canonical-schema.mjs + - name: Build plugin run: | cd plugin npm ci + npm run typecheck npm run build - - name: Build server + - name: Build + test server run: | cd server npm ci + npm run typecheck npm run build + npm test - name: Package artifacts env: From 0053b4323c07fe3ac903e3ac88c37f31b5ab054b Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 15:44:45 +0530 Subject: [PATCH 08/38] fix(plugin): prevent wrong-sprite dedup collisions + drop dangling child refs - dedupKey now hashes the full PNG byte stream with two independent 32-bit lanes (64-bit key) + length instead of one 32-bit FNV + 2 probe bytes, so a hash collision can no longer silently substitute a different node's sprite. True duplicates still dedup byte-for-byte; the wire format is unchanged. - captureSubtree now excludes excludedIds from a parent's children array (mirrors walk()'s own guard), so an excluded node nested in a canonical part subtree no longer leaves a dangling child id the importer can't resolve. --- plugin/src/exporter.ts | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/plugin/src/exporter.ts b/plugin/src/exporter.ts index 6af4693..9a1ca02 100644 --- a/plugin/src/exporter.ts +++ b/plugin/src/exporter.ts @@ -394,28 +394,24 @@ function pngSize(bytes: Uint8Array): { w: number; h: number } { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return { w: dv.getUint32(16), h: dv.getUint32(20) }; } -function fnv1a(bytes: Uint8Array): string { - let h = 0x811c9dc5; +// Content key for the PNG dedup map. A single 32-bit FNV-1a collides at ~50% +// odds by ~77k assets, and ANY collision silently substitutes the WRONG sprite +// with no error anywhere. So hash the FULL byte stream with two independent +// 32-bit lanes (FNV-1a + a distinct multiply-rotate) for a combined 64-bit key, +// plus the byte length: an undetected collision now needs BOTH 32-bit hashes +// AND the length to match (~2^-64). True duplicates still produce an identical +// key and dedup byte-for-byte; the wire format is untouched (this key only ever +// lives in the in-memory hashToFile map). +function dedupKey(bytes: Uint8Array): string { + let h1 = 0x811c9dc5; // FNV-1a + let h2 = (0x01000193 ^ 0x9e3779b9) >>> 0; // distinct seed for the second lane for (let i = 0; i < bytes.length; i++) { - h ^= bytes[i]; - h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; + const b = bytes[i]; + h1 = Math.imul(h1 ^ b, 0x01000193) >>> 0; // FNV-1a prime + h2 = Math.imul((h2 ^ b) >>> 0, 0x85ebca77) >>> 0; // distinct prime + h2 = ((h2 << 13) | (h2 >>> 19)) >>> 0; // rotl to decorrelate the lanes } - return h.toString(16); -} - -// Content key for the PNG dedup map. FNV-1a alone is only 32 bits — two -// different sprites can collide (≈50% odds by ~77k assets, but any single -// collision silently substitutes the WRONG sprite with no error anywhere). -// Folding in the byte length plus two cheap probe bytes (middle + last; the -// first PNG byte is the constant 0x89, so it discriminates nothing) makes an -// undetected collision require equal hash AND length AND probes — while true -// duplicates still dedup byte-identically, and the wire format is untouched -// (this key only ever lives in the in-memory hashToFile map). -function dedupKey(bytes: Uint8Array): string { - const len = bytes.length; - const mid = len ? bytes[len >> 1] : 0; - const last = len ? bytes[len - 1] : 0; - return `${fnv1a(bytes)}-${len.toString(16)}-${mid.toString(16)}-${last.toString(16)}`; + return `${h1.toString(16)}-${h2.toString(16)}-${bytes.length.toString(16)}`; } function exportConstraint(scale: ExportScale): ExportSettingsImage['constraint'] { @@ -1727,7 +1723,9 @@ export async function exportDesign( const rasterLeaf = exportable && bakesWholeSubtree(node); const children: SceneNode[] = !isCanon && !rasterLeaf && 'children' in node ? ((node as ChildrenMixin).children.slice() as SceneNode[]).filter( - (c) => (c as unknown as { visible?: boolean }).visible !== false) + (c) => + (c as unknown as { visible?: boolean }).visible !== false && + !excludedIds.has(c.id)) : []; subs.push({ node, parentId, exportable, children }); for (const c of children) await walk(c, node.id); From 1e7f595cd754e7fe0357d558fe5392340bcb24fb Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 15:52:59 +0530 Subject: [PATCH 09/38] fix(unity): preserve modal listeners + stage live-import before swap - FigForgeModal.BindPrimary/Secondary/Tertiary now remove only their own previously-installed ModalData closure (tracked per action) instead of RemoveAllListeners(), so Inspector/user-added onPrimary listeners survive Open(ModalData) and don't stack across repeated opens. - FigForgeLiveImport stages the whole bundle into a unique temp sibling folder and validates it (non-empty, project.json re-parses) before deleting the old import and moving the staged bundle into place, so a malformed/empty live push can no longer wipe the previously-good import. --- unity/Editor/FigForgeLiveImport.cs | 54 ++++++++++++++++++++++--- unity/Runtime/Controls/FigForgeModal.cs | 16 ++++++-- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/unity/Editor/FigForgeLiveImport.cs b/unity/Editor/FigForgeLiveImport.cs index c9e32b1..60b5d03 100644 --- a/unity/Editor/FigForgeLiveImport.cs +++ b/unity/Editor/FigForgeLiveImport.cs @@ -230,10 +230,21 @@ static void ImportBundle(string json) string projectRoot = Directory.GetParent(Application.dataPath).FullName; string destAbs = Path.Combine(projectRoot, destAssets.Replace('/', Path.DirectorySeparatorChar)); - // Clean re-import: drop the previous version of this project. - if (Directory.Exists(destAbs)) Directory.Delete(destAbs, true); - Directory.CreateDirectory(destAbs); + // Stage the whole bundle into a uniquely-named temp sibling first, so a + // malformed/empty push can never wipe the previously-good import. Only + // after every file is written do we delete the old dest and move temp + // into place. On any failure we leave dest untouched and drop the temp. + string liveRootAbs = Path.Combine(projectRoot, LiveRoot.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(liveRootAbs); + string tmpAbs = Path.Combine(liveRootAbs, $".{SafeName(projName)}__tmp_{Guid.NewGuid():N}"); + if (Directory.Exists(tmpAbs)) Directory.Delete(tmpAbs, true); + Directory.CreateDirectory(tmpAbs); + // From here on, file writes target the temp folder; `destAbs` is only + // touched at the very end during the swap. + string buildAbs = tmpAbs; + try + { var index = new ProjIndex { name = projName, @@ -251,7 +262,7 @@ static void ImportBundle(string json) while (used.Contains(folder)) folder = $"{baseFolder}_{n++}"; used.Add(folder); - string folderAbs = Path.Combine(destAbs, folder); + string folderAbs = Path.Combine(buildAbs, folder); Directory.CreateDirectory(folderAbs); File.WriteAllText(Path.Combine(folderAbs, "manifest.json"), s.manifest ?? "{}"); if (s.assets != null) @@ -300,7 +311,40 @@ static void ImportBundle(string json) }); } index.screens = indexScreens.ToArray(); - File.WriteAllText(Path.Combine(destAbs, "project.json"), JsonUtility.ToJson(index, true)); + File.WriteAllText(Path.Combine(buildAbs, "project.json"), JsonUtility.ToJson(index, true)); + + // Confirm the staged bundle is non-empty and that project.json parses + // back before we touch the existing good import. + if (index.screens.Length == 0) + throw new Exception("no screens staged"); + string stagedProjectJson = File.ReadAllText(Path.Combine(buildAbs, "project.json")); + if (JsonUtility.FromJson(stagedProjectJson) == null) + throw new Exception("staged project.json failed to parse"); + } + catch + { + // Validation/write failed — leave the existing dest untouched. + try { if (Directory.Exists(tmpAbs)) Directory.Delete(tmpAbs, true); } catch { /* best effort */ } + throw; + } + + // Swap: drop the previous version of this project, then move the fully + // staged bundle into place. Do this just before the AssetDatabase sees + // it so a partial/failed write never leaves a corrupt dest. + try + { + if (Directory.Exists(destAbs)) Directory.Delete(destAbs, true); + // Drop any stale .meta Unity left for the old folder so the move + // doesn't collide with it on import. + string destMeta = destAbs + ".meta"; + if (File.Exists(destMeta)) { try { File.Delete(destMeta); } catch { /* best effort */ } } + Directory.Move(tmpAbs, destAbs); + } + catch + { + try { if (Directory.Exists(tmpAbs)) Directory.Delete(tmpAbs, true); } catch { /* best effort */ } + throw; + } AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); diff --git a/unity/Runtime/Controls/FigForgeModal.cs b/unity/Runtime/Controls/FigForgeModal.cs index e110f90..3d66eb0 100644 --- a/unity/Runtime/Controls/FigForgeModal.cs +++ b/unity/Runtime/Controls/FigForgeModal.cs @@ -97,6 +97,12 @@ public bool ShowClose bool _bound; + // The last ModalData-installed closure for each action, so Bind* can remove ONLY its own + // previously-added listener instead of wiping Inspector/user-added listeners. + UnityAction _modalPrimary; + UnityAction _modalSecondary; + UnityAction _modalTertiary; + // Per-instance content stack: opening the SAME modal again pushes the current state // and shows the new one; each close peels back (LIFO) until empty, then dismisses — // so one GameObject "stacks" without cloning a second instance. @@ -311,19 +317,23 @@ public void BindClose(Button button) public void BindPrimary(UnityAction action) { - onPrimary.RemoveAllListeners(); + // Remove ONLY our own previously-installed closure, not Inspector/user listeners. + if (_modalPrimary != null) onPrimary.RemoveListener(_modalPrimary); + _modalPrimary = action; if (action != null) onPrimary.AddListener(action); } public void BindSecondary(UnityAction action) { - onSecondary.RemoveAllListeners(); + if (_modalSecondary != null) onSecondary.RemoveListener(_modalSecondary); + _modalSecondary = action; if (action != null) onSecondary.AddListener(action); } public void BindTertiary(UnityAction action) { - onTertiary.RemoveAllListeners(); + if (_modalTertiary != null) onTertiary.RemoveListener(_modalTertiary); + _modalTertiary = action; if (action != null) onTertiary.AddListener(action); } From 32820cbdee7feac56012ff34f609cd60b4b451db Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 15:55:51 +0530 Subject: [PATCH 10/38] fix(unity): unique canonical prefab paths + per-ref radio groups - Canonical prefab path: distinct refs that differed only in punctuation/whitespace (e.g. 'Btn/Primary' vs 'Btn-Primary') sanitized to the same .prefab file and silently overwrote each other. Append a stable SigHash of the full ref when SafeAsset is lossy; clean alphanumeric refs keep their name. Deterministic per ref, so existing prefab reuse survives re-import. - Radio ToggleGroup was keyed only by the immediate parent, so two independent radio sets under one frame became mutually exclusive. Key the group by (parent, canonical ref) via a per-build map so same-ref radios share a group while distinct refs get their own. --- unity/Editor/HierarchyBuilder.cs | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/unity/Editor/HierarchyBuilder.cs b/unity/Editor/HierarchyBuilder.cs index e82fce7..43cbf96 100644 --- a/unity/Editor/HierarchyBuilder.cs +++ b/unity/Editor/HierarchyBuilder.cs @@ -49,6 +49,10 @@ public class BuildContext // placed this build, resetting their name/rect/binding overrides to prefab // defaults (the page's first inputs came out as default-sized 'inputfield'). public readonly HashSet resolvedCanonicalRefs = new HashSet(); + // Radio canonicals are grouped by (parent, canonical ref): two independent radio + // SETS placed under the same parent must NOT share a ToggleGroup, or selecting an + // option in one set would clear the other. Keyed "parentInstanceID|ref". + public readonly Dictionary radioGroups = new Dictionary(); } public static class HierarchyBuilder @@ -61,6 +65,7 @@ public static GameObject BuildPage(Manifest manifest, Transform parent, BuildCon ctx.registered.Clear(); ctx.resolvedCanonicalRefs.Clear(); + ctx.radioGroups.Clear(); // One Figma frame = one root. If several, wrap them under a page root. // Compositor auto-create is suppressed for the whole element build: @@ -353,11 +358,18 @@ static GameObject BuildElement(ElementData e, Dictionary in ctx.log($"canonical {e.canonical.kind} '{e.canonical.Ref}' → placeholder"); inst = BuildPlaceholderButton(e, parent, ctx); } - // Radios under the same parent share one ToggleGroup → mutually exclusive. + // Radios of the SAME ref under one parent share a ToggleGroup (mutually + // exclusive), but distinct refs get distinct groups so independent radio + // sets under the same frame don't deselect each other. if (canonicalKind == "radio") { - var grp = parent.GetComponent() ?? parent.gameObject.AddComponent(); - grp.allowSwitchOff = true; + string groupKey = parent.GetInstanceID() + "|" + e.canonical.Ref; + if (!ctx.radioGroups.TryGetValue(groupKey, out var grp) || grp == null) + { + grp = parent.gameObject.AddComponent(); + grp.allowSwitchOff = true; + ctx.radioGroups[groupKey] = grp; + } var tg = inst.GetComponentInChildren(true); if (tg != null) tg.group = grp; } @@ -4007,7 +4019,7 @@ static GameObject ResolveOrGenerateCanonicalPrefab(ElementData e, BuildContext c // Candidate prefab: a library-mapped one (hand-made or previously // generated) wins lookup; else an existing generated prefab on disk. - string path = $"{CanonicalFolder}/{SafeAsset(refName)}.prefab"; + string path = $"{CanonicalFolder}/{CanonicalPrefabFile(refName)}.prefab"; var lib = ctx.canonical ?? LoadOrCreateCanonicalLibrary(); ctx.canonical = lib; var refEntry = lib.ResolveEntry(kind, refName); @@ -4554,6 +4566,18 @@ static string SafeAsset(string s) return new string(a); } + // Prefab file stem for a canonical ref. SafeAsset maps every non-alphanumeric + // char to '_', so two distinct refs differing only in punctuation/whitespace + // (e.g. "Btn/Primary" vs "Btn-Primary") would sanitize to the SAME file and + // silently overwrite each other. When sanitization is lossy, append a stable + // hash of the FULL ref so distinct refs map to distinct files; clean alphanumeric + // refs keep their pristine name. Deterministic per ref, so reuse survives re-import. + static string CanonicalPrefabFile(string refName) + { + string safe = SafeAsset(refName); + return safe == refName ? safe : $"{safe}_{SigHash(refName)}"; + } + static void AddTransparentRaycastTarget(GameObject go) { var img = go.AddComponent(); From 631aa3816448e1b6fd56749aa61cff64a777db18 Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 16:01:42 +0530 Subject: [PATCH 11/38] fix(server): survive post-bind server errors + close path-guard TOCTOU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Leader now installs a permanent 'error' handler on the http server (after bind) and on the WebSocketServer, so a runtime error (e.g. EMFILE/ENFILE on accept) is logged to stderr instead of crashing the bridge as an unhandled event and severing the plugin socket. - resolveAndValidateOutputPath now returns the canonicalized path it actually validated (not the raw resolved path), so a symlinked ancestor can't redirect a write between the containment check and the write. Test updated to expect the canonical path (os.tmpdir() is itself a symlink on macOS). Deferred: the post-takeover plugin-reconnect race in election.ts — bridge.send already returns a fast clear error (not a hang) when disconnected, and the fix is timing-sensitive concurrency best verified against the live multi-process system rather than changed blind. --- server/src/leader.ts | 12 ++++++++++++ server/src/tools.ts | 5 ++++- server/test/tools.test.ts | 6 ++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/server/src/leader.ts b/server/src/leader.ts index 66c338e..93a92ad 100644 --- a/server/src/leader.ts +++ b/server/src/leader.ts @@ -95,6 +95,10 @@ export class Leader implements PluginSender { constructor() { this.wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD_BYTES }); + // An 'error' event with no listener throws and crashes the process; log instead. + this.wss.on('error', (err) => { + process.stderr.write(`[figforge-bridge] ws server error: ${err instanceof Error ? err.message : String(err)}\n`); + }); this.server = http.createServer((req, res) => this.onRequest(req, res)); this.server.on('upgrade', (req, socket, head) => { if (req.url !== undefined && this.isAllowedWsUpgrade(req)) { @@ -148,6 +152,14 @@ export class Leader implements PluginSender { this.server.once('error', reject); this.server.listen(BRIDGE_PORT, '127.0.0.1', () => { this.server.off('error', reject); + // Bind succeeded: replace the one-shot bind guard with a permanent + // handler so a later runtime error (e.g. EMFILE/ENFILE on accept under + // fd exhaustion) is logged instead of crashing the bridge as an + // unhandled 'error' event — which would sever the plugin socket and + // force every follower to scramble for takeover. + this.server.on('error', (err) => { + process.stderr.write(`[figforge-bridge] http server error: ${err instanceof Error ? err.message : String(err)}\n`); + }); resolve(); }); }); diff --git a/server/src/tools.ts b/server/src/tools.ts index dc4f208..c7f1eae 100644 --- a/server/src/tools.ts +++ b/server/src/tools.ts @@ -67,7 +67,10 @@ export function resolveAndValidateOutputPath(outDir: string, workspaceRoot: stri if (realResolved !== realRoot && !realResolved.startsWith(realRoot + path.sep)) { throw new Error(`Refusing to write outside the bridge working directory: ${outDir}`); } - return resolved; + // Return the canonicalized path that was actually validated (not the raw + // `resolved`), so the bytes land at the location whose containment we checked + // — a symlinked ancestor can't redirect the write between check and use. + return realResolved; } interface UnityExport { diff --git a/server/test/tools.test.ts b/server/test/tools.test.ts index e589036..ac02419 100644 --- a/server/test/tools.test.ts +++ b/server/test/tools.test.ts @@ -8,7 +8,7 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; import os from 'node:os'; -import { existsSync } from 'node:fs'; +import { existsSync, realpathSync } from 'node:fs'; import { mkdtemp, mkdir, rm, symlink } from 'node:fs/promises'; import { resolveAndValidateOutputPath, executeExportUnity, validateManifest } from '../dist/tools.js'; @@ -57,7 +57,9 @@ describe('figmaNodeId schema', () => { }); describe('resolveAndValidateOutputPath', () => { - const root = path.resolve(os.tmpdir(), 'figforge-workspace'); + // realpath the tmp base: the guard now returns the canonicalized path it + // validated, and os.tmpdir() is itself a symlink on macOS (/var -> /private/var). + const root = path.resolve(realpathSync(os.tmpdir()), 'figforge-workspace'); it('allows the root itself and nested subdirs', () => { assert.equal(resolveAndValidateOutputPath('.', root), root); From 4820d10be9d7112cb99ee17f74ba79336a1d2601 Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 16:06:34 +0530 Subject: [PATCH 12/38] fix(unity): guard prefab-instantiate NRE, accessor recursion, frame-guard leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BuildElement: a broken managed prefab can make InstantiatePrefab return null; fall back to a labelled placeholder instead of NRE'ing on inst.name. - FigForgeFrameInspector: DrawFields and Collect recursed through group refs with no cycle guard, so cyclic accessor wiring hung the editor / StackOverflow'd. DrawFields now tracks the current expansion path; Collect tracks visited ids. - FrameManager.AddGuard(frame,…): a frame whose name sanitized to empty silently became a GLOBAL guard (the string overload treats an empty key as global), applying one frame's precondition to all navigation. Now warns and skips. --- unity/Editor/HierarchyBuilder.cs | 12 +++++++- .../Inspectors/FigForgeFrameInspector.cs | 28 ++++++++++++++----- unity/Runtime/FrameManager.cs | 12 +++++++- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/unity/Editor/HierarchyBuilder.cs b/unity/Editor/HierarchyBuilder.cs index 43cbf96..889b6b2 100644 --- a/unity/Editor/HierarchyBuilder.cs +++ b/unity/Editor/HierarchyBuilder.cs @@ -299,7 +299,17 @@ static GameObject BuildElement(ElementData e, Dictionary in if (prefab != null) { inst = (GameObject)UnityEditor.PrefabUtility.InstantiatePrefab(prefab, parent); - inst.name = ObjectName(e, canonicalKind); + if (inst == null) + { + // A broken/corrupt managed prefab can instantiate to null — + // fall back to a labelled placeholder instead of NRE'ing. + ctx.log($"canonical '{e.canonical.Ref}' prefab failed to instantiate → placeholder"); + inst = BuildPlaceholderButton(e, parent, ctx); + } + else + { + inst.name = ObjectName(e, canonicalKind); + } } else if (canonicalKind == "button" && e.canonical.shape != null) { diff --git a/unity/Editor/Inspectors/FigForgeFrameInspector.cs b/unity/Editor/Inspectors/FigForgeFrameInspector.cs index c7cb503..fb65b93 100644 --- a/unity/Editor/Inspectors/FigForgeFrameInspector.cs +++ b/unity/Editor/Inspectors/FigForgeFrameInspector.cs @@ -137,8 +137,9 @@ public static void DrawSection(SerializedObject so, UnityEngine.Object target, public static string AccessorKey(string fieldName) => !string.IsNullOrEmpty(fieldName) && fieldName[0] == '_' ? fieldName.Substring(1) : fieldName; - static void DrawFields(SerializedObject so, FieldInfo[] fields, Dictionary expanded) + static void DrawFields(SerializedObject so, FieldInfo[] fields, Dictionary expanded, HashSet path = null) { + path ??= new HashSet(); foreach (var f in fields) { var prop = so.FindProperty(f.Name); @@ -178,10 +179,20 @@ static void DrawFields(SerializedObject so, FieldInfo[] fields, Dictionary Validate(Component root, string rootName, FigForgeScreen return list; } - static void Collect(Component comp, string path, FigForgeScreen reg, List list) + static void Collect(Component comp, string path, FigForgeScreen reg, List list, HashSet visited = null) { + visited ??= new HashSet(); + // Cyclic group wiring would otherwise recurse until StackOverflow. + if (comp == null || !visited.Add(comp.GetInstanceID())) return; foreach (var f in FigForgeAccessorTree.AccessorFields(comp.GetType())) { string key = FigForgeAccessorTree.AccessorKey(f.Name); @@ -375,7 +389,7 @@ static void Collect(Component comp, string path, FigForgeScreen reg, List Date: Tue, 23 Jun 2026 16:17:16 +0530 Subject: [PATCH 13/38] fix(server): bounded wait for plugin reconnect after leader takeover A follower that lost the takeover race (or whose freshly-elected leader hasn't re-attached the plugin WS yet) immediately got 'Figma plugin is not connected' during a normal leader restart. Follower now exposes pingStatus() with the /ping pluginConnected flag; the send path waits up to 3s (250ms poll) for the plugin to reconnect before issuing the call. Bounded, side-effect-free (only re-probes /ping), happy path untouched; a truly-down plugin still errors after the wait. typecheck/build clean, 29/29 tests pass. --- server/src/election.ts | 44 ++++++++++++++++++++++++++++++++++++++++-- server/src/follower.ts | 16 +++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/server/src/election.ts b/server/src/election.ts index 70fd3b9..7ab2e01 100644 --- a/server/src/election.ts +++ b/server/src/election.ts @@ -14,6 +14,19 @@ import type { PluginSender, RpcResponse } from './types.js'; type Role = 'leader' | 'follower'; +// After a takeover we lost (someone else became leader), the winner's Figma +// plugin WebSocket has usually dropped and is mid-reconnect. Sending immediately +// races that reconnect and surfaces a spurious "plugin not connected" error during +// a normal leader-restart window. Poll the new leader's /ping pluginConnected flag +// for a short bounded period so a healthy system isn't reported as broken. The +// bound is small (well under the per-tool budgets) and only delays the lost-race +// path — the happy path (we are leader, or a follower whose leader's plugin is +// already connected) is untouched. +const PLUGIN_RECONNECT_WAIT_MS = 3_000; +const PLUGIN_RECONNECT_POLL_MS = 250; + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + export class FigForgeNode implements PluginSender { private role: Role = 'follower'; private leader: Leader | null = null; @@ -74,13 +87,40 @@ export class FigForgeNode implements PluginSender { return this.leader.send(tool, nodeIds, params, timeoutMs); } // Follower path. If the leader has vanished, try to take over once. - const alive = await this.follower.ping(); - if (!alive) { + const status = await this.follower.pingStatus(); + if (!status.reachable) { await this.tryBecomeLeader(); if (this.role === 'leader' && this.leader) { return this.leader.send(tool, nodeIds, params, timeoutMs); } + // We lost the port race: a brand-new leader just bound. Its plugin socket + // is almost certainly mid-reconnect, so wait (bounded) for it to land + // before sending — otherwise we'd surface a spurious not-connected error + // during a normal leader restart. + await this.waitForPluginReconnect(); + } else if (!status.pluginConnected) { + // Leader is up but its plugin isn't connected yet (e.g. it just took over + // from a crashed leader). Same bounded wait before issuing the send. + await this.waitForPluginReconnect(); } return this.follower.send(tool, nodeIds, params, timeoutMs); } + + /** + * Poll the leader's /ping pluginConnected flag until the plugin reconnects or + * the bounded window elapses. Returns regardless; the subsequent send still + * surfaces a real not-connected error if the plugin never came back. This only + * retries the wait/probe — it never re-issues a tool call, so it is + * side-effect-free. + */ + private async waitForPluginReconnect(): Promise { + const deadline = Date.now() + PLUGIN_RECONNECT_WAIT_MS; + while (Date.now() < deadline) { + const status = await this.follower.pingStatus(); + if (status.reachable && status.pluginConnected) return; + const remaining = deadline - Date.now(); + if (remaining <= 0) return; + await delay(Math.min(PLUGIN_RECONNECT_POLL_MS, remaining)); + } + } } diff --git a/server/src/follower.ts b/server/src/follower.ts index 2b413c9..ba3686b 100644 --- a/server/src/follower.ts +++ b/server/src/follower.ts @@ -13,11 +13,23 @@ export class Follower implements PluginSender { private base = `http://127.0.0.1:${BRIDGE_PORT}`; async ping(): Promise { + return (await this.pingStatus()).reachable; + } + + /** + * Probe the leader's /ping. `reachable` means a leader answered; `pluginConnected` + * reflects whether that leader currently holds the Figma plugin WebSocket (the + * /ping body exposes it — see Leader.onRequest). A freshly-elected leader is + * reachable but not yet plugin-connected during the plugin's reconnect window. + */ + async pingStatus(): Promise<{ reachable: boolean; pluginConnected: boolean }> { try { const r = await fetch(`${this.base}/ping`, { signal: AbortSignal.timeout(2000) }); - return r.ok; + if (!r.ok) return { reachable: false, pluginConnected: false }; + const body = (await r.json()) as { pluginConnected?: unknown }; + return { reachable: true, pluginConnected: body?.pluginConnected === true }; } catch { - return false; + return { reachable: false, pluginConnected: false }; } } From dc9288dd140d742ab5b6c2140f489cabd87c7b0c Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 16:17:16 +0530 Subject: [PATCH 14/38] fix(unity): guard FrameManager against OnHide re-entrant navigation The hide loop fires OnHide() before Current is assigned; a handler that navigates re-entrantly there had its result clobbered when the outer Show unwound. Add a generation token: each ShowInternal claims a generation before the hide loop and only commits Current/visibility if it's still current, so the innermost nav wins. Normal (non-re-entrant) path is behavior-identical. --- unity/Runtime/FrameManager.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/unity/Runtime/FrameManager.cs b/unity/Runtime/FrameManager.cs index cf69607..8685145 100644 --- a/unity/Runtime/FrameManager.cs +++ b/unity/Runtime/FrameManager.cs @@ -144,6 +144,12 @@ internal bool ShowUnguarded(FigForgeFrame frame) const int MaxRedirectDepth = 8; + // Re-entrancy generation token. Hiding other screens fires OnHide(); a handler may + // navigate re-entrantly during that loop. Each ShowInternal claims a generation, and + // only the call whose generation is still current commits the final Current/visibility + // — so the innermost nav wins and the outer call doesn't clobber it on unwind. + int _showGen; + bool ShowInternal(FigForgeFrame target, string label, bool runGuards, int redirectDepth, FigForgeNavLink via) { BindAll(); @@ -186,6 +192,9 @@ bool ShowInternal(FigForgeFrame target, string label, bool runGuards, int redire Debug.LogWarning($"[FigForge] FrameManager: screen '{target.ScreenKey}' requires shell '{target.shellKey}', but no matching shell is registered."); return false; } + // Claim a generation after all early-return guards: any nested Show triggered + // during the hide loop (or shell SetVisible) below bumps _showGen past ours. + int gen = ++_showGen; foreach (var s in screens) if (s != null && s != target && s != activeShell) s.SetVisible(false); @@ -202,6 +211,10 @@ bool ShowInternal(FigForgeFrame target, string label, bool runGuards, int redire RefreshCompositors(target.gameObject); } FillParent(target.GetComponent()); + // If a nested Show ran during the hide loop / shell work above (e.g. from an + // OnHide handler), it bumped _showGen and already committed ITS target. Bail + // without touching Current/visibility so we don't clobber the innermost nav. + if (_showGen != gen) return true; // Assign Current BEFORE SetVisible(true): SetVisible fires OnShow(), and if a // handler navigates again re-entrantly, that inner call must win. Setting Current // first means the inner navigation's result isn't clobbered when we unwind here. From dfe9afacb3ba54b4054675e58ece949ea249e415 Mon Sep 17 00:00:00 2001 From: Rajesh D'Monte Date: Tue, 23 Jun 2026 16:17:16 +0530 Subject: [PATCH 15/38] fix(unity): wire each nav link's button exactly once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NavBinder.Start does a scene-global find, so N binders each wired every link → N listeners per button → one click fired Show N times. Add a NonSerialized 'bound' marker on FigForgeNavLink; binders skip already-bound links. Resets per scene load, so each link is wired exactly once per load. --- unity/Runtime/FigForgeNavBinder.cs | 5 +++++ unity/Runtime/FigForgeNavLink.cs | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/unity/Runtime/FigForgeNavBinder.cs b/unity/Runtime/FigForgeNavBinder.cs index b4e3803..2735abb 100644 --- a/unity/Runtime/FigForgeNavBinder.cs +++ b/unity/Runtime/FigForgeNavBinder.cs @@ -37,6 +37,10 @@ void Start() foreach (var link in links) { if (link == null || string.IsNullOrEmpty(link.targetScreen)) continue; + // Idempotency guard: the find is scene-global, so every binder + // sees every link. Skip links a prior binder already wired so each + // button's onClick gets exactly one listener (one click => one Show). + if (link.bound) continue; // No ?? here: in the editor a missing Button comes back as Unity's // fake-null stub, which ?? treats as found — explicit == null is safe. var btn = link.GetComponent