diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57131c7..a93ef3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,18 @@ jobs: # files directly via `node --test test/*.test.ts` (native TypeScript # type stripping landed in Node 22.6). node-version: 22 + # Cache the npm download cache, keyed off both lockfiles, so the + # per-package `npm ci` steps below restore from cache instead of + # re-downloading every dependency on each run. + cache: 'npm' + cache-dependency-path: | + plugin/package-lock.json + server/package-lock.json - - 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 +56,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/.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: 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/plugin/package-lock.json b/plugin/package-lock.json index 98b0982..a79c008 100644 --- a/plugin/package-lock.json +++ b/plugin/package-lock.json @@ -1,12 +1,12 @@ { "name": "figforge", - "version": "1.0.57", + "version": "1.0.58", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "figforge", - "version": "1.0.57", + "version": "1.0.58", "license": "MIT", "dependencies": { "@types/earcut": "^3.0.0", diff --git a/plugin/package.json b/plugin/package.json index 8d11bed..5a166e8 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,6 +1,6 @@ { "name": "figforge", - "version": "1.0.57", + "version": "1.0.58", "description": "FigForge — export Figma frames as Unity uGUI-ready manifests + PNG assets", "license": "MIT", "private": true, diff --git a/plugin/src/exporter.ts b/plugin/src/exporter.ts index 6af4693..f1527b5 100644 --- a/plugin/src/exporter.ts +++ b/plugin/src/exporter.ts @@ -282,6 +282,17 @@ function vanillaEffects(effects: Shadow[] | undefined): Shadow[] | undefined { return drops.length ? drops : undefined; } +// PNG-baked nodes: exportAsync already composites the node's OWN drop/inner +// shadow + layer blur into the sprite, so Unity must NOT re-render them on top +// (double shadow/blur). Keep only effects that read the live backdrop and so +// cannot be baked — background/backdrop blur — which Unity's compositor draws +// against dynamic pixels behind the sprite. +function bakedAssetEffects(effects: Shadow[] | undefined): Shadow[] | undefined { + if (!effects) return undefined; + const live = effects.filter((e) => e.kind === 'backgroundBlur'); + return live.length ? live : undefined; +} + function buildStyle(node: SceneNode, options: ExportOptions, hasAsset: boolean): Style | undefined { const opacity = (node as unknown as { opacity?: number }).opacity ?? 1; const blendMode = nodeBlendMode(node); @@ -394,28 +405,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'] { @@ -1071,6 +1078,18 @@ export async function exportDesign( const assets: BinaryAsset[] = []; const assetEntries: ManifestAsset[] = []; const hashToFile = new Map(); + // Disambiguate file-name collisions in O(1) per asset (a Set lookup) instead + // of an O(n) scan over `assets`, and always rebuild the candidate from the + // BASE name (`base_1.png`, `base_2.png`, …) so repeated collisions never + // compound suffixes into `base_1_2.png`. + const usedAssetNames = new Set(); + function uniqueAssetName(base: string): string { + let candidate = base; + let n = 1; + while (usedAssetNames.has(candidate)) candidate = base.replace('.png', `_${n++}.png`); + usedAssetNames.add(candidate); + return candidate; + } const assetByNode = new Map(); const failedExportIds = new Set(); @@ -1147,10 +1166,7 @@ export async function exportDesign( let file = hashToFile.get(hash); const dims = pngSize(bytes); if (!file) { - file = generateFileName(root.name, p.node.name, scaleNum); - // disambiguate collisions - let n = 1; - while (assets.some((a) => a.name === file)) file = file.replace('.png', `_${n++}.png`); + file = uniqueAssetName(generateFileName(root.name, p.node.name, scaleNum)); hashToFile.set(hash, file); assets.push({ name: file, data: bytes }); assetEntries.push({ file, nodeId: p.node.id, scale: scaleNum }); @@ -1199,9 +1215,7 @@ export async function exportDesign( const dims = pngSize(bytes); let file = hashToFile.get(hash); if (!file) { - file = generateFileName(root.name, `${master.name}_${layerName}`, scaleNum); - let n = 1; - while (assets.some((a) => a.name === file)) file = file.replace('.png', `_${n++}.png`); + file = uniqueAssetName(generateFileName(root.name, `${master.name}_${layerName}`, scaleNum)); hashToFile.set(hash, file); assets.push({ name: file, data: bytes }); assetEntries.push({ file, nodeId: layer.id, scale: scaleNum }); @@ -1234,9 +1248,7 @@ export async function exportDesign( const dims = pngSize(bytes); let file = hashToFile.get(hash); if (!file) { - file = generateFileName(root.name, nameHint, scaleNum); - let n = 1; - while (assets.some((a) => a.name === file)) file = file.replace('.png', `_${n++}.png`); + file = uniqueAssetName(generateFileName(root.name, nameHint, scaleNum)); hashToFile.set(hash, file); assets.push({ name: file, data: bytes }); assetEntries.push({ file, nodeId: node.id, scale: scaleNum }); @@ -1270,6 +1282,12 @@ export async function exportDesign( } } + // Depth cap for the recursive state-layer shape/colour finders below. A button + // state layer's renderable fill lives within a handful of nesting levels; this + // bounds the descent so a pathologically deep (or accidentally cyclic-looking) + // subtree can't blow the stack or stall the export. + const MAX_STATE_RECURSION = 32; + // First solid-fill colour of a node → RGBA (null if no solid fill). function solidRGBA(node: SceneNode): RGBA | null { const fills = (node as unknown as { fills?: Paint[] | symbol }).fills; @@ -1284,12 +1302,13 @@ export async function exportDesign( // so reading only the layer's own fill grabs the (often default) frame colour // instead of the real one. Called on an INSTANCE's state layer it picks up that // instance's overridden colour. null = no solid fill (e.g. a gradient state). - function stateSolid(node: SceneNode): RGBA | null { + function stateSolid(node: SceneNode, depth = 0): RGBA | null { const own = solidRGBA(node); if (own) return own; + if (depth >= MAX_STATE_RECURSION) return null; if ('children' in node) { for (const c of (node as ChildrenMixin).children as SceneNode[]) { - const f = stateSolid(c); + const f = stateSolid(c, depth + 1); if (f) return f; } } @@ -1482,16 +1501,17 @@ export async function exportDesign( return node.type !== 'TEXT' && node.type !== 'SLICE'; } - async function stateShape(node: SceneNode): Promise { + async function stateShape(node: SceneNode, depth = 0): Promise { if (visualShapeCandidate(node)) { const own = await shapeOfWithAsset(node); if (own) return own; } + if (depth >= MAX_STATE_RECURSION) return null; if ('children' in node) { const kids = (node as ChildrenMixin).children as SceneNode[]; for (const c of kids) { if (c.name.toLowerCase() === 'label' || c.name.toLowerCase() === 'hitarea') continue; - const sh = await stateShape(c); + const sh = await stateShape(c, depth + 1); if (sh) return sh; } } @@ -1566,14 +1586,31 @@ export async function exportDesign( return true; } + // Variant props for a component set's COMPONENT children, extracted ONCE per + // set (a single async pass) and cached by set id. componentSetStateSource is + // called many times per master (one per state, plus exportStates), and each + // call would otherwise re-extract props for every sibling — O(states × + // children) async work over an unchanging set. + type SetVariantEntry = { child: SceneNode; variants: CanonicalVariantProps | undefined }; + const setVariantCache = new Map(); + async function setVariantEntries(set: ComponentSetNode): Promise { + const cached = setVariantCache.get(set.id); + if (cached) return cached; + const entries: SetVariantEntry[] = []; + for (const child of set.children as SceneNode[]) { + if (child.type !== 'COMPONENT') continue; + entries.push({ child, variants: await extractVariantProps(child) }); + } + setVariantCache.set(set.id, entries); + return entries; + } + async function componentSetStateSource(master: SceneNode, state: string): Promise { const comp = master.type === 'COMPONENT' ? master as ComponentNode : undefined; const set = comp?.parent?.type === 'COMPONENT_SET' ? comp.parent as ComponentSetNode : undefined; if (!set) return undefined; const base = await extractVariantProps(master); - for (const child of set.children as SceneNode[]) { - if (child.type !== 'COMPONENT') continue; - const variants = await extractVariantProps(child); + for (const { child, variants } of await setVariantEntries(set)) { if (variants?.state === state && compatibleVariant(base, variants)) return child; } return undefined; @@ -1682,9 +1719,7 @@ export async function exportDesign( const dims = pngSize(bytes); let file = hashToFile.get(hash); if (!file) { - file = generateFileName(root.name, node.name, scaleNum); - let n = 1; - while (assets.some((a) => a.name === file)) file = file.replace('.png', `_${n++}.png`); + file = uniqueAssetName(generateFileName(root.name, node.name, scaleNum)); hashToFile.set(hash, file); assets.push({ name: file, data: bytes }); assetEntries.push({ file, nodeId: node.id, scale: scaleNum }); @@ -1727,7 +1762,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); @@ -1755,7 +1792,7 @@ export async function exportDesign( ? stretchTransform() : mapTransform({ rect: { x: nx, y: ny, w: nw, h: nh }, - parent: parentDims(node, planById), + parent: parentDims(node), abs: absXY(node), parentAbs: parentAbsXY(node), horizontal: (node as unknown as { constraints?: Constraints }).constraints?.horizontal, @@ -2696,7 +2733,7 @@ export async function exportDesign( nodeY = nodeAbs[1] - gy; parentAbs = layoutParentAbs(lp); } else { - parentSize = parentDims(node, planById); + parentSize = parentDims(node); const xy = localXY(node); nodeX = xy.x; nodeY = xy.y; @@ -2755,6 +2792,17 @@ export async function exportDesign( delete style.stroke; delete style.strokes; } + // Any PNG-backed node (bake-self or a plain exportable image/icon/vector) + // already has its own drop/inner shadow + layer blur composited into the + // sprite by exportAsync. Strip those baked effects so Unity doesn't draw + // them a second time on top; keep only live backdrop-reading blur. (Vanilla + // drops effects entirely below — this just makes the non-vanilla path match + // what flattenVanillaStyle already does for hasAsset.) + if (hasAsset && style?.effects) { + const live = bakedAssetEffects(style.effects); + if (live) style.effects = live; + else delete style.effects; + } style = flattenVanillaStyle(style, hasAsset); if (hasAsset || style?.fill || style?.stroke) components.push('Image'); } @@ -2996,9 +3044,26 @@ function decomposeFlip(node: SceneNode): { rotation: number; flipX: boolean; fli if (!rt || !rt[0] || !rt[1]) return { rotation: fallbackRot, flipX: false, flipY: false }; const a = rt[0][0], b = rt[0][1], c = rt[1][0], d = rt[1][1]; if (a * d - b * c >= 0) return { rotation: fallbackRot, flipX: false, flipY: false }; - // Reflection. Two single-axis factorings exist; pick the one with the SMALLER - // residual rotation, so a pure horizontal flip → flipX/0° and a pure vertical - // flip → flipY/0° (instead of the other axis + 180°): + // The angle recovery below assumes M is a (uniformly scaled) rotation·reflection: + // the atan2 ratios are only meaningful when the two columns are orthogonal and + // equal-length. A non-uniform scale or shear breaks that assumption, so detect it + // and fall back to node.rotation rather than emitting a bogus angle. (The flip + // flags still come from the determinant sign, which is robust to scale/shear.) + const col0 = Math.hypot(a, c), col1 = Math.hypot(b, d); + const dot = a * b + c * d; // 0 ⇒ orthogonal columns (no shear) + const orthoTol = 1e-3 * Math.max(col0 * col1, 1e-6); + const scaleTol = 1e-2 * Math.max(col0, col1, 1e-6); + if (col0 < 1e-6 || col1 < 1e-6 || Math.abs(dot) > orthoTol || Math.abs(col0 - col1) > scaleTol) { + // Sheared / non-uniformly-scaled reflection: the clean R·diag factoring doesn't + // apply. Keep node.rotation and mirror horizontally (Figma's default flip); + // exact recovery would need a full polar/QR decomposition we don't attempt here. + return { rotation: fallbackRot, flipX: true, flipY: false }; + } + // Reflection with orthogonal, equal-length columns (a uniformly scaled + // rotation·reflection). The atan2 ratios below are scale-invariant — a positive + // uniform scale cancels — so they recover the true angle. Pick the single-axis + // factoring with the SMALLER residual rotation, so a pure horizontal flip → + // flipX/0° and a pure vertical flip → flipY/0° (instead of the other axis + 180°): // M = R(θ)·diag(-1,1) ⇒ θ = atan2(b, -a) [flip X — origin is the RIGHT edge] // M = R(θ)·diag(1,-1) ⇒ θ = atan2(b, a) [flip Y — origin is the BOTTOM edge] const thetaX = (Math.atan2(b, -a) * 180) / Math.PI; @@ -3084,10 +3149,7 @@ function layoutParentAbs(lp: SceneNode): [number, number] | undefined { return absXY(lp); } -function parentDims( - node: SceneNode, - planById: Map -): { w: number; h: number } { +function parentDims(node: SceneNode): { w: number; h: number } { const parent = (node as unknown as { parent?: BaseNode | null }).parent; if (parent && 'width' in parent) { return { diff --git a/plugin/src/main.ts b/plugin/src/main.ts index 81dc818..138da3f 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(); @@ -42,24 +51,39 @@ function selectedRoot(): SceneNode | null { return ok ? node : null; } +// buildTree is async, so two pushSelection runs can overlap (selectionchange +// firing again, or selectionchange racing the startup/explicit reload calls). +// A monotonically-increasing token makes only the LATEST run post to the UI: a +// stale run that resolves second is dropped rather than overwriting fresher +// state. The whole body is wrapped so a buildTree rejection surfaces as a +// no-selection state instead of an unhandled rejection swallowed by `void`. +let pushSelectionToken = 0; async function pushSelection() { - const root = selectedRoot(); - if (!root) { - figma.ui.postMessage({ type: 'no-selection' }); - return; + const token = ++pushSelectionToken; + try { + const root = selectedRoot(); + if (!root) { + if (token === pushSelectionToken) figma.ui.postMessage({ type: 'no-selection' }); + return; + } + const tree = await buildTree(root, excluded); + if (token !== pushSelectionToken) return; // superseded by a newer pushSelection + const size = { + w: (root as unknown as { width?: number }).width ?? 0, + h: (root as unknown as { height?: number }).height ?? 0, + }; + let count = 0; + const walk = (n: typeof tree) => { + count++; + n.children.forEach(walk); + }; + walk(tree); + figma.ui.postMessage({ type: 'selection-info', name: root.name, elementCount: count, size, tree }); + } catch (e) { + if (token === pushSelectionToken) { + figma.ui.postMessage({ type: 'no-selection', error: String((e as Error)?.message || e) }); + } } - const tree = await buildTree(root, excluded); - const size = { - w: (root as unknown as { width?: number }).width ?? 0, - h: (root as unknown as { height?: number }).height ?? 0, - }; - let count = 0; - const walk = (n: typeof tree) => { - count++; - n.children.forEach(walk); - }; - walk(tree); - figma.ui.postMessage({ type: 'selection-info', name: root.name, elementCount: count, size, tree }); } figma.on('selectionchange', () => { void pushSelection(); }); @@ -85,6 +109,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 +156,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]); @@ -270,6 +297,14 @@ figma.ui.onmessage = async (msg: { type: string; [k: string]: unknown }) => { } case 'create-button': { + // Creators switch figma.currentPage and run async; an in-flight export + // mutates node.visible and would be corrupted by the page switch — reject + // behind the same gate the export paths use (rejected as error-flagged + // 'status', not 'export-error', so the running export's UI is untouched). + if (exportInFlight) { + figma.ui.postMessage({ type: 'status', message: EXPORT_BUSY_MESSAGE, error: true }); + break; + } try { useComponentsPage = (msg as { componentsPage?: boolean }).componentsPage !== false; await createCanonicalButton(); @@ -286,6 +321,10 @@ figma.ui.onmessage = async (msg: { type: string; [k: string]: unknown }) => { } case 'create-canonical': { + if (exportInFlight) { // see 'create-button' — creators switch pages, gate behind export + figma.ui.postMessage({ type: 'status', message: EXPORT_BUSY_MESSAGE, error: true }); + break; + } try { useComponentsPage = (msg as { componentsPage?: boolean }).componentsPage !== false; const comp = await createCanonical(String((msg as { kind?: string }).kind || ''), @@ -315,6 +354,10 @@ figma.ui.onmessage = async (msg: { type: string; [k: string]: unknown }) => { } case 'create-shell': { + if (exportInFlight) { // see 'create-button' — creators switch pages, gate behind export + figma.ui.postMessage({ type: 'status', message: EXPORT_BUSY_MESSAGE, error: true }); + break; + } try { useComponentsPage = (msg as { componentsPage?: boolean }).componentsPage !== false; const result = await createShellScaffold((msg as { shellOpts?: Partial }).shellOpts); @@ -373,7 +416,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 +845,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 +864,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; @@ -839,16 +882,22 @@ async function handleMcp(req: McpRequest) { // vs ~3.7× as decimal number[] text. The bridge (server/src/tools.ts) // accepts both forms during the transition. const shots: { nodeId: string; data: string }[] = []; + // Ids that resolve to nothing or to a non-exportable node are reported in + // `missing` rather than silently dropped — otherwise a shorter screenshots + // array reads as success and the caller can't tell partial from complete. + const missing: 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; }).exportAsync({ format: 'PNG', constraint: { type: 'SCALE', value: scale } }); shots.push({ nodeId: id, data: bytesToBase64(bytes) }); + } else { + missing.push(id); } } - response.data = { screenshots: shots }; + response.data = { screenshots: shots, missing }; break; } case 'export_unity': { @@ -868,8 +917,12 @@ async function handleMcp(req: McpRequest) { }; const exportSets = exportSetsWithConfigs(req.params?.elementConfigs as ElementConfig[] | undefined); const exports: unknown[] = []; + // Ids that resolve to nothing or to a non-exportable node are reported + // in `missing` rather than silently dropped (see get_screenshot) — a + // shorter exports array would otherwise read as a complete success. + const missing: 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 result = await exportDesign( node, @@ -889,9 +942,11 @@ async function handleMcp(req: McpRequest) { // either form. assets: result.assets.map((a) => ({ name: a.name, data: bytesToBase64(a.data) })), }); + } else { + missing.push(id); } } - response.data = { exports }; + response.data = { exports, missing }; } finally { exportInFlight = false; } @@ -899,10 +954,13 @@ async function handleMcp(req: McpRequest) { } case 'export_project_unity': { if (exportInFlight) throw new Error(EXPORT_BUSY_MESSAGE); - const found = collectScreens(figma.currentPage); - if (found.length === 0) throw new Error('No top-level frames (or frames in sections) on this page.'); + // Claim the gate immediately after the guard (before collectScreens, which + // reads the tree exportDesign mutates) so a concurrent export can't slip in + // during collection — matching export_unity above. Cleared in `finally`. exportInFlight = true; try { + const found = collectScreens(figma.currentPage); + if (found.length === 0) throw new Error('No top-level frames (or frames in sections) on this page.'); const scale = (req.params?.scale as ExportScale) || DEFAULT_EXPORT_SCALE; const options = { ...DEFAULT_EXPORT_OPTIONS, @@ -943,6 +1001,9 @@ async function handleMcp(req: McpRequest) { break; } case 'create_canonical': { + // Creators switch figma.currentPage and run async; reject while an export + // is mutating node.visible (same gate as export_unity above). + if (exportInFlight) throw new Error(EXPORT_BUSY_MESSAGE); useComponentsPage = (req.params as { componentsPage?: boolean } | undefined)?.componentsPage !== false; const kind = String((req.params as { kind?: string } | undefined)?.kind || ''); const comp = kind === 'button' @@ -963,6 +1024,7 @@ async function handleMcp(req: McpRequest) { break; } case 'create_shell': { + if (exportInFlight) throw new Error(EXPORT_BUSY_MESSAGE); // see 'create_canonical' useComponentsPage = (req.params as { componentsPage?: boolean } | undefined)?.componentsPage !== false; const result = await createShellScaffold((req.params as { shellOpts?: Partial } | undefined)?.shellOpts); response.data = { @@ -1007,6 +1069,7 @@ async function loadUiFont(): Promise { } } const all = await figma.listAvailableFontsAsync(); + if (all.length === 0) throw new Error('No fonts available to load'); const f = all[0].fontName; await figma.loadFontAsync(f); return f; diff --git a/plugin/src/mapper.ts b/plugin/src/mapper.ts index f993b7f..4b647e1 100644 --- a/plugin/src/mapper.ts +++ b/plugin/src/mapper.ts @@ -135,14 +135,32 @@ export function mapTransform(input: MapInput): UnityTransform { const pTop = snapPixel(py); const pBottom = snapPixel(py + parent.h); - pw = pRight > pLeft ? pRight - pLeft : parent.w; - ph = pBottom > pTop ? pBottom - pTop : parent.h; - - // Child rect edges relative to the SNAPPED parent origin (Unity parent-space). - left = aLeft - pLeft; - right = aRight - pLeft; - top = ph - (aTop - pTop); // figma top edge, Y-flipped - bottom = ph - (aBottom - pTop); // figma bottom edge, Y-flipped + // Degenerate snap (parent < 1px collapses pRight<=pLeft, or rounding inverts + // the edge) falls back to the UNSNAPPED parent size. When it does, the child + // edges must also be derived from unsnapped space on that axis — otherwise we + // mix a snapped child rect against an unsnapped parent size and the anchors + // resolve against the wrong origin/extent. + const widthOk = pRight > pLeft; + const heightOk = pBottom > pTop; + pw = widthOk ? pRight - pLeft : parent.w; + ph = heightOk ? pBottom - pTop : parent.h; + + // Child rect edges relative to the parent origin (Unity parent-space). Snapped + // edges when the parent axis snapped cleanly; unsnapped when we fell back. + if (widthOk) { + left = aLeft - pLeft; + right = aRight - pLeft; + } else { + left = fax - px; + right = fax + rect.w - px; + } + if (heightOk) { + top = ph - (aTop - pTop); // figma top edge, Y-flipped + bottom = ph - (aBottom - pTop); // figma bottom edge, Y-flipped + } else { + top = ph - (fay - py); // figma top edge, Y-flipped (unsnapped) + bottom = ph - (fay + rect.h - py); // figma bottom edge, Y-flipped (unsnapped) + } } else { pw = parent.w; ph = parent.h; diff --git a/plugin/src/naming.ts b/plugin/src/naming.ts index 35ff1d0..84a04d4 100644 --- a/plugin/src/naming.ts +++ b/plugin/src/naming.ts @@ -33,11 +33,12 @@ export function generateFileName(root: string, element: string, scale: number): // between is the instance name. Examples: // Btn_Save_PrimaryButton → kind=button, instance=Save, ref=PrimaryButton // Btn_Cancel_Secondary_Button → kind=button, instance=Cancel, ref=Secondary_Button -// (ref is the *last* token: "Button"; see below) +// (ref is everything AFTER the instance token) // -// To keep multi-word refs usable we treat the ref as the final token only. -// Designers who need underscores in a ref should avoid them; the convention is -// deliberately simple while we support a single canonical kind (button). +// The first token is the kind tag, the SECOND token is the instance name, and the +// REMAINDER (joined back with underscores) is the ref — so multi-word refs like +// "Secondary_Button" survive. Three-part names are unchanged (the remainder is a +// single token). The instance is a single token by design; refs may be multi-word. // --------------------------------------------------------------------------- const KIND_TAGS: Record = { btn: 'button', @@ -80,8 +81,8 @@ export function parseCanonical(name: string): CanonicalRef | null { const kind = KIND_TAGS[parts[0].toLowerCase()]; if (!kind) return null; - const ref = parts[parts.length - 1]; - const instanceName = parts.slice(1, parts.length - 1).join('_'); + const instanceName = parts[1]; + const ref = parts.slice(2).join('_'); if (!ref || !instanceName) return null; return { kind, ref, instanceName }; diff --git a/plugin/src/traverser.ts b/plugin/src/traverser.ts index fb669d3..ca67441 100644 --- a/plugin/src/traverser.ts +++ b/plugin/src/traverser.ts @@ -110,6 +110,14 @@ const VECTOR_TYPES = new Set([ const CONTAINER_TYPES = new Set(['FRAME', 'GROUP', 'COMPONENT', 'INSTANCE', 'COMPONENT_SET']); +/** + * Paint opacity at or below which a paint contributes nothing to the rendered + * output ("effectively invisible"). Shared so the exportability check here and + * the vector-mesh path (vector.ts singleSolid) agree on faint paints instead of + * each picking its own threshold (was ===0 here vs >0.0001 there). + */ +export const INVISIBLE_PAINT_EPS = 0.0001; + /** * A paint that contributes nothing to the rendered output and must be treated * as "no fill": hidden, fully transparent, or an IMAGE paint with no source @@ -120,7 +128,7 @@ const CONTAINER_TYPES = new Set(['FRAME', 'GROUP', 'COMPONENT', 'INSTANCE', 'COM export function isEmptyPaint(paint: Paint | undefined | null): boolean { if (!paint) return true; if (paint.visible === false) return true; - if (typeof paint.opacity === 'number' && paint.opacity === 0) return true; + if (typeof paint.opacity === 'number' && paint.opacity <= INVISIBLE_PAINT_EPS) return true; if (paint.type === 'IMAGE' && !(paint as ImagePaint).imageHash) return true; return false; } @@ -167,6 +175,11 @@ function hasBackgroundBlur(node: SceneNode): boolean { export function hasVisibleStroke(node: SceneNode): boolean { const w = (node as unknown as { strokeWeight?: number }).strokeWeight; + // Only a numeric weight <= 0 definitively hides the stroke. When strokeWeight + // is figma.mixed (per-side weights via strokeTopWeight/etc.), `typeof w` is not + // 'number', so we fall through and conservatively treat the stroke as visible + // if any stroke paint is present — at least one side could be > 0, and missing + // a real border is worse than baking a thin one. if (typeof w === 'number' && w <= 0) return false; return paints(node, 'strokes').some((f) => !isEmptyPaint(f)); } @@ -175,6 +188,17 @@ function isVisible(node: SceneNode): boolean { return (node as unknown as { visible?: boolean }).visible !== false; } +/** + * Fully transparent via node-level opacity (opacity===0). Such a node renders + * nothing, so rasterizing it bakes a blank PNG (+ an invisible Image) into Unity. + * `visible:false` is already filtered by isVisible; opacity is a separate channel + * Figma exposes independently, so it needs its own guard in the exportable check. + */ +function isZeroOpacity(node: SceneNode): boolean { + const o = (node as unknown as { opacity?: number }).opacity; + return typeof o === 'number' && o === 0; +} + function hasChildren(node: SceneNode): node is SceneNode & ChildrenMixin { return 'children' in node; } @@ -198,6 +222,7 @@ export function isIconContainer(node: SceneNode): boolean { */ export function isExportable(node: SceneNode): boolean { if (!isVisible(node)) return false; + if (isZeroOpacity(node)) return false; // opacity:0 → renders nothing, bakes a blank PNG if (VECTOR_TYPES.has(node.type)) return true; if (node.type === 'TEXT') return false; // structural by default if (isIconContainer(node)) return true; // all-vector children → single icon diff --git a/plugin/src/types.ts b/plugin/src/types.ts index 1abb78f..98c6277 100644 --- a/plugin/src/types.ts +++ b/plugin/src/types.ts @@ -18,7 +18,7 @@ export const MANIFEST_VERSION = '2.0'; // Canonical-control capture generation this plugin emits — counterpart: // unity/Editor/HierarchyBuilder.cs `CanonicalSchema`. Keep the two numbers in // lockstep; the importer warns (but continues) when they differ. -export const CANONICAL_SCHEMA = 63; +export const CANONICAL_SCHEMA = 64; // --------------------------------------------------------------------------- // Geometry primitives @@ -191,6 +191,9 @@ export interface ButtonShape { borderWidth?: number; borderAlign?: StrokeAlign; // inside|outside|center (default inside) effects?: Shadow[]; + // legacy: read by the Unity importer for older manifests; NEVER emitted by the current (2.0) exporter + shadow?: Shadow; // first drop shadow on the regular layer + shadows?: Shadow[]; // all visible drop shadows } export interface CanonicalStateShapes { diff --git a/plugin/src/ui.ts b/plugin/src/ui.ts index e63a103..edf99a6 100644 --- a/plugin/src/ui.ts +++ b/plugin/src/ui.ts @@ -472,7 +472,7 @@ function renderTree() { row.innerHTML = ` ${children.length ? (expanded || revealingMatches ? '▾' : '▸') : ''} - ${TYPE_SHORT[node.type] || node.type.slice(0, 3)} + ${escapeHtml(TYPE_SHORT[node.type] || node.type.slice(0, 3))} ${escapeHtml(node.displayName)} ${node.canonicalRef ? `${escapeHtml(node.canonicalRef)}` : ''} `; @@ -721,10 +721,18 @@ $('#exportFrameUnityBtn').addEventListener('click', () => { post({ type: 'export', target: 'unity', scale: parseScale(), options: currentOptions(), elementConfigs: collectConfigs() }); }); -function unityImportUrl(): string { +// The plugin manifest's networkAccess.allowedDomains only whitelists +// http://localhost:1995 (Figma matches the exact port — no wildcards), so any +// other port is blocked by Figma before the fetch ever leaves the iframe. +const UNITY_ALLOWED_PORT = 1995; + +function unityImportPort(): number { const raw = ($('#unityPort') as HTMLInputElement)?.value; - const port = Math.min(65535, Math.max(1024, parseInt(raw, 10) || 1995)); - return `http://localhost:${port}/import`; + return Math.min(65535, Math.max(1024, parseInt(raw, 10) || UNITY_ALLOWED_PORT)); +} + +function unityImportUrl(): string { + return `http://localhost:${unityImportPort()}/import`; } function unityToken(): string { @@ -740,18 +748,20 @@ async function sendToUnity(project: { name: string; initial: string }, screens: // `data` array from a 1.0-era plugin. // Conservative scalability guard: the whole project is base64-encoded into one // in-memory JSON body and POSTed in a single fetch — there is no streaming yet. - // Estimate the encoded payload (base64 ≈ 1.33× the raw bytes) and warn the user - // before a very large send that may exhaust memory or be refused by Unity. - // NOTE: this is only a heads-up; a real fix is per-screen/per-asset streaming. + // Estimate the encoded payload (base64 ≈ 1.33× the raw bytes); past the limit a + // single JSON.stringify is liable to OOM the iframe (or be refused by Unity), so + // reject up front rather than crash mid-encode with no recoverable message. + // NOTE: a real fix is per-screen/per-asset streaming; this is the hard ceiling. const APPROX_BODY_LIMIT = 200 * 1024 * 1024; // ~200 MB encoded let rawBytes = 0; for (const s of screens) for (const a of s.assets) rawBytes += a.data.length; const approxBodyBytes = Math.ceil(rawBytes * 4 / 3); if (approxBodyBytes > APPROX_BODY_LIMIT) { setStatus( - `This project is very large (~${Math.round(approxBodyBytes / (1024 * 1024))} MB encoded) — the single-request send may run out of memory or be refused by Unity. Sending anyway; if it fails, export fewer screens at a time.`, + `This project is too large to send (~${Math.round(approxBodyBytes / (1024 * 1024))} MB encoded, cap ${APPROX_BODY_LIMIT / (1024 * 1024)} MB) — the single-request send would likely run out of memory or be refused by Unity. Export fewer screens at a time.`, true, ); + return; } let wireScreens: Array<{ name: string; manifest: string; section?: string; role?: string; assets: Array<{ name: string; b64: string }> }> | null = screens.map((s) => ({ name: s.name, @@ -760,8 +770,10 @@ async function sendToUnity(project: { name: string; initial: string }, screens: role: s.role, assets: s.assets.map((a) => ({ name: a.name, b64: bytesToBase64(a.data) })), })); + let serialized = false; try { const body = JSON.stringify({ project, screens: wireScreens }); + serialized = true; // Drop the large base64 wire copy before awaiting the fetch — the request // already holds its own reference to `body`, so this lets the encoded screens // be reclaimed instead of pinning two full copies for the whole round-trip. @@ -779,7 +791,31 @@ async function sendToUnity(project: { name: string; initial: string }, screens: setStatus('Unity rejected the token — copy it from the FigForge importer (Live import → Plugin token) into the Token field.', true); else setStatus(`Unity refused the import (HTTP ${res.status}).`, true); } catch (e) { - setStatus(`Couldn't reach Unity at ${url} — is the FigForge importer open with live import enabled?`, true); + // If we never finished JSON.stringify, the throw is a serialization/OOM + // failure (the payload was too big to encode into one string), not a network + // problem — say so plainly so the user reduces the send rather than chasing + // Unity connectivity. + if (!serialized) { + setStatus( + `Couldn't build the send payload — the project is too large to serialize in one request (${String((e as Error)?.message || e)}). Export fewer screens at a time.`, + true, + ); + return; + } + // A fetch to a non-whitelisted origin never reaches the network — Figma + // blocks it at the iframe boundary, surfacing the same TypeError as a + // connection-refused failure. Name the real cause when the port isn't the + // one the manifest allows, so the user doesn't chase a dead Unity instead. + const port = unityImportPort(); + if (port !== UNITY_ALLOWED_PORT) { + setStatus( + `Figma blocks port ${port} — only ${UNITY_ALLOWED_PORT} is allowed by the plugin manifest. ` + + `Change the importer port back to ${UNITY_ALLOWED_PORT}, or add http://localhost:${port} to manifest allowedDomains.`, + true, + ); + } else { + setStatus(`Couldn't reach Unity at ${url} — is the FigForge importer open with live import enabled?`, true); + } } } @@ -1022,7 +1058,11 @@ function withCurrentExportSettings(payload: { type?: string; params?: Record { img.src = url; previewImg = img; img.onload = () => updatePreviewZoom(); + img.onerror = () => { + // The blob never decoded (corrupt PNG / revoked URL) — restore the empty + // state and reclaim the object URL instead of leaving a broken-image icon. + if (previewObjectUrl === url) { + URL.revokeObjectURL(url); + previewObjectUrl = null; + } + previewImg = null; + $('#previewWrap').innerHTML = '
Preview unavailable.
'; + setPreviewZoomLabel('Fit'); + }; $('#previewWrap').appendChild(img); updatePreviewZoom(); break; @@ -1156,7 +1212,19 @@ window.onmessage = (event: MessageEvent) => { if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(msg.payload)); bridgeLog(`→ ${msg.payload.type} ${msg.payload.error ? 'ERR' : 'ok'}`); + } else { + // The socket reconnected (or dropped) mid-roundtrip, so this computed + // reply has nowhere to go. Buffer-and-flush is out of scope; at least make + // the drop visible instead of silently swallowing the response. + bridgeLog(`dropped mcp-response (${msg.payload?.type ?? 'unknown'}) — socket not OPEN`); } break; + + default: + // A message type main sent that this UI build doesn't handle (version skew + // or a new path that forgot its UI case). Make it visible instead of + // silently dropping it, so the gap is diagnosable from the log. + bridgeLog(`unhandled message from main: ${String(msg.type)}`); + break; } }; diff --git a/plugin/src/variants.ts b/plugin/src/variants.ts index db6e577..2d5fe9b 100644 --- a/plugin/src/variants.ts +++ b/plugin/src/variants.ts @@ -173,22 +173,58 @@ function normalizeValue(axis: string, name: string, value: string | boolean): st return typeof value === 'boolean' ? (value ? 'true' : 'false') : (words(value) || String(value)); } +// Recognized axis buckets legitimately collapse several differently-named +// properties onto one key (e.g. both "State" and "Interaction" → 'state'), and +// first-wins there is intentional. The fallback branch of axisFor instead returns +// the trimmed property name, so two DISTINCT fallback properties that trim to the +// same string would otherwise silently drop the second — those we disambiguate. +const RECOGNIZED_AXES = new Set(['state', 'value', 'size', 'tone', 'intent', 'severity']); + +// When the SAME axis is supplied by more than one source, the instance's live, +// resolved value must win over component/variant defaults — relying on array push +// order made this fragile. Higher number = higher precedence. componentProperties +// is the instance's currently-bound value; componentSet is the master default. +const SOURCE_PRIORITY: Record = { + componentProperties: 4, + variantProperties: 3, + mainComponent: 2, + componentSet: 1, +}; +function sourcePriority(source: string | undefined): number { + return source ? (SOURCE_PRIORITY[source] ?? 0) : 0; +} + export function normalizeVariantEntries(entries: VariantInput[]): CanonicalVariantProps | undefined { const raw: CanonicalVariantAxis[] = []; const axes: Record = {}; const original: Record = {}; + // Priority of the source that currently owns each axis value — a later entry + // from a higher-priority source (the instance's live value) overwrites it. + const axisPriority: Record = {}; const sources = new Set(); for (const entry of entries) { if (!entry || !entry.name) continue; - const axis = axisFor(entry.name, entry.value); + let axis = axisFor(entry.name, entry.value); const value = normalizeValue(axis, entry.name, entry.value); if (value === undefined) continue; const source = entry.source ?? 'componentProperties'; + const priority = sourcePriority(source); const originalValue = typeof entry.value === 'boolean' ? (entry.value ? 'true' : 'false') : String(entry.value); - if (axes[axis] === undefined) { + // Fallback (unrecognized) axis collision: a distinct property trimmed to an + // already-used key. Suffix it (axis_2, axis_3, …) so it isn't silently lost. + if (axes[axis] !== undefined && !RECOGNIZED_AXES.has(axis)) { + let n = 2; + while (axes[`${axis}_${n}`] !== undefined) n++; + axis = `${axis}_${n}`; + } + // Set the axis if unseen, or if this source outranks the one that set it — so + // the instance's resolved value wins over component/variant defaults instead + // of whichever source happened to be pushed first. + if (axes[axis] === undefined || priority > (axisPriority[axis] ?? -1)) { axes[axis] = value; original[axis] = originalValue; + axisPriority[axis] = priority; } sources.add(source); raw.push({ diff --git a/plugin/src/vector.ts b/plugin/src/vector.ts index b6bcfcc..403f6d2 100644 --- a/plugin/src/vector.ts +++ b/plugin/src/vector.ts @@ -16,6 +16,7 @@ // ============================================================================= import earcut from 'earcut'; import type { RGBA, VectorDrawing, VectorMesh } from './types'; +import { INVISIBLE_PAINT_EPS } from './traverser'; const AA_PX = 0.75; // anti-alias feather width, node-local px const FLATTEN_TOL = 0.15; // max bézier chord deviation, node-local px @@ -37,7 +38,7 @@ const MAX_RING_SLOTS = 2 * (MAX_TOTAL_VERTS + 1); function singleSolid(paints: unknown): RGBA | null | 'unsupported' { if (typeof paints === 'symbol') return 'unsupported'; // figma.mixed if (!Array.isArray(paints)) return null; - const vis = (paints as Paint[]).filter((p) => p && p.visible !== false && (p.opacity ?? 1) > 0.0001); + const vis = (paints as Paint[]).filter((p) => p && p.visible !== false && (p.opacity ?? 1) > INVISIBLE_PAINT_EPS); if (vis.length === 0) return null; if (vis.length > 1) return 'unsupported'; const p = vis[0]; @@ -112,9 +113,13 @@ export function buildVectorDrawing(node: SceneNode): VectorDrawing | null { // --------------------------------------------------------------------------- function parsePath(data: string): number[][] | null { const out: number[][] = []; - if (!data) return out; + // Empty / whitespace-only data is a LEGITIMATELY empty path (e.g. a node with + // no fill geometry) → empty ring array. A non-empty string that yields no + // tokens is a genuine PARSE FAILURE → null, so the caller keeps the PNG + // fallback rather than silently treating it as an empty shape. + if (!data || !data.trim()) return out; const tok = data.match(/[a-zA-Z]|-?\d*\.?\d+(?:[eE][+-]?\d+)?/g); - if (!tok) return out; + if (!tok) return null; let i = 0; let cur: number[] = []; @@ -123,8 +128,14 @@ function parsePath(data: string): number[][] | null { let cx = 0; let cy = 0; const num = (): number => parseFloat(tok[i++]); + let bad = false; // tripped if any parsed coord is non-finite → PNG fallback const flush = (): void => { - if (cur.length >= 6) out.push(cur); + if (cur.length >= 6) { + for (let k = 0; k < cur.length; k++) { + if (!Number.isFinite(cur[k])) { bad = true; break; } + } + out.push(cur); + } cur = []; }; @@ -184,6 +195,9 @@ function parsePath(data: string): number[][] | null { } } flush(); + // Any non-finite coordinate (NaN/Infinity from a malformed token) would + // poison earcut downstream — bail to the PNG fallback instead. + if (bad) return null; return out; } @@ -243,8 +257,13 @@ function triangulate(contours: number[][], windingRule: string): { verts: number const rule = (windingRule || '').toUpperCase(); if (rings.length > 1 && rule !== 'EVENODD') return null; - const repX = rings.map((r) => r[0]); - const repY = rings.map((r) => r[1]); + // Representative point per ring for containment tests. Using vertex[0] is + // degenerate — it lies ON the ring, so an exactly-shared vertex with another + // ring makes pointInRing's parity test unreliable. The centroid of the ring's + // first triangle is an interior-ish point that generically misses every + // vertex/edge of the other rings. Rings here always have >= 3 points. + const repX = rings.map((r) => (r[0] + r[2] + r[4]) / 3); + const repY = rings.map((r) => (r[1] + r[3] + r[5]) / 3); const depth: number[] = rings.map((_, i) => { let d = 0; @@ -333,7 +352,13 @@ function withAA(coreVerts: number[], coreTris: number[], color: RGBA): VectorMes const tris = coreTris.slice(); const alpha: number[] = new Array(coreVerts.length / 2).fill(1); - const key = (a: number, b: number): number => a * 0x100000 + b; + // Pack a directed edge (a,b) into one number. The radix must exceed the max + // vertex index, or two distinct edges collide. Core verts are bounded by the + // MAX_TOTAL_VERTS cap enforced before withAA runs, so MAX_TOTAL_VERTS+1 is a + // safe radix (max key ~5.8e8, well under MAX_SAFE_INTEGER). The old 0x100000 + // (2^20) radix silently collided once a mesh exceeded ~1M verts. + const RADIX = MAX_TOTAL_VERTS + 1; + const key = (a: number, b: number): number => a * RADIX + b; const hasTwin = new Set(); for (let t = 0; t < coreTris.length; t += 3) { hasTwin.add(key(coreTris[t], coreTris[t + 1])); diff --git a/scripts/check-canonical-schema.mjs b/scripts/check-canonical-schema.mjs index 578ce01..1ca3a43 100644 --- a/scripts/check-canonical-schema.mjs +++ b/scripts/check-canonical-schema.mjs @@ -1,44 +1,216 @@ #!/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(', ')}`); +} + +// --------------------------------------------------------------------------- +// (4) CanonicalKind coverage — every value in the TS `CanonicalKind` union MUST +// be parseable by the C# importer. The importer accepts a kind only if +// CanonicalLibrary.TryParseKind has a matching (lowercased) `case "…":` label +// (this covers both the enum names and aliases like "dialog"/"notification"). +// So the set of TryParseKind labels MUST be a SUPERSET of the TS union — adding +// a TS kind without wiring TryParseKind would make those instances silently +// fail to resolve their prefab, and this gate catches it. +// Best-effort & intentionally loose: it only checks that each TS literal has a +// C# case; it does NOT require the two sides to match exactly (C# may accept +// extra aliases the TS union doesn't list). +// --------------------------------------------------------------------------- +const canonicalLibraryCs = read('unity/Runtime/CanonicalLibrary.cs'); + +// TS: `export type CanonicalKind = 'a' | 'b' | …;` → the quoted literals. +const tsUnionDecl = matchOrExit( + typesTs, + /export type CanonicalKind\s*=\s*([^;]+);/, + 'CanonicalKind union', + 'plugin/src/types.ts', +)[1]; +const tsKinds = [...tsUnionDecl.matchAll(/'([^']+)'/g)].map((m) => m[1].toLowerCase()); + +// C#: every `case "label":` in the file (TryParseKind is the only switch with +// string cases here). Lowercased to mirror TryParseKind's ToLowerInvariant(). +const csKindCases = new Set( + [...canonicalLibraryCs.matchAll(/case\s+"([^"]+)"\s*:/g)].map((m) => m[1].toLowerCase()), +); + +const missingKinds = tsKinds.filter((k) => !csKindCases.has(k)); +if (missingKinds.length) { + fail( + `✗ CanonicalKind coverage gap: the TS union (plugin/src/types.ts) lists kind(s) ` + + `[${missingKinds.map((k) => `'${k}'`).join(', ')}] that CanonicalLibrary.TryParseKind ` + + `(unity/Runtime/CanonicalLibrary.cs) does NOT accept — those instances would fail to ` + + `resolve their prefab.\n` + + ` Add a matching 'case "…":' to TryParseKind (it lowercases input, so use the lowercase form).`, + ); +} else { + console.log(`✓ CanonicalKind coverage: all ${tsKinds.length} TS kinds parseable by TryParseKind`); } -console.log(`✓ canonical schema in lockstep: ${plugin.value}`); +process.exit(failed ? 1 : 0); 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..38f0bdd 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; @@ -69,18 +82,69 @@ 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) { + 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); + 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(); + } + const resp = await this.follower.send(tool, nodeIds, params, timeoutMs); + // The /ping above and this send are not atomic: the leader can die in the + // gap, so a ping-confirmed leader can still be gone by send time. Follower + // marks that connection-level failure with an "unreachable" error (vs a + // "Leader returned " error, which means the leader is alive and the + // failure is real). On unreachable, self-heal exactly as the ping==false + // path does — try to take over and retry once — so a leader death mid-call + // doesn't surface as a spurious error. + if (this.isLeaderUnreachableError(resp)) { + await this.tryBecomeLeader(); + if (this.role === 'leader' && this.leader) { + return this.leader.send(tool, nodeIds, params, timeoutMs); + } + // We lost the port race: a new leader bound. Wait (bounded) for its plugin + // socket to land, then retry the proxied call once. + await this.waitForPluginReconnect(); + return this.follower.send(tool, nodeIds, params, timeoutMs); + } + return resp; + } + + /** A connection-level failure reaching the leader (it vanished), as opposed + * to an HTTP error from a live leader. Mirrors Follower.send's error strings. */ + private isLeaderUnreachableError(resp: RpcResponse): boolean { + return typeof resp.error === 'string' && resp.error.startsWith('Leader unreachable:'); + } + + /** + * 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)); } - return this.follower.send(tool, nodeIds, params); } } diff --git a/server/src/follower.ts b/server/src/follower.ts index 44ae4b5..ba3686b 100644 --- a/server/src/follower.ts +++ b/server/src/follower.ts @@ -3,28 +3,47 @@ // ============================================================================= 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}`; 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 }; } } - 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..3ef701e 100644 --- a/server/src/leader.ts +++ b/server/src/leader.ts @@ -7,11 +7,16 @@ // ============================================================================= import http from 'node:http'; +import { timingSafeEqual } from 'node:crypto'; 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; @@ -26,6 +31,16 @@ function isLoopbackAddress(address: string | undefined): boolean { return address !== undefined && LOOPBACK_ADDRESSES.has(address); } +/** Constant-time string compare. Length-checks first (timingSafeEqual throws on + * unequal-length buffers) so a mismatched token can't leak its length, then + * compares the bytes without short-circuiting on the first differing char. */ +function safeStringEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a, 'utf8'); + const bufB = Buffer.from(b, 'utf8'); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + export class RpcBodyTooLargeError extends Error { constructor(limitBytes = RPC_MAX_BODY_BYTES) { super(`RPC body exceeds ${limitBytes} bytes.`); @@ -34,6 +49,10 @@ export class RpcBodyTooLargeError extends Error { } export function readRpcBody(req: http.IncomingMessage, limitBytes = RPC_MAX_BODY_BYTES): Promise { + // Content-Length is only an early-reject optimization: it lets us refuse an + // oversized body before draining any of it. It is client-supplied and not + // trusted as the enforcement point — the streaming byte counter below (onData) + // is what actually caps the body, regardless of any declared or absent header. const contentLength = req.headers['content-length']; const declaredBytes = typeof contentLength === 'string' ? Number(contentLength) : undefined; if (declaredBytes !== undefined && Number.isFinite(declaredBytes) && declaredBytes > limitBytes) { @@ -91,6 +110,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)) { @@ -133,7 +156,7 @@ export class Leader implements PluginSender { (typeof protocolToken === 'string' ? protocolToken.split(',').map((s) => s.trim()).find((s) => s.length > 0) ?? null : null); - if (presented !== expectedToken) return false; + if (presented === null || !safeStringEqual(presented, expectedToken)) return false; } return true; } @@ -144,13 +167,21 @@ 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(); }); }); } - 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 +209,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/schema.ts b/server/src/schema.ts index dd15306..47bc96b 100644 --- a/server/src/schema.ts +++ b/server/src/schema.ts @@ -14,8 +14,6 @@ export const figmaNodeId = z 'Expected a Figma node id like "123:456" or an instance path like "I123:4;567:8" (colon, not hyphen).' ); -export const screenshotFormat = z.enum(['PNG', 'SVG', 'JPG']).default('PNG'); - export const getNodeInput = { nodeId: figmaNodeId.describe('Node id to fetch') }; export const designContextInput = { diff --git a/server/src/tools.ts b/server/src/tools.ts index 07d68f6..ec7aa68 100644 --- a/server/src/tools.ts +++ b/server/src/tools.ts @@ -21,12 +21,18 @@ 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) }] }; } +// Cap forwarded error text. Plugin/runtime errors are echoed to the MCP client +// verbatim; an unbounded string (e.g. a giant stack or payload dump from the +// plugin) shouldn't be relayed wholesale, so truncate to a sane ceiling. +const MAX_ERROR_LEN = 2_000; function fail(message: string): ToolResult { - return { content: [{ type: 'text', text: message }], isError: true }; + const text = message.length > MAX_ERROR_LEN ? `${message.slice(0, MAX_ERROR_LEN)}… (truncated)` : message; + return { content: [{ type: 'text', text }], isError: true }; } function unwrap(r: RpcResponse): ToolResult { return r.error ? fail(r.error) : ok(r.data); @@ -64,9 +70,14 @@ export function resolveAndValidateOutputPath(outDir: string, workspaceRoot: stri const realResolved = canonicalizeExisting(resolved); const realRoot = canonicalizeExisting(root); if (realResolved !== realRoot && !realResolved.startsWith(realRoot + path.sep)) { - throw new Error(`Refusing to write outside the bridge working directory: ${outDir}`); + // Don't echo the (possibly absolute) caller-supplied path back in the error — + // it can leak the resolved filesystem location. The refusal alone is enough. + throw new Error('Refusing to write outside the bridge working directory.'); } - 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 { @@ -112,7 +123,13 @@ function safeFolderName(raw: unknown, fallback: string): string { * bridge during the transition. */ function wireBytesToBuffer(data: unknown): Buffer | null { if (typeof data === 'string') return Buffer.from(data, 'base64'); - if (Array.isArray(data)) return Buffer.from(data as number[]); + if (Array.isArray(data)) { + // Buffer.from(number[]) silently coerces non-byte entries (NaN/floats/out-of- + // range wrap to 0..255), which would write corrupt bytes from malformed wire + // data. Reject unless every entry is a finite integer already in byte range. + const isByteArray = data.every((v) => typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 255); + return isByteArray ? Buffer.from(data as number[]) : null; + } return null; } @@ -124,7 +141,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 +189,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; @@ -189,9 +206,13 @@ export async function executeExportProjectUnity( const screen = screens[i]; if (!screen?.manifest) continue; const base = safeFolderName(screen.name || screen.manifest.screen?.name, `screen_${i + 1}`); + // Dedup with a "__N" suffix. safeFolderName collapses runs of underscores, + // so a double underscore can never appear in `base` itself — that keeps the + // suffix unambiguous, so a base that already ends in "_2" can't alias the + // dedup of a plain "base" + 2 onto the same folder name. let folder = base; let n = 1; - while (used.has(folder)) folder = `${base}_${n++}`; + while (used.has(folder)) folder = `${base}__${n++}`; used.add(folder); const screenDir = path.join(resolvedDir, folder); @@ -526,20 +547,31 @@ 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])); const written: string[] = []; + const skipped: string[] = []; for (const item of items) { const bytes = wireBytesToBuffer(byId.get(item.nodeId)); - if (!bytes) continue; + if (!bytes) { + // Plugin returned no (or malformed) bytes for this node — record it + // instead of silently dropping it so the caller can see what missed. + skipped.push(item.nodeId); + continue; + } const resolved = resolveAndValidateOutputPath(item.outputPath, workspaceRoot); await mkdir(path.dirname(resolved), { recursive: true }); await writeFile(resolved, bytes); written.push(resolved); } - return ok({ written }); + const result = { written, skipped }; + // Nothing landed on disk at all — surface as an error rather than a + // success with an empty `written`. + return written.length === 0 ? fail(JSON.stringify(result, null, 2)) : ok(result); } ); 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; 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); diff --git a/unity/.npmignore b/unity/.npmignore new file mode 100644 index 0000000..3b627c0 --- /dev/null +++ b/unity/.npmignore @@ -0,0 +1,6 @@ +# Keep EditMode tests out of the published UPM tarball (`npm pack`). The Tests +# folder + its asmdef are dev-only; shipping them would compile test code (and +# pull a test-framework dependency) into consumers' projects. The directory is +# deliberately NOT renamed so the in-repo test asmdef keeps resolving. +Tests +Tests.meta diff --git a/unity/Editor/CodeGen/FrameCodeGen.cs b/unity/Editor/CodeGen/FrameCodeGen.cs index 7d5db5a..c09d627 100644 --- a/unity/Editor/CodeGen/FrameCodeGen.cs +++ b/unity/Editor/CodeGen/FrameCodeGen.cs @@ -187,11 +187,11 @@ static void EmitWireBody(StringBuilder sb, List members) sb.AppendLine(" if (reg == null) return;"); foreach (var m in members) if (string.IsNullOrEmpty(m.collectionName)) - sb.AppendLine(" " + m.FieldName + " = reg.Get<" + m.csharpType + ">(\"" + m.Key + "\");"); + sb.AppendLine(" " + m.FieldName + " = reg.Get<" + m.csharpType + ">(\"" + Escape(m.Key) + "\");"); foreach (var c in Collections(members)) { string t = c[0].csharpType; - string inits = string.Join(", ", c.ConvertAll(m => "reg.Get<" + t + ">(\"" + m.Key + "\")")); + string inits = string.Join(", ", c.ConvertAll(m => "reg.Get<" + t + ">(\"" + Escape(m.Key) + "\")")); sb.AppendLine(" _" + c[0].collectionName + " = new " + t + "[] { " + inits + " };"); } sb.AppendLine(" }"); @@ -267,12 +267,19 @@ public static string EmitFrameManager(IList frames) var sections = new SortedSet(StringComparer.Ordinal); foreach (var f in frames) if (!string.IsNullOrEmpty(f.section)) sections.Add(f.section); + // Reserve already-emitted safe section names alongside the frame classes: a section + // renamed onto another section's identifier (e.g. section "Foo" colliding with a frame + // class → "FooSection", while a genuine section "FooSection" also exists) would otherwise + // silently merge into an unrelated section's block. Since every section here is visible, + // probe against both sets so each distinct section gets a distinct nested class. + var reservedNames = new HashSet(frameClasses, StringComparer.Ordinal); foreach (var sec in sections) { - string safe = SafeSectionName(sec, frameClasses, out bool renamed); + string safe = SafeSectionName(sec, reservedNames, out bool renamed); + reservedNames.Add(safe); if (renamed) UnityEngine.Debug.LogWarning($"[FigForge] section '{sec}' collides with frame class " - + $"'{sec}' → emitted as 'Frames.{safe}'."); + + $"or sibling section → emitted as 'Frames.{safe}'."); sb.AppendLine(); sb.AppendLine(" public static partial class " + safe); sb.AppendLine(" {"); @@ -297,6 +304,13 @@ public static string EmitFrameManager(IList frames) // class, which is a real component type referenced elsewhere). The rename is a pure // function of (section, reserved set), so every frame in that section computes the same // safe name and they all land in the one partial class. + // RESIDUAL: the per-frame path only sees frame class names, not sibling SECTION names (each + // frame is emitted into its own file with no cross-section visibility). Two distinct sections + // can therefore still resolve to the same safe name (e.g. section "Foo" renamed to "FooSection" + // landing on a genuine section "FooSection"). This is benign here — both emit `public static + // partial class `, which C# merges, and each frame's accessor lives on its own frame — + // so it compiles. The single-file EmitFrameManager (full-import preview) reserves sibling + // section names to keep them distinct; the per-frame path cannot without a contract change. public static string EmitFrameManagerForFrame(FrameModel f, ISet reservedFrameClassNames = null) { var sb = new StringBuilder(); @@ -363,6 +377,12 @@ public static string EmitFramesCore() sb.AppendLine(" // Typed + null-safe navigation: Frames.Show(Frames.Settings). (Or Frames.Settings.Show().)"); sb.AppendLine(" public static void Show(FigForgeFrame frame) { if (frame != null) frame.Show(); }"); sb.AppendLine(); + sb.AppendLine(" // Hide / toggle a frame — mainly for `persistent` overlay frames the end-user"); + sb.AppendLine(" // dismisses: Frames.Hide(Frames.SidePanel). (Or Frames.SidePanel.Hide().) Hiding the"); + sb.AppendLine(" // current navigated screen just leaves nothing shown."); + sb.AppendLine(" public static void Hide(FigForgeFrame frame) { if (frame != null) frame.Hide(); }"); + sb.AppendLine(" public static void SetVisible(FigForgeFrame frame, bool visible) { if (frame != null) frame.SetVisible(visible); }"); + sb.AppendLine(); sb.AppendLine(" // ---- navigation guards (preconditions) -------------------------------"); sb.AppendLine(" // Gate entry to a screen with real C#. Register once at runtime, e.g. in a"); sb.AppendLine(" // bootstrap component's Start():"); @@ -562,6 +582,20 @@ public static string EmitAsmdef() + "}\n"; } + // Deterministic .asmdef.meta so the generated asmdef's GUID is source-control-stable + // instead of a random per-machine GUID Unity would otherwise mint on first import. + // The GUID is a pure function of the asmdef name (same hash used for the .cs metas). + public static string EmitAsmdefMeta() + { + return "fileFormatVersion: 2\n" + + "guid: " + DeterministicGuid("FigForge.Generated.asmdef") + "\n" + + "AssemblyDefinitionImporter:\n" + + " externalObjects: {}\n" + + " userData: \n" + + " assetBundleName: \n" + + " assetBundleVariant: \n"; + } + // ---- element → C# accessor type ---------------------------------------------- public static string CSharpType(ElementData e) { diff --git a/unity/Editor/CodeGen/FrameCodeGenDriver.cs b/unity/Editor/CodeGen/FrameCodeGenDriver.cs index 556f756..c051192 100644 --- a/unity/Editor/CodeGen/FrameCodeGenDriver.cs +++ b/unity/Editor/CodeGen/FrameCodeGenDriver.cs @@ -25,6 +25,43 @@ internal static class FrameCodeGenDriver const string GenRoot = "Assets/FigForge/Generated"; const string FramesDir = GenRoot + "/Frames"; + // ---- import batch (full multi-frame import) ----------------------------------- + // A full project import calls WriteFiles once per frame. Without a shared notion of + // "every frame in THIS import", each WriteFiles rebuilds its cross-frame reserved set + // by scanning Frames.*.g.cs on disk — which (a) varies between frames as files are + // written, so two frames in one section can compute different section class names + // (CS0102), and (b) harvests orphaned files left by renamed/removed frames as if they + // were real, and never deletes them. A batch fixes both: the importer declares the + // expected frame class names up front (single source of truth), every per-frame emit + // sees the SAME reserved set, and EndBatch sweeps the orphans. Outside a batch (a + // single-frame incremental rebuild) we fall back to the on-disk scan and DO NOT sweep, + // so a partial run can never delete files for frames it simply didn't touch. + static HashSet _batchFrameClassNames; + + /// Open a full-import batch. is the + /// complete set of frame class names this import will generate (overlays excluded — they + /// have no frame class). While open, every frame's cross-frame reserved set is this exact + /// set, so all frames in a section resolve to one nested class. Must be paired with + /// (call it from a finally). + public static void BeginBatch(IEnumerable expectedFrameClassNames) + { + _batchFrameClassNames = new HashSet(System.StringComparer.Ordinal); + if (expectedFrameClassNames != null) + foreach (var n in expectedFrameClassNames) + if (!string.IsNullOrEmpty(n)) _batchFrameClassNames.Add(n); + } + + /// Close a full-import batch and sweep orphaned Frames.<Old>.g.cs / + /// Frames/<Old>.g.cs left by frames renamed or removed in Figma. Only runs for a + /// full import (a batch was open); a single-frame rebuild never sweeps. + public static void EndBatch() + { + var expected = _batchFrameClassNames; + _batchFrameClassNames = null; + if (expected == null) return; + SweepOrphanFrameFiles(expected); + } + /// Generate the accessor layer for one imported frame. Returns the model /// so the caller can wire the prefab against the same identifiers. public static FrameModel Generate(Manifest m, string section = "", bool includeGroups = true) @@ -228,6 +265,9 @@ public static void WriteFiles(FrameModel f) Directory.CreateDirectory(FramesDir); bool changed = false; changed |= WriteIfChanged(GenRoot + "/FigForge.Generated.asmdef", FrameCodeGen.EmitAsmdef()); + // Deterministic .asmdef.meta so the asmdef GUID is source-control-stable across machines + // (otherwise Unity mints a random GUID on first import → churn / cross-clone ref drift). + changed |= WriteIfChanged(GenRoot + "/FigForge.Generated.asmdef.meta", FrameCodeGen.EmitAsmdefMeta()); // Overlay layers are NOT navigable screens — they don't get a ` : FigForgeFrame` // class or a Frames.X accessor (and a layer named "Dialogs" would collide with the static @@ -264,9 +304,8 @@ public static void WriteFiles(FrameModel f) else if (File.Exists(dialogsCs)) { // Frame stopped being an overlay (or lost its dialogs) — drop the stale accessors. - File.Delete(dialogsCs); - if (File.Exists(dialogsCs + ".meta")) File.Delete(dialogsCs + ".meta"); - changed = true; + // Via the AssetDatabase so the .meta + asset DB don't go stale. + if (DeleteAsset(dialogsCs)) changed = true; } // Compile on the next tick — never mid-import (a domain reload would abort it). @@ -304,15 +343,13 @@ static bool WriteGroupFiles(FrameModel f) foreach (var file in Directory.GetFiles(dir, "*.g.cs")) { if (expected.Contains(Path.GetFileName(file))) continue; - File.Delete(file); - if (File.Exists(file + ".meta")) File.Delete(file + ".meta"); - changed = true; + // Via the AssetDatabase so the .meta + asset DB stay consistent. + if (DeleteAsset(dir + "/" + Path.GetFileName(file))) changed = true; } if (expected.Count == 0) // no groups left — drop the folder entirely { - try { Directory.Delete(dir, true); } catch { /* best-effort */ } - if (File.Exists(dir + ".meta")) File.Delete(dir + ".meta"); - changed = true; + if (DeleteAsset(dir)) changed = true; + else Debug.LogWarning($"[FigForge] could not delete empty group folder '{dir}' — remove it manually."); } } return changed; @@ -325,6 +362,73 @@ static bool WriteIfChanged(string path, string content) return true; } + // Delete a generated asset through the AssetDatabase so the .meta + asset DB stay + // consistent (a raw File.Delete leaves an orphan .meta and a stale DB entry). Falls + // back to File.Delete only if the asset isn't under the project's Assets/ DB. Returns + // true if anything was removed. + static bool DeleteAsset(string path) + { + if (path.StartsWith("Assets/", System.StringComparison.Ordinal) && AssetDatabase.DeleteAsset(path)) + return true; + bool removed = false; + if (File.Exists(path)) { File.Delete(path); removed = true; } + if (File.Exists(path + ".meta")) { File.Delete(path + ".meta"); removed = true; } + return removed; + } + + // Full-import sweep (EndBatch only): delete Frames..g.cs files and Frames/.g.cs + // frame classes (+ their .g group folders) whose class name is no longer expected — + // i.e. a frame renamed or removed in Figma. The filename is the authoritative class name + // (body-free), same convention SiblingFrameClassNames relies on. Scoped to a FULL import so + // it can't delete a frame that simply wasn't part of a partial rebuild. + static void SweepOrphanFrameFiles(HashSet expected) + { + if (!Directory.Exists(GenRoot)) return; + + // Stale Frames..g.cs (the per-frame accessor partial). + const string fmPrefix = "Frames."; + const string suffix = ".g.cs"; + foreach (var file in Directory.GetFiles(GenRoot, "Frames.*.g.cs")) + { + string name = Path.GetFileName(file); + if (name == "Frames.Core.g.cs") continue; // the fixed core, not a frame + if (name.Length <= fmPrefix.Length + suffix.Length) continue; + string cls = name.Substring(fmPrefix.Length, name.Length - fmPrefix.Length - suffix.Length); + if (expected.Contains(cls)) continue; + DeleteAsset(GenRoot + "/" + name); + } + + // Stale Dialogs..g.cs (overlay accessors whose frame was renamed/removed) — + // keyed by the same frame className, and DialogFrameClassNames harvests these too, so + // an orphan would poison the reserved set just like an orphan Frames..g.cs. + const string dlgPrefix = "Dialogs."; + foreach (var file in Directory.GetFiles(GenRoot, "Dialogs.*.g.cs")) + { + string name = Path.GetFileName(file); + if (name == "Dialogs.Core.g.cs") continue; // the fixed core, not a frame + if (name.Length <= dlgPrefix.Length + suffix.Length) continue; + string cls = name.Substring(dlgPrefix.Length, name.Length - dlgPrefix.Length - suffix.Length); + if (expected.Contains(cls)) continue; + DeleteAsset(GenRoot + "/" + name); + } + + // Stale Frames/.g.cs (the FigForgeFrame subclass) + its .g group folder. + if (Directory.Exists(FramesDir)) + { + foreach (var file in Directory.GetFiles(FramesDir, "*.g.cs")) + { + string name = Path.GetFileName(file); + if (name.Length <= suffix.Length) continue; + string cls = name.Substring(0, name.Length - suffix.Length); + if (expected.Contains(cls)) continue; + DeleteAsset(FramesDir + "/" + name); + string groupDir = FramesDir + "/" + cls + ".g"; + if (Directory.Exists(groupDir) && !DeleteAsset(groupDir)) + Debug.LogWarning($"[FigForge] could not delete stale group folder '{groupDir}' — remove it manually."); + } + } + } + // ---- cross-frame identifier reservation (CS0102 guards) ----------------------- // Frames are generated one at a time, each writing its own `Frames..g.cs` / // `Dialogs..g.cs` into a SHARED partial class. A duplicate member emitted by a @@ -335,6 +439,17 @@ static bool WriteIfChanged(string path, string content) // (the filename is the authoritative, body-free source) plus the frame being written. static HashSet SiblingFrameClassNames(string selfClassName) { + // During a full import the expected set is fixed up front — return it verbatim so + // EVERY frame in the run (and so every frame in a section) sees the same reserved set + // and resolves a section to one nested class. On-disk orphans are deliberately NOT + // consulted here: they're swept at EndBatch instead of poisoning the reserved set. + if (_batchFrameClassNames != null) + { + var batch = new HashSet(_batchFrameClassNames, System.StringComparer.Ordinal); + if (!string.IsNullOrEmpty(selfClassName)) batch.Add(selfClassName); + return batch; + } + var names = new HashSet(System.StringComparer.Ordinal); if (!string.IsNullOrEmpty(selfClassName)) names.Add(selfClassName); if (!Directory.Exists(GenRoot)) return names; diff --git a/unity/Editor/CodeGen/FrameCodeGenWire.cs b/unity/Editor/CodeGen/FrameCodeGenWire.cs index 3987ea0..5e749ff 100644 --- a/unity/Editor/CodeGen/FrameCodeGenWire.cs +++ b/unity/Editor/CodeGen/FrameCodeGenWire.cs @@ -117,9 +117,18 @@ static FigForgeFrameElement UpgradeGroup(FigForgeFrameElement baseEl, Type t) string typeKey = baseEl.FigmaTypeKey; string genType = baseEl.generatedType; // FigForgeFrameElement is [DisallowMultipleComponent], so the base must go before the - // subclass can be added. + // subclass can be added. If the swap fails (AddComponent throws / yields an unexpected + // type), re-add the base so the GameObject is never left WITHOUT an element component. UnityEngine.Object.DestroyImmediate(baseEl); - if (!(go.AddComponent(t) is FigForgeFrameElement comp)) return null; + FigForgeFrameElement comp = null; + try { comp = go.AddComponent(t) as FigForgeFrameElement; } + catch (Exception e) { Debug.LogException(e); } + if (comp == null) + { + var restored = go.AddComponent(); + if (restored != null) { restored.ConfigureType(typeKey); restored.generatedType = genType; } + return null; + } comp.ConfigureType(typeKey); comp.generatedType = genType; return comp; @@ -132,6 +141,7 @@ static bool UpgradeFrame(FigForgeFrame baseFrame, Type t, out FrameManager manag var go = baseFrame.gameObject; bool isShell = baseFrame.isShell; bool usesShell = baseFrame.usesShell; + bool persistent = baseFrame.persistent; string shellKey = baseFrame.shellKey; string genType = baseFrame.generatedType; var reg = go.GetComponent(); @@ -140,12 +150,37 @@ static bool UpgradeFrame(FigForgeFrame baseFrame, Type t, out FrameManager manag bool wasInitial = mgr != null && mgr.initialScreen == baseFrame; // FigForgeFrame is [DisallowMultipleComponent], so the base must go before the - // subclass can be added. + // subclass can be added. If the swap fails (AddComponent throws / yields an unexpected + // type), re-add the base so the page is never left WITHOUT a FigForgeFrame component + // (which would orphan it from the manager and break Frames.X navigation). UnityEngine.Object.DestroyImmediate(baseFrame); - if (!(go.AddComponent(t) is FigForgeFrame comp)) return false; + FigForgeFrame comp = null; + try { comp = go.AddComponent(t) as FigForgeFrame; } + catch (Exception e) { Debug.LogException(e); } + if (comp == null) + { + var restored = go.AddComponent(); + if (restored != null) + { + restored.isShell = isShell; + restored.usesShell = usesShell; + restored.persistent = persistent; + restored.shellKey = shellKey; + restored.generatedType = genType; + if (mgr != null) + { + if (idx >= 0 && idx < mgr.screens.Count) mgr.screens[idx] = restored; + else if (!mgr.screens.Contains(restored)) mgr.Register(restored); + if (wasInitial) mgr.initialScreen = restored; + EditorUtility.SetDirty(mgr); + } + } + return false; + } comp.isShell = isShell; comp.usesShell = usesShell; + comp.persistent = persistent; comp.shellKey = shellKey; comp.generatedType = genType; comp.__WireFrame(reg); diff --git a/unity/Editor/CodeGen/IdentifierUtil.cs b/unity/Editor/CodeGen/IdentifierUtil.cs index 1b4e3b6..a47987e 100644 --- a/unity/Editor/CodeGen/IdentifierUtil.cs +++ b/unity/Editor/CodeGen/IdentifierUtil.cs @@ -99,9 +99,9 @@ public static string StripSerializeMarker(string raw) public static readonly IReadOnlyCollection ReservedFrameMembers = new[] { // FigForgeFrame public/protected members - "isShell", "usesShell", "shellKey", "generatedType", + "isShell", "usesShell", "persistent", "shellKey", "generatedType", "isVisible", "IsBound", "ScreenKey", - "Show", "Guard", "OnShow", "OnHide", "OnBind", + "Show", "Hide", "SetVisible", "Guard", "OnShow", "OnHide", "OnBind", "__WireFrame", "__Get", "__GetList", // Common inherited Unity (Component/Behaviour/MonoBehaviour/Object) members // a Figma layer could realistically be named after. diff --git a/unity/Editor/Data/ManifestData.cs b/unity/Editor/Data/ManifestData.cs index 3fae14a..cf5780a 100644 --- a/unity/Editor/Data/ManifestData.cs +++ b/unity/Editor/Data/ManifestData.cs @@ -31,6 +31,11 @@ public class Manifest public class ManifestSettings { + // Required in the TS contract (plugin/src/types.ts emits 0.15 by default); + // mirrored here as the canonical default so an older/partial manifest that + // omits it still gets the same value. The sole read site + // (FigForgeImporterWindow.ApplyManifestSettings) additionally null-guards + // Manifest.settings, so a null settings block can't NPE. public float fontFaceDilate = 0.15f; } @@ -89,7 +94,7 @@ public class Stroke public class ShadowData { - public string kind; // dropShadow|innerShadow|layerBlur + public string kind; // dropShadow|innerShadow|layerBlur|backgroundBlur (TS Shadow.kind) public float[] color; // rgba 0..1 public float offsetX, offsetY; // Figma px (+y down) public float blur; // Figma effect radius @@ -166,8 +171,8 @@ public class CanonicalShape public float[] borderColor; public float borderWidth; public string borderAlign; // inside|outside|center (null = inside) - public ShadowData shadow; // first drop shadow on the regular layer - public List shadows; // all visible drop shadows + public ShadowData shadow; // first drop shadow on the regular layer — legacy: read for older manifests; not emitted by the 2.0 exporter + public List shadows; // all visible drop shadows — legacy: read for older manifests; not emitted by the 2.0 exporter public List effects; // all visible Figma effects } public class CanonicalStateColors { public float[] normal; public float[] highlighted; public float[] pressed; } @@ -221,6 +226,10 @@ public class CanonicalRef public string primaryLabel; // modal primary action label (legacy / fallback) public string secondaryLabel; // modal secondary action label (legacy / fallback) public List actions; // modal action buttons in design order (left→right), up to 3 + // NOTE: severity/position (and progressStyle below) are TS string-union types + // (plugin/src/types.ts) but are intentionally kept as open strings on the C# + // side — the importer switches on known values and falls back gracefully, so + // it must not reject an unrecognized value a newer plugin might emit. public string severity; // toast: info | success | warning | error public string position; // toast host position public float? duration; // toast auto-dismiss seconds @@ -385,12 +394,14 @@ public class AssetEntry { public string file; public string nodeId; public float public class AssetDiagnosticIssue { - public string category; // missingFonts|unsupportedFills|rasterFallbacks|oversizedPngs|blendModeCaveats + public string category; // missingFonts|unsupportedFills|rasterFallbacks|oversizedPngs|blendModeCaveats|variantExtraction public string severity; // info|warning public string nodeId; public string nodeName; public string asset; public string message; + // TS contract (Record) only ever puts scalars + // here; `object` is just Newtonsoft's landing type. Expect no nested objects/arrays. public Dictionary details; } diff --git a/unity/Editor/FigForgeCanvasHelperEditor.cs b/unity/Editor/FigForgeCanvasHelperEditor.cs index 9320da3..fdd87e2 100644 --- a/unity/Editor/FigForgeCanvasHelperEditor.cs +++ b/unity/Editor/FigForgeCanvasHelperEditor.cs @@ -66,16 +66,19 @@ public override void OnInspectorGUI() var manager = (FrameManager)target; int beforeColumns = manager != null ? manager.editorColumns : 5; - DrawDefaultInspector(); + // Draw via SerializedProperty so we can clamp editorColumns BEFORE the edit is + // committed (ApplyModifiedProperties), rather than after DrawDefaultInspector has + // already written an out-of-range value back to the manager. + serializedObject.Update(); + var columnsProp = serializedObject.FindProperty("editorColumns"); + DrawPropertiesExcluding(serializedObject, "m_Script", "editorColumns"); + if (columnsProp != null) + columnsProp.intValue = Mathf.Clamp( + EditorGUILayout.IntField("Editor grid columns", columnsProp.intValue), 1, 50); + serializedObject.ApplyModifiedProperties(); if (manager == null) return; - int afterColumns = Mathf.Clamp(manager.editorColumns, 1, 50); - if (afterColumns != manager.editorColumns) - { - Undo.RecordObject(manager, "FigForge Editor Columns"); - manager.editorColumns = afterColumns; - EditorUtility.SetDirty(manager); - } + int afterColumns = manager.editorColumns; if (afterColumns != beforeColumns) { diff --git a/unity/Editor/FigForgeFillDrawer.cs b/unity/Editor/FigForgeFillDrawer.cs index 9bd0e38..026a6ed 100644 --- a/unity/Editor/FigForgeFillDrawer.cs +++ b/unity/Editor/FigForgeFillDrawer.cs @@ -57,11 +57,15 @@ public override void OnGUI(Rect position, SerializedProperty property, GUIConten EditorGUIUtility.labelWidth = 0f; if (kind.enumValueIndex == (int)FigForgeFillKind.Gradient) { - if (gradient.gradientValue == null) - gradient.gradientValue = DefaultGradient(color.colorValue); + // Don't seed a default here: this path runs every repaint, so an + // unconditional write marks the object dirty just from being drawn. + // The mode-popup EndChangeCheck below already seeds on the actual + // user action that switches a fill to Gradient. Fall back to a + // throwaway default purely for display when the value is still null. EditorGUI.showMixedValue = gradient.hasMultipleDifferentValues; EditorGUI.BeginChangeCheck(); - var nextGradient = EditorGUI.GradientField(swatchRect, gradient.gradientValue); + var nextGradient = EditorGUI.GradientField(swatchRect, + gradient.gradientValue ?? DefaultGradient(color.colorValue)); if (EditorGUI.EndChangeCheck()) gradient.gradientValue = nextGradient; EditorGUI.showMixedValue = false; diff --git a/unity/Editor/FigForgeImporterWindow.cs b/unity/Editor/FigForgeImporterWindow.cs index aff8bb3..7a470c7 100644 --- a/unity/Editor/FigForgeImporterWindow.cs +++ b/unity/Editor/FigForgeImporterWindow.cs @@ -120,6 +120,16 @@ void OnEnable() RefreshFonts(); } + // Destroy the GUIStyle backing textures WindowStyles created. _styles is rebuilt + // by EnsureStyles after each domain reload, so without this the prior instance's + // HideAndDontSave textures leak as orphaned native objects. Null it so a later + // OnGUI rebuilds fresh styles rather than touching disposed textures. + void OnDisable() + { + _styles?.Dispose(); + _styles = null; + } + void RefreshManifests() { _manifestPaths = Directory @@ -132,7 +142,7 @@ void RefreshManifests() _projectPaths = Directory .GetFiles(Application.dataPath, "project.json", SearchOption.AllDirectories) .Select(p => "Assets" + p.Substring(Application.dataPath.Length).Replace('\\', '/')) - .Where(p => { try { return File.ReadAllText(p).Contains("figforge/project"); } catch { return false; } }) + .Where(p => HasSchemaMarker(p, "figforge/project")) .ToList(); _selectedProject = Mathf.Clamp(_selectedProject, 0, Mathf.Max(0, _projectPaths.Count - 1)); @@ -143,9 +153,25 @@ void RefreshManifests() // Only FigForge manifests carry the "figforge/manifest" schema marker. // Requiring it keeps the scan from trying to parse foreign/old-schema // manifest.json files in the project (which throw and spam the log). - static bool IsFigForgeManifest(string assetPath) + static bool IsFigForgeManifest(string assetPath) => HasSchemaMarker(assetPath, "figforge/manifest"); + + // The schema marker sits in the file head (it's the first/second JSON key the + // plugin emits), so read only a bounded prefix instead of slurping the whole + // file. RefreshManifests runs on every OnEnable/ImportZip/LiveBuildPage and + // scans the entire Assets tree — full ReadAllText on every manifest.json / + // project.json (some carry large inlined data) made that needlessly heavy. + const int SchemaProbeBytes = 8 * 1024; + static bool HasSchemaMarker(string assetPath, string marker) { - try { return File.ReadAllText(assetPath).Contains("figforge/manifest"); } + try + { + var buffer = new char[SchemaProbeBytes]; + using (var reader = new StreamReader(assetPath)) + { + int read = reader.Read(buffer, 0, buffer.Length); + return read > 0 && new string(buffer, 0, read).Contains(marker); + } + } catch { return false; } } @@ -457,6 +483,23 @@ void ImportZip() if (string.IsNullOrEmpty(zip)) return; var dest = $"Assets/FigForge/Imports/{SafeName(Path.GetFileNameWithoutExtension(zip))}"; + + // A prior import into the same folder is merged into, not replaced — same-named + // files are overwritten but stale assets from the old import linger silently. + // If the destination already holds an import, confirm before clearing it so the + // result is exactly the zip's contents. Fresh/empty folder → no prompt. + if (AssetDatabase.IsValidFolder(dest) && + Directory.EnumerateFileSystemEntries( + Path.Combine(Directory.GetParent(Application.dataPath).FullName, + dest.Replace('/', Path.DirectorySeparatorChar))).Any()) + { + if (!EditorUtility.DisplayDialog("Replace existing import?", + $"Folder \"{dest}\" already has an import — replace its contents?\n\nStale files from the previous import will be removed so the result matches this zip.", + "Replace", "Cancel")) + return; + AssetDatabase.DeleteAsset(dest); + } + var manifestPath = ZipImporter.ExtractToAssets(zip, dest); if (string.IsNullOrEmpty(manifestPath)) return; @@ -717,6 +760,9 @@ void Build() } catch (System.Exception e) { + // Also surface in the Console: the in-window log is invisible when the + // importer isn't focused (the per-screen inner catches already LogError). + Debug.LogError($"[FigForge] build failed: {e}"); Log($"build failed: {e.Message}\n{e.StackTrace}", MessageType.Error); } finally @@ -865,8 +911,16 @@ GameObject ReuseOrBuildScreen(LoadedScreen screen, string projectName, Transform Log($"reused unchanged '{screen.m.screen.name}'", MessageType.Info); return existing.gameObject; // reused → its generated accessors already exist } + // A changed frame is rebuilt from scratch (its GameObject is destroyed below), so + // capture the user-set `persistent` (don't-auto-hide) flag — the importer doesn't + // re-derive it from the manifest — and carry it onto the rebuilt frame so a + // re-import never silently resets the user's choice. (== null, not ??: GetComponent + // on a missing component yields Unity's fake-null stub that ?? treats as found.) + bool prevPersistent = false; if (existing != null) { + var existingFrame = existing.gameObject.GetComponent(); + prevPersistent = existingFrame != null && existingFrame.persistent; Log($"patched changed '{screen.m.screen.name}'", MessageType.Info); DestroyImmediate(existing.gameObject); } @@ -879,6 +933,12 @@ GameObject ReuseOrBuildScreen(LoadedScreen screen, string projectName, Transform if (stretch) StretchToParent(page); StampImported(page, projectName, screen); EnsureImportMarkers(page); // fresh build: everything here is imported + if (prevPersistent) + { + var pf = page.GetComponent(); + if (pf == null) pf = page.AddComponent(); + pf.persistent = true; + } return page; } @@ -1264,8 +1324,6 @@ void WarmUpImportedFrames(FrameManager mgr) if (frame == null || !warmed.Add(frame.gameObject)) continue; HierarchyBuilder.WarmUpGeneratedGraphics(frame.gameObject, _warmUpBatchSize); } - if (mgr.shell != null && warmed.Add(mgr.shell)) - HierarchyBuilder.WarmUpGeneratedGraphics(mgr.shell, _warmUpBatchSize); Canvas.ForceUpdateCanvases(); SceneView.RepaintAll(); } @@ -1320,9 +1378,24 @@ bool BuildPageProject(string projectPath, bool includeUnityCustomizations = fals var mgr = canvas.GetComponent() ?? canvas.gameObject.AddComponent(); mgr.editorColumns = _editorColumns; mgr.screens.Clear(); - mgr.shell = null; RemoveStaleImported(canvas.transform, proj.name, new HashSet(loaded.Select(s => s.importKey))); + // Declare the full set of frame class names this import will generate so every + // per-frame WriteFiles shares one reserved set (sections resolve to a single + // nested class) and EndBatch can sweep Frames..g.cs left by a Figma rename/ + // remove. Overlays have no frame class (see WriteFiles), so they're excluded — + // mirrors BuildModel's className = ToIdentifier(displayName ?? name). + var expectedFrameClasses = new HashSet(); + foreach (var s in loaded) + { + if (FrameRoles.IsOverlay(s.ps.role)) continue; + var sc = s.m != null ? s.m.screen : null; + if (sc == null) continue; + expectedFrameClasses.Add(IdentifierUtil.ToIdentifier( + !string.IsNullOrEmpty(sc.displayName) ? sc.displayName : sc.name)); + } + FrameCodeGenDriver.BeginBatch(expectedFrameClasses); + // 1. Persistent Shells (optional) — one per Section. Screens in the // same Section mount into that shell's Content slot. Shell frames are // registered too, so they can be shown directly like any other frame. @@ -1435,7 +1508,14 @@ bool BuildPageProject(string projectPath, bool includeUnityCustomizations = fals } mgr.initialScreen = mgr.Find(proj.initial); - if (canvas.GetComponent() == null) canvas.gameObject.AddComponent(); + var navBinder = canvas.GetComponent() ?? canvas.gameObject.AddComponent(); + // Pre-wire the manager reference so it's explicit in the Inspector and + // the runtime Start() lookup is skipped. The field is a private + // [SerializeField] on a runtime-assembly type, so set it via + // SerializedObject rather than widening its visibility. + var navBinderSO = new SerializedObject(navBinder); + navBinderSO.FindProperty("screenManager").objectReferenceValue = mgr; + navBinderSO.ApplyModifiedPropertiesWithoutUndo(); // Editor convenience: keep every imported frame visible/editable. // Runtime Start() still switches to one active frame via Show(). @@ -1458,8 +1538,13 @@ bool BuildPageProject(string projectPath, bool includeUnityCustomizations = fals Log($"built page '{proj.name}'{customizationSummary} — {built} screen(s){shellSummary}, initial '{proj.initial}' ✓", MessageType.Info); return true; } - catch (System.Exception e) { Log($"page build failed: {e.Message}\n{e.StackTrace}", MessageType.Error); return false; } - finally { EditorUtility.ClearProgressBar(); AssetDatabase.SaveAssets(); } + // Also LogError so the failure shows in the Console even when the importer + // window isn't focused (the per-screen inner catches already do this). + catch (System.Exception e) { Debug.LogError($"[FigForge] page build failed: {e}"); Log($"page build failed: {e.Message}\n{e.StackTrace}", MessageType.Error); return false; } + // EndBatch sweeps orphan Frames..g.cs and clears the shared reserved set. In the + // finally so a mid-import throw can't leave the batch open (which would make the NEXT + // single-frame import wrongly think it's still in a full run). + finally { FrameCodeGenDriver.EndBatch(); EditorUtility.ClearProgressBar(); AssetDatabase.SaveAssets(); } } void BuildPageUITK(ProjectData proj, List loaded) @@ -1873,6 +1958,23 @@ void Divider() sealed class WindowStyles { + // Every Texture2D MakeTexture/MakeButtonTexture/MakeForgeButtonTexture builds is + // collected here so Dispose() can DestroyImmediate them. They use + // HideFlags.HideAndDontSave (never serialized), so without an explicit destroy they + // leak as orphaned native textures each time _styles is rebuilt (e.g. domain reload). + readonly List _textures = new List(); + // Instance field initializers below can't call instance methods (CS0236), so the + // static Make* helpers stash each created texture here; the constructor (which runs + // after all field initializers) takes ownership into _textures so Dispose() can + // destroy them. The editor is single-threaded, so this scratch list is safe. + static readonly List s_pending = new List(); + + public WindowStyles() + { + _textures.AddRange(s_pending); + s_pending.Clear(); + } + public readonly GUIStyle hero = new GUIStyle { padding = new RectOffset(16, 16, 14, 12), @@ -2040,6 +2142,7 @@ static Texture2D MakeTexture(Color color) }; texture.SetPixel(0, 0, color); texture.Apply(); + s_pending.Add(texture); return texture; } @@ -2076,6 +2179,7 @@ static Texture2D MakeButtonTexture(Color top, Color bottom, Color border, int ra } texture.Apply(); + s_pending.Add(texture); return texture; } @@ -2121,6 +2225,7 @@ static Texture2D MakeForgeButtonTexture(Color top, Color bottom, Color bevel, in } texture.Apply(); + s_pending.Add(texture); return texture; } @@ -2132,6 +2237,16 @@ static float RoundedRectDistance(float x, float y, float width, float height, fl float ay = Mathf.Max(py, 0f); return Mathf.Sqrt(ax * ax + ay * ay) + Mathf.Min(Mathf.Max(px, py), 0f) - radius; } + + // Destroy the native textures this instance created. Only our own + // HideAndDontSave textures are tracked here, so this never touches shared + // or asset textures. Guarded + cleared so a double-call is a no-op. + public void Dispose() + { + foreach (var t in _textures) + if (t != null) DestroyImmediate(t); + _textures.Clear(); + } } } } diff --git a/unity/Editor/FigForgeLiveImport.cs b/unity/Editor/FigForgeLiveImport.cs index c9e32b1..a38cae3 100644 --- a/unity/Editor/FigForgeLiveImport.cs +++ b/unity/Editor/FigForgeLiveImport.cs @@ -31,6 +31,14 @@ public static class FigForgeLiveImport const int DefaultPort = 1995; const string LiveRoot = "Assets/FigForge/Live"; + // Loopback + token-gated, so these are belt-and-braces caps. The plugin's own + // send guard rejects bundles above ~200 MB encoded (see ui.ts sendToUnity); + // mirror a slightly higher ceiling here so a single malformed/huge POST can't + // pin unbounded memory in the Editor, and bound the queue so a burst of + // requests can't grow _inbox without limit before Pump drains it. + const long MaxBodyBytes = 256L * 1024 * 1024; // 256 MB + const int MaxInboxDepth = 8; + static HttpListener _listener; static readonly Queue _inbox = new Queue(); static readonly object _gate = new object(); @@ -163,6 +171,23 @@ static void OnContext(IAsyncResult ar) Respond(res, 401, "{\"ok\":false,\"error\":\"unauthorized\"}"); break; } + // Reject an oversized body up front (declared Content-Length) + // before reading it into memory — a single huge POST shouldn't + // be able to pin hundreds of MB in the Editor. + if (ctx.Request.ContentLength64 > MaxBodyBytes) + { + Respond(res, 413, "{\"ok\":false,\"error\":\"payload too large\"}"); + break; + } + // Bound the queue so a burst can't grow _inbox without limit + // before the main-thread Pump drains it. + bool full; + lock (_gate) full = _inbox.Count >= MaxInboxDepth; + if (full) + { + Respond(res, 503, "{\"ok\":false,\"error\":\"importer busy\"}"); + break; + } // The wire is always UTF-8 (the plugin posts JSON via fetch, // which encodes JS strings as UTF-8). Request.ContentEncoding // is NOT trustworthy: without an explicit charset in the @@ -172,7 +197,14 @@ static void OnContext(IAsyncResult ar) string body; using (var sr = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) body = sr.ReadToEnd(); - lock (_gate) _inbox.Enqueue(body); + lock (_gate) + { + // Re-check under the lock: another request may have filled + // the queue between the check above and here. + if (_inbox.Count >= MaxInboxDepth) full = true; + else _inbox.Enqueue(body); + } + if (full) { Respond(res, 503, "{\"ok\":false,\"error\":\"importer busy\"}"); break; } Respond(res, 202, "{\"ok\":true,\"queued\":true}"); break; default: @@ -220,21 +252,50 @@ static void Pump() static void ImportBundle(string json) { - var bundle = JsonUtility.FromJson(json); + LiveBundle bundle; + try { bundle = JsonUtility.FromJson(json); } + catch (Exception e) { throw new Exception($"unparseable bundle JSON: {e.Message}"); } if (bundle == null || bundle.screens == null || bundle.screens.Length == 0) throw new Exception("empty or unparseable bundle"); + // Version-gate the wire format BEFORE any destructive step (mirrors + // ManifestParser's supported-version check). The bundle carries no + // top-level version; each screen's manifest string does, so probe the + // first non-empty one. A mismatched generation degrades field-by-field + // silently otherwise — and the swap below would already have deleted the + // previous good import before ManifestParser.Load rejected it downstream. + string bundleVersion = FirstManifestVersion(bundle.screens); + if (bundleVersion != null && System.Array.IndexOf(SupportedManifestVersions, bundleVersion) < 0) + throw new Exception( + $"bundle manifest version '{bundleVersion}' is not supported by this importer " + + $"(supported: {string.Join(", ", SupportedManifestVersions)}) — import rejected. " + + "One side is stale: update the FigForge Unity importer or re-export with a current plugin."); + string projName = bundle.project != null && !string.IsNullOrEmpty(bundle.project.name) ? bundle.project.name : "Untitled"; string destAssets = $"{LiveRoot}/{SafeName(projName)}"; 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); - - var index = new ProjIndex + // 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; + + // Declared outside the try so it's in scope for the post-swap build call below; + // the catch rethrows, so control only reaches that use when index was assigned. + ProjIndex index = null; + try + { + index = new ProjIndex { name = projName, initial = bundle.project != null ? bundle.project.initial : "", @@ -251,7 +312,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 +361,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); @@ -324,6 +418,31 @@ static string SafeName(string s) return new string(chars); } + // Manifest wire-format versions this receiver accepts — kept in sync with + // ManifestParser.SupportedManifestVersions (the downstream gate). "1.0" = + // legacy number[] live-import assets, "2.0" = base64 assets + canonicalSchema. + static readonly string[] SupportedManifestVersions = { "1.0", "2.0" }; + + // Pull the `version` field out of the first screen whose manifest carries one. + // Returns null when none is present (a pre-versioned bundle), which we treat as + // "don't reject on version" — ManifestParser.Load still has the final say. + static string FirstManifestVersion(LiveScreen[] screens) + { + foreach (var s in screens) + { + if (s == null || string.IsNullOrEmpty(s.manifest)) continue; + try + { + var probe = JsonUtility.FromJson(s.manifest); + if (probe != null && !string.IsNullOrEmpty(probe.version)) return probe.version; + } + catch { /* malformed manifest string — let downstream parse surface it */ } + } + return null; + } + + [Serializable] class VersionProbe { public string version; } + // ---- wire payload (mirrors the plugin's export-page-complete message) -- [Serializable] class LiveBundle { public LiveProject project; public LiveScreen[] screens; } [Serializable] class LiveProject { public string name; public string initial; } diff --git a/unity/Editor/FontAutoImporter.cs b/unity/Editor/FontAutoImporter.cs index 480f041..e4630ec 100644 --- a/unity/Editor/FontAutoImporter.cs +++ b/unity/Editor/FontAutoImporter.cs @@ -81,7 +81,7 @@ static TMP_FontAsset Generate(string family, string style, Action log) // to the TMP default instead. try { - string src = FindFontFile(family, style); // project-relative .ttf/.otf (OS file copied in) + string src = FindFontFile(family, style, log); // project-relative .ttf/.otf (OS file copied in) if (src == null) return null; string outPath = $"{FontFolder}/{Safe(Path.GetFileNameWithoutExtension(src))} SDF.asset"; @@ -273,7 +273,7 @@ static string PrintableGlyph(char c) // Best .ttf/.otf for (family, style) across project + package + OS by // tier; copies an OS file into the project when it's the winner. - static string FindFontFile(string family, string style) + static string FindFontFile(string family, string style, Action log) { var candidates = AssetDatabase.FindAssets("t:Font").Select(AssetDatabase.GUIDToAssetPath) .Where(IsFontFile).Concat(BundledInterFontFiles()).Concat(OsFontFiles()) @@ -291,12 +291,60 @@ static string FindFontFile(string family, string style) if (chosen == null) return null; if (IsAssetDatabasePath(chosen)) return chosen; + // Embedding a font from the OS folders copies a binary the user may not + // have redistribution rights to — flag it so a packaged build's licensing + // is a deliberate choice, not a silent surprise. + log?.Invoke($"embedding system font '{Path.GetFileName(chosen)}' from {chosen} into {FontFolder}/ — verify you may redistribute it before shipping a build"); + TextureImportHelper.EnsureFolder(FontFolder); - string dest = $"{FontFolder}/{Path.GetFileName(chosen)}"; + // Two distinct OS files can share a bare filename across font folders; + // qualify the destination on collision (when an existing copy is a + // DIFFERENT file) so the second source doesn't clobber the first. A + // byte-identical copy is reused as-is, so re-imports don't pile up suffixes. + string dest = UniqueFontDest(Path.GetFileName(chosen), chosen); try { File.Copy(chosen, ProjectAbs(dest), true); AssetDatabase.ImportAsset(dest); return dest; } catch { return null; } } + // Resolve a destination under FontFolder that won't overwrite an unrelated + // existing copy. The plain name is reused when it's free or already holds the + // same bytes as the source; otherwise we append a numeric suffix, again + // reusing the first slot whose contents already match the source. + static string UniqueFontDest(string fileName, string sourceAbs) + { + string plain = $"{FontFolder}/{fileName}"; + if (!File.Exists(ProjectAbs(plain)) || SameFile(ProjectAbs(plain), sourceAbs)) return plain; + + string stem = Path.GetFileNameWithoutExtension(fileName); + string ext = Path.GetExtension(fileName); + for (int n = 1; n < 1000; n++) + { + string candidate = $"{FontFolder}/{stem}_{n}{ext}"; + string candidateAbs = ProjectAbs(candidate); + if (!File.Exists(candidateAbs) || SameFile(candidateAbs, sourceAbs)) return candidate; + } + return plain; // pathological; fall back to overwriting the plain name + } + + static bool SameFile(string aAbs, string bAbs) + { + try + { + var a = new FileInfo(aAbs); + var b = new FileInfo(bAbs); + if (!a.Exists || !b.Exists || a.Length != b.Length) return false; + using (var sa = a.OpenRead()) + using (var sb = b.OpenRead()) + { + int x; + while ((x = sa.ReadByte()) != -1) + if (x != sb.ReadByte()) return false; + return true; + } + } + catch { return false; } + } + static IEnumerable BundledInterFontFiles() { const string dir = "Packages/com.figforge.unity-importer/Fonts/Inter"; diff --git a/unity/Editor/HierarchyBuilder.cs b/unity/Editor/HierarchyBuilder.cs index e82fce7..b7a8ce8 100644 --- a/unity/Editor/HierarchyBuilder.cs +++ b/unity/Editor/HierarchyBuilder.cs @@ -49,6 +49,13 @@ 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(); + // First radio Toggle registered per group key, in build order. If a set ships with + // nothing pre-selected, this one is turned on so a radio set is never all-off. + public readonly Dictionary radioGroupFirst = new Dictionary(); } public static class HierarchyBuilder @@ -61,6 +68,8 @@ public static GameObject BuildPage(Manifest manifest, Transform parent, BuildCon ctx.registered.Clear(); ctx.resolvedCanonicalRefs.Clear(); + ctx.radioGroups.Clear(); + ctx.radioGroupFirst.Clear(); // One Figma frame = one root. If several, wrap them under a page root. // Compositor auto-create is suppressed for the whole element build: @@ -106,6 +115,17 @@ public static GameObject BuildPage(Manifest manifest, Transform parent, BuildCon } finally { FigForgePageCompositor.SuppressAutoCreate = false; } + // A radio set must show one selected option. If the captured design left a + // group entirely off, turn on the first radio built for it. (allowSwitchOff is + // false, so once on it stays a single-selection set.) + foreach (var kv in ctx.radioGroups) + { + var grp = kv.Value; + if (grp == null || grp.AnyTogglesOn()) continue; + if (ctx.radioGroupFirst.TryGetValue(kv.Key, out var first) && first != null) + first.isOn = true; + } + // The page root receives FigForgeFrame from the importer; the FigForgeFrameElement // the builder added to it as a structural container would just duplicate it (same // isVisible / RectTransform / visibility helpers), so drop it. Nested groups, which @@ -202,6 +222,13 @@ static string HierarchyPath(Transform t) return string.Join("/", parts.ToArray()); } + // Above this many warm-able graphics the per-batch Canvas.ForceUpdateCanvases() + // flushes cost more (a full-canvas rebuild each) than the SceneView stall they + // pre-empt, so on very large pages we skip the intermediate flushes and rely on + // the single trailing flush. Correctness is unchanged either way: every graphic + // is still touched/dirtied; only the number of redundant rebuild passes differs. + const int WarmUpIntermediateFlushCap = 2000; + // Warm the generated SDF graphics during editor import so the SceneView does // not spend the next several seconds rebuilding visible chunks of the page. // Batches still run inside the import call; the size only controls how often @@ -232,6 +259,9 @@ internal static void WarmUpGeneratedGraphics(GameObject pageRoot, int batchSize) Canvas.ForceUpdateCanvases(); var sources = pageRoot.GetComponentsInChildren(true); + // On very large pages the intermediate flushes dominate import time without + // changing the result, so gate them off and let the trailing flush do the work. + bool intermediateFlush = (all.Count + sources.Length) <= WarmUpIntermediateFlushCap; int warmedSources = 0; for (int i = 0; i < sources.Length; i++) { @@ -239,7 +269,7 @@ internal static void WarmUpGeneratedGraphics(GameObject pageRoot, int batchSize) if (source == null || !source.isActiveAndEnabled) continue; _ = source.GetCompositorSurface(); warmedSources++; - if (warmedSources % batchSize == 0) + if (intermediateFlush && warmedSources % batchSize == 0) Canvas.ForceUpdateCanvases(); } @@ -251,11 +281,10 @@ internal static void WarmUpGeneratedGraphics(GameObject pageRoot, int batchSize) g.SetVerticesDirty(); g.SetMaterialDirty(); - if ((i + 1) % batchSize == 0) + if (intermediateFlush && (i + 1) % batchSize == 0) Canvas.ForceUpdateCanvases(); } - Canvas.ForceUpdateCanvases(); Canvas.ForceUpdateCanvases(); SceneView.RepaintAll(); } @@ -294,7 +323,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) { @@ -353,13 +392,28 @@ 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(); + // A radio set must keep exactly one option selected: clicking the + // active radio must not turn it off. (An initially all-off set stays + // off until the first selection, after which it can never return to off.) + grp.allowSwitchOff = false; + ctx.radioGroups[groupKey] = grp; + } var tg = inst.GetComponentInChildren(true); - if (tg != null) tg.group = grp; + if (tg != null) + { + tg.group = grp; + if (!ctx.radioGroupFirst.ContainsKey(groupKey)) + ctx.radioGroupFirst[groupKey] = tg; + } } ApplyTransform(inst.GetComponent() ?? inst.AddComponent(), e, ctx); @@ -465,6 +519,13 @@ static GameObject BuildElement(ElementData e, Dictionary in } } } + else if (!string.IsNullOrEmpty(childId)) + { + // A declared extra child whose id isn't in the manifest index can't be + // built and is silently dropped from the visual — surface it so the + // missing decorative layer is debuggable. + Debug.LogWarning($"[FigForge] '{e.name}': extra child id '{childId}' not found in manifest — layer dropped."); + } } return inst; } @@ -2600,7 +2661,8 @@ static void ApplyStepperButtonStates(GameObject button, Graphic bg, CanonicalSha if (button == null || bg == null || (rollover == null && pressed == null)) return; if (bg is FigForgeRoundedRect || bg is FigForgeLayeredRect) { - var baseFill = ShapeFills(shape).Count > 0 ? ShapeFills(shape)[0] : FigForgeFill.Solid(bg.color); + var fills = ShapeFills(shape); + var baseFill = fills.Count > 0 ? fills[0] : FigForgeFill.Solid(bg.color); var states = button.AddComponent(); states.normal = baseFill; states.highlighted = rollover != null ? FigForgeFill.Solid(ToColor(rollover)) : baseFill; @@ -2733,7 +2795,8 @@ static void ApplyDropdownBackgroundStates(Graphic bg, CanonicalRef c, BuildConte if (bg == null || (c.bgRollover == null && c.bgPressed == null)) return; if (c.shape != null && (bg is FigForgeRoundedRect || bg is FigForgeLayeredRect)) { - var fill = ShapeFills(c.shape).Count > 0 ? ShapeFills(c.shape)[0] : FigForgeFill.None; + var fills = ShapeFills(c.shape); + var fill = fills.Count > 0 ? fills[0] : FigForgeFill.None; var states = bg.gameObject.AddComponent(); SetStates(states, fill, new CanonicalStateColors { @@ -2760,12 +2823,15 @@ static void BuildDropdownTemplate(GameObject root, CanonicalRef c, BuildContext trt.anchorMin = new Vector2(0, 0); trt.anchorMax = new Vector2(1, 0); trt.pivot = new Vector2(0.5f, 1); trt.anchoredPosition = new Vector2(0, 2 * sf); trt.sizeDelta = new Vector2(0, Mathf.Max(itemHeight, itemHeight * Mathf.Min(Mathf.Max(c.options != null ? c.options.Count : 3, 1), 6))); + // Visible popup background — the captured menu shape (fill/border/gradient/ + // shadow). It is the VISUAL only; the rounded clip is a dedicated stencil + // source on the Viewport below. (It used to double as the Mask graphic here, + // but a Mask only writes the stencil where its graphic draws opaque pixels — + // so a popup shape that came through fill-less wrote NO stencil and clipped + // the entire option list to nothing.) var popupShape = c.popupShape ?? c.shape ?? c.optionShape; if (popupShape != null) - { AddShapeGraphic(template, popupShape, ctx); - template.AddComponent().showMaskGraphic = true; - } else template.AddComponent().color = Color.white; var scroll = template.AddComponent(); @@ -2774,8 +2840,25 @@ static void BuildDropdownTemplate(GameObject root, CanonicalRef c, BuildContext var viewport = NewRect("Viewport", template.transform); var vrt = viewport.GetComponent(); vrt.anchorMin = Vector2.zero; vrt.anchorMax = Vector2.one; vrt.sizeDelta = Vector2.zero; vrt.pivot = new Vector2(0, 1); - viewport.AddComponent().color = Color.white; - viewport.AddComponent().showMaskGraphic = false; + // Rounded clip via a DEDICATED stencil source — same pattern as the List/Table + // viewport (BuildScrollShell). A near-invisible solid-fill SDF rounded-rect + // ALWAYS draws, so it ALWAYS writes the stencil; the option rows then clip to + // the popup's rounded silhouette (schema v14: square row fills must not poke + // past the rounded popup corners) regardless of whether the captured popup + // background carries a fill. Vanilla has no shader → square rectangular Mask. + float popupClipR = popupShape != null ? Mathf.Max(0f, popupShape.cornerRadius) * sf : 0f; + if (!ctx.vanilla && popupClipR > 0.5f) + { + var clip = viewport.AddComponent(); + clip.Configure(FigForgeFill.Solid(new Color(1f, 1f, 1f, 0.004f)), FigForgeStroke.None, + new Vector4(popupClipR, popupClipR, popupClipR, popupClipR)); + viewport.AddComponent().showMaskGraphic = true; + } + else + { + viewport.AddComponent().color = Color.white; + viewport.AddComponent().showMaskGraphic = false; + } scroll.viewport = vrt; var content = NewRect("Content", viewport.transform); @@ -4007,7 +4090,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); @@ -4082,8 +4165,17 @@ static GameObject ResolveOrGenerateCanonicalPrefab(ElementData e, BuildContext c bind.signature = sig; // stamp so a later definition change triggers regen TextureImportHelper.EnsureFolder(CanonicalFolder); - var prefab = UnityEditor.PrefabUtility.SaveAsPrefabAsset(temp, path); - UnityEngine.Object.DestroyImmediate(temp); + GameObject prefab; + try + { + prefab = UnityEditor.PrefabUtility.SaveAsPrefabAsset(temp, path); + } + finally + { + // Always tear down the scratch GameObject so a throwing save does not + // leak a stray copy into the editing scene. + UnityEngine.Object.DestroyImmediate(temp); + } if (prefab != null) { ctx.log($"generated canonical {kind} '{refName}' → {path}"); @@ -4219,11 +4311,16 @@ static GameObject ResolveOrGenerateCanonicalPrefab(ElementData e, BuildContext c // v59: canonical Modal/Dialog and Toast/Notification controls. // v60: canonical controls capture a child named Icon as a reusable sprite // and wire it through FigForgeBindings.icon / generated Icon slots. + // v64: dropdown popup clips its option rows with a DEDICATED rounded stencil + // source (a near-invisible solid-fill SDF rect) on the Viewport, like + // List/Table. The old path reused the captured popup background as the + // Mask graphic, so a fill-less popup shape wrote no stencil and hid the + // whole option list. // // Counterpart constant: plugin/src/types.ts CANONICAL_SCHEMA — the plugin // stamps its number into the manifest (canonicalSchema) and ManifestParser // warns when the two differ. Bump BOTH together. - internal const int CanonicalSchema = 63; // bumped: forces frames to rebuild so vanilla shadow/fill fixes re-apply + internal const int CanonicalSchema = 64; // bumped: dropdown popup uses a dedicated stencil source (regen to re-apply) // Invariant culture for every signature number: signatures persist in the // committed library asset, so a comma-decimal locale (de-DE, fr-FR, …) must @@ -4554,6 +4651,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(); 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 p.StartsWith(Root + "/", System.StringComparison.Ordinal)) + .Distinct() + .ToList(); + if (generated.Count == 0) + { + EditorUtility.DisplayDialog("FigForge", "No generated sprites found.", "OK"); + return; + } + + // Build the set of generated sprites referenced by any scene or prefab in the + // project. GetDependencies(recursive) walks the serialized references of each + // scene/prefab, so a sprite reachable from a built page (in-scene or saved prefab) + // is kept; only truly unreferenced assets are removed. + var referenced = new HashSet(); + var consumers = AssetDatabase.FindAssets("t:Scene t:Prefab") + .Select(AssetDatabase.GUIDToAssetPath) + .Distinct(); + foreach (var c in consumers) + foreach (var dep in AssetDatabase.GetDependencies(c, true)) + if (dep.StartsWith(Root + "/", System.StringComparison.Ordinal)) + referenced.Add(dep); + + var orphans = generated.Where(p => !referenced.Contains(p)).ToList(); + if (orphans.Count == 0) + { + EditorUtility.DisplayDialog("FigForge", + $"No orphaned generated sprites — all {generated.Count} are referenced.", "OK"); + return; + } + + if (!EditorUtility.DisplayDialog("FigForge — Clean Up Generated Sprites", + $"Delete {orphans.Count} of {generated.Count} generated sprite asset(s) that no scene " + + "or prefab references?\n\nReferenced sprites are kept.", "Delete", "Cancel")) + return; + + var failed = new List(); + AssetDatabase.DeleteAssets(orphans.ToArray(), failed); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + int deleted = orphans.Count - failed.Count; + Debug.Log($"[FigForge] cleaned up {deleted} orphaned generated sprite(s)" + + (failed.Count > 0 ? $" ({failed.Count} could not be deleted)" : "") + "."); + } + } } diff --git a/unity/Editor/SpriteAtlasHelper.cs b/unity/Editor/SpriteAtlasHelper.cs index 71ebf06..212e7ab 100644 --- a/unity/Editor/SpriteAtlasHelper.cs +++ b/unity/Editor/SpriteAtlasHelper.cs @@ -29,7 +29,13 @@ public static SpriteAtlas Build(string screenName, string spriteFolder, AtlasSet TextureImportHelper.EnsureFolder(atlasFolder); string path = $"{atlasFolder}/{Sanitize(screenName)}_Atlas.spriteatlas"; - var atlas = new SpriteAtlas(); + // Reconfigure an existing atlas in place rather than CreateAsset over it: + // re-creating churns the .spriteatlas GUID, breaking any references (and + // the late-binding SpriteAtlasManager lookups) that point at the old one. + var atlas = AssetDatabase.LoadAssetAtPath(path); + bool isNew = atlas == null; + if (isNew) atlas = new SpriteAtlas(); + var packing = new SpriteAtlasPackingSettings { padding = settings.padding, @@ -40,9 +46,17 @@ public static SpriteAtlas Build(string screenName, string spriteFolder, AtlasSet atlas.SetIncludeInBuild(settings.includeInBuild); var folderAsset = AssetDatabase.LoadAssetAtPath(spriteFolder); + // Clear prior packables so a reconfigured atlas doesn't accumulate stale + // folder entries across rebuilds; then add the current sprite folder. + if (!isNew) + { + var prior = atlas.GetPackables(); + if (prior != null && prior.Length > 0) atlas.Remove(prior); + } if (folderAsset != null) atlas.Add(new Object[] { folderAsset }); - AssetDatabase.CreateAsset(atlas, path); + if (isNew) AssetDatabase.CreateAsset(atlas, path); + else EditorUtility.SetDirty(atlas); AssetDatabase.SaveAssets(); SpriteAtlasUtility.PackAtlases(new[] { atlas }, EditorUserBuildSettings.activeBuildTarget); return atlas; 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 diff --git a/unity/Runtime/Controls/FigForgeModal.cs b/unity/Runtime/Controls/FigForgeModal.cs index e110f90..801a7fb 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. @@ -104,7 +110,9 @@ public bool ShowClose readonly System.Collections.Generic.List _contentStack = new System.Collections.Generic.List(); - public bool IsOpen => gameObject.activeSelf; + // Matches the modal stack's liveness test (activeInHierarchy): a modal under an + // inactive parent isn't reachable/visible, so it must not report as open. + public bool IsOpen => gameObject.activeInHierarchy; // ---- modal stack (LIFO) ------------------------------------------------------ // Opening pushes; closing pops. The top modal is the interactive one (its backdrop @@ -240,7 +248,12 @@ public void Open() if (!gameObject.activeSelf) gameObject.SetActive(true); _stack.Remove(this); // de-dup if re-opened _stack.Add(this); // push: now the top - transform.SetAsLastSibling(); // render above earlier modals in the same parent + // Render above earlier modals — but only within THIS modal's own parent. Sibling + // order doesn't reorder across different parents/Canvases; correct cross-parent + // stacking would need per-modal sorting (e.g. an overlay Canvas with sortingOrder, + // like the toast host). The importer keeps stacked modals under one parent, so the + // single-parent assumption holds for generated output. + transform.SetAsLastSibling(); onOpened.Invoke(); } @@ -311,19 +324,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); } diff --git a/unity/Runtime/Controls/FigForgeProgress.cs b/unity/Runtime/Controls/FigForgeProgress.cs index 1d6c098..2b7f026 100644 --- a/unity/Runtime/Controls/FigForgeProgress.cs +++ b/unity/Runtime/Controls/FigForgeProgress.cs @@ -76,12 +76,12 @@ public enum Style { Bar, Ring, Segments } public ProgressEvent onValueChanged = new ProgressEvent(); /// Range start — `bar.minValue = 0`. Re-clamps the current value - /// (onValueChanged fires if it moves) and repaints the fill. - public float minValue { get => m_MinValue; set { m_MinValue = value; Set(m_Value); } } + /// (silently — a range change does not fire onValueChanged) and repaints the fill. + public float minValue { get => m_MinValue; set { m_MinValue = value; Set(m_Value, false); } } /// Range end — `bar.maxValue = 100`. Re-clamps the current value - /// (onValueChanged fires if it moves) and repaints the fill. - public float maxValue { get => m_MaxValue; set { m_MaxValue = value; Set(m_Value); } } + /// (silently — a range change does not fire onValueChanged) and repaints the fill. + public float maxValue { get => m_MaxValue; set { m_MaxValue = value; Set(m_Value, false); } } /// The current progress — `bar.value = 0.75f`. Clamped to /// [minValue..maxValue]; drives the fill width and the percentage read-out. diff --git a/unity/Runtime/Controls/FigForgeStepper.cs b/unity/Runtime/Controls/FigForgeStepper.cs index e10c028..df14f79 100644 --- a/unity/Runtime/Controls/FigForgeStepper.cs +++ b/unity/Runtime/Controls/FigForgeStepper.cs @@ -5,6 +5,7 @@ using System.Globalization; using TMPro; using UnityEngine; +using UnityEngine.Events; using UnityEngine.UI; namespace FigForge @@ -13,6 +14,8 @@ namespace FigForge [DisallowMultipleComponent] public class FigForgeStepper : MonoBehaviour { + [System.Serializable] public class StepperEvent : UnityEvent { } + public TMP_InputField input; public Button minusButton; public Button plusButton; @@ -23,6 +26,9 @@ public class FigForgeStepper : MonoBehaviour [SerializeField] float m_Value; + [Tooltip("Invoked with the new value whenever it changes (buttons, typed input, or Value setter).")] + public StepperEvent onValueChanged = new StepperEvent(); + public float Value { get => m_Value; @@ -83,10 +89,7 @@ void SetValue(float value, bool sendCallback) bool changed = !Mathf.Approximately(m_Value, clamped); m_Value = clamped; RefreshText(); - if (changed && sendCallback) - { - // Reserved for future event surface; input text already reflects value. - } + if (changed && sendCallback) onValueChanged.Invoke(m_Value); } void RefreshText() diff --git a/unity/Runtime/Controls/FigForgeToastHost.cs b/unity/Runtime/Controls/FigForgeToastHost.cs index 26f1e2a..39a7318 100644 --- a/unity/Runtime/Controls/FigForgeToastHost.cs +++ b/unity/Runtime/Controls/FigForgeToastHost.cs @@ -204,10 +204,14 @@ IEnumerator AutoDismiss(float duration) public void ApplySeverity(ToastSeverity severity) { var color = SeverityColor(severity); + var baseBg = new Color(0.07f, 0.075f, 0.09f, 0.96f); if (accent != null) accent.color = color; if (background != null) { - background.color = new Color(0.07f, 0.075f, 0.09f, 0.96f); + // The accent strip carries the severity colour. With no accent graphic on + // the prefab, fall back to a subtle severity tint of the dark background so + // the severity is still conveyed (instead of every toast looking identical). + background.color = accent != null ? baseBg : Color.Lerp(baseBg, color, 0.12f); } } @@ -342,7 +346,11 @@ public static void Clear() static FigForgeToastHost ResolveHost() { +#if UNITY_2023_1_OR_NEWER + var existing = Object.FindFirstObjectByType(FindObjectsInactive.Include); +#else var existing = Object.FindObjectOfType(true); +#endif if (existing != null) return existing; EnsureEventSystem(); @@ -382,7 +390,11 @@ static FigForgeToastHost ResolveHost() static Canvas FindFigForgeCanvas() { +#if UNITY_2023_1_OR_NEWER + var helper = Object.FindFirstObjectByType(FindObjectsInactive.Include); +#else var helper = Object.FindObjectOfType(true); +#endif if (helper == null) return null; var canvas = helper.GetComponentInParent(); if (canvas == null) return null; @@ -391,7 +403,11 @@ static Canvas FindFigForgeCanvas() static void EnsureEventSystem() { +#if UNITY_2023_1_OR_NEWER + if (Object.FindFirstObjectByType() != null) return; +#else if (Object.FindObjectOfType() != null) return; +#endif // Scene-scoped: do NOT persist across scene loads, so each scene // manages its own EventSystem alongside its FigForge canvas. new GameObject("EventSystem", typeof(EventSystem), typeof(StandaloneInputModule)); diff --git a/unity/Runtime/FigForgeCachedQuad.shader b/unity/Runtime/FigForgeCachedQuad.shader index a9c81a1..f4ff03c 100644 --- a/unity/Runtime/FigForgeCachedQuad.shader +++ b/unity/Runtime/FigForgeCachedQuad.shader @@ -81,7 +81,10 @@ Shader "FigForge/CachedQuad" // Premultiplied output: scale the WHOLE colour by the mask factor. c *= UnityGet2DClipping(i.worldPosition.xy, _ClipRect); #endif - clip(c.a - 0.001); + // Additive blends (Screen/PlusLighter) can carry near-zero alpha but + // non-zero RGB; clipping on alpha alone would drop their contribution. + // Clip on the max channel so additive light still composites. + clip(max(c.a, max(c.r, max(c.g, c.b))) - 0.001); return c; } ENDCG diff --git a/unity/Runtime/FigForgeFrame.cs b/unity/Runtime/FigForgeFrame.cs index ceaadf3..4b149bd 100644 --- a/unity/Runtime/FigForgeFrame.cs +++ b/unity/Runtime/FigForgeFrame.cs @@ -17,6 +17,12 @@ public class FigForgeFrame : MonoBehaviour [Tooltip("If true this screen mounts inside its shell's content slot; the matching shell stays visible. If false it's full-screen and shells hide.")] public bool usesShell; + [Tooltip("If true this frame is NOT auto-hidden when another screen is shown — it's an " + + "independent overlay you control. It starts HIDDEN; reveal it with Show() / " + + "SetVisible(true) (which overlays the current screen WITHOUT hiding it) and take " + + "it down with Hide() / SetVisible(false). Preserved across re-imports.")] + public bool persistent; + [Tooltip("Importer shell group key. For shell frames this identifies the shell; for shell-mounted screens this identifies which shell to show.")] [ReadOnly] public string shellKey; @@ -33,14 +39,22 @@ public class FigForgeFrame : MonoBehaviour public bool IsBound => _bound; public string ScreenKey => name; - // Typed navigation: Frames.Settings.Show() — shows this frame (hiding the rest) - // via the active manager. Internal string key is never exposed to callers. + // Typed navigation: Frames.Settings.Show(). + // • A normal screen NAVIGATES — shows this frame and hides the rest. + // • A `persistent` frame OVERLAYS — becomes visible over the current screen + // without hiding it, and navigation won't auto-hide it afterward. public void Show() { var m = FrameManager.Resolve(); - if (m != null) m.Show(this); + if (m == null) return; + if (persistent) m.ShowPersistent(this); + else m.Show(this); } + // Hide this frame. Meant for `persistent` overlays the user dismisses; hiding the + // current navigated screen just leaves nothing shown (the caller's choice). + public void Hide() => SetVisible(false); + // Gate navigation INTO this screen: Frames.Settings.Guard(ctx => ...). public void Guard(NavGuard guard) { @@ -90,7 +104,9 @@ internal void BindOnce() OnBind(); } - internal void SetVisible(bool visible) + // Public so consumer code can directly toggle a frame's visibility (e.g. dismiss a + // `persistent` overlay). FrameManager also drives this during navigation. + public void SetVisible(bool visible) { BindOnce(); if (gameObject.activeSelf == visible) return; diff --git a/unity/Runtime/FigForgeImageBlend.cs b/unity/Runtime/FigForgeImageBlend.cs index eaf2c5e..a41bec7 100644 --- a/unity/Runtime/FigForgeImageBlend.cs +++ b/unity/Runtime/FigForgeImageBlend.cs @@ -122,6 +122,13 @@ void OnDisable() void OnDestroy() { + // OnDisable normally tears down the present quad + surface, but Unity does + // not guarantee OnDisable before OnDestroy in every teardown path (e.g. + // destroying an inactive GameObject), which would orphan the hidden + // __FigForgeImageBlend child. Mirror FigForgeLayeredRect.OnDestroy and clean + // up here too — both calls are guarded no-ops when already released. + DestroyPresent(); + ReleaseSurface(); if (_bakeMaterial != null) { if (Application.isPlaying) Destroy(_bakeMaterial); diff --git a/unity/Runtime/FigForgeLayeredRect.cs b/unity/Runtime/FigForgeLayeredRect.cs index 668ab7a..8f159a7 100644 --- a/unity/Runtime/FigForgeLayeredRect.cs +++ b/unity/Runtime/FigForgeLayeredRect.cs @@ -245,6 +245,12 @@ public class FigForgeLayeredRect : MaskableGraphic, IFigForgeCompositorSource // so it defaults false on load/instantiate → the first access normalizes. bool _listsNormalized; FigForgePageCompositor _pageCompositor; +#if UNITY_EDITOR + // OnValidate fires repeatedly during a drag-edit; each call used to queue a + // fresh delayCall closure, stacking N registration passes per dirty burst. + // Gate on this flag so only one is pending at a time; cleared when it runs. + bool _pendingValidateRegistration; +#endif public IReadOnlyList Fills => fills; public IReadOnlyList Strokes => strokes; @@ -424,8 +430,12 @@ public override Material materialForRendering { get { + // base.materialForRendering reads `this.material` (our override), which + // already calls UpdateActiveMaterial on `active` — so don't upload to + // `active` a second time here (mirrors FigForgeImageBlend/TextBlend/ + // VectorGraphic). Only the stencil-wrapped clone, when uGUI hands back a + // material distinct from `active`, still needs configuring. var active = ActiveRenderMaterial(); - UpdateActiveMaterial(active); var renderMaterial = base.materialForRendering; if (renderMaterial != null && renderMaterial != active) UpdateActiveMaterial(renderMaterial); @@ -1503,12 +1513,17 @@ protected override void OnValidate() // which Unity disallows inside OnValidate ("SendMessage cannot be called during // OnValidate") — defer it to the next editor tick, guarding against the // component being destroyed or disabled in between. - UnityEditor.EditorApplication.delayCall += () => + if (!_pendingValidateRegistration) { - if (this == null || !isActiveAndEnabled) return; - UpdatePageCompositorRegistration(); - MarkPageCompositorDirty(); - }; + _pendingValidateRegistration = true; + UnityEditor.EditorApplication.delayCall += () => + { + _pendingValidateRegistration = false; + if (this == null || !isActiveAndEnabled) return; + UpdatePageCompositorRegistration(); + MarkPageCompositorDirty(); + }; + } MarkPageCompositorDirty(); base.OnValidate(); SetVerticesDirty(); diff --git a/unity/Runtime/FigForgeList.cs b/unity/Runtime/FigForgeList.cs index 24a5d91..157ffd1 100644 --- a/unity/Runtime/FigForgeList.cs +++ b/unity/Runtime/FigForgeList.cs @@ -73,7 +73,23 @@ public class FigForgeList : MonoBehaviour readonly List _items = new List(); - public IReadOnlyList Items => _items; + /// The list's items. Normally the data model you set via SetItems/AddItem; + /// if that's empty but the list is showing design-time preview rows, returns those + /// rows' visible content instead — so Items always matches what's on screen (and + /// Items.Count == ItemCount). Once you SetItems, it's the data model verbatim. + public IReadOnlyList Items + => (_items.Count > 0 || content == null) ? _items : ReadRenderedItems(); + + // Scrape every rendered row into items — the preview-row fallback for Items, mirroring + // GetItem's per-row fallback. Only hit when the data model is empty but rows render. + IReadOnlyList ReadRenderedItems() + { + int n = content.childCount; + var items = new List(n); + for (int i = 0; i < n; i++) + items.Add(ReadRenderedItem(i) ?? default); + return items; + } /// Show/hide the whole control — `list.isVisible = false`. Drives /// GameObject.SetActive, so a hidden control stops rendering, receiving input, @@ -127,6 +143,130 @@ public void SetItems(IEnumerable titles) Rebuild(); } + // --- Granular item accessors ------------------------------------------ + // List-shaped counterpart to the Table's cell accessors: a List row is one + // FigForgeListItem (Title + optional Subtitle), not a cell grid. Reads address the + // data model set via SetItems/AddItem/…, and fall back to a row's visible text when + // it's rendered but has no backing data (design-time preview rows), so a validly- + // selected row never returns null. Single-item writes patch the rendered text IN + // PLACE (no Rebuild, so scroll + selection survive); Add/Insert/Remove Rebuild. + + /// Number of rows the list currently shows — the range Select accepts and + /// GetItem addresses. This is the rendered row count (so design-time preview rows + /// count too); it equals the data-model size once you SetItems/AddItem. + public int ItemCount => content != null ? content.childCount : _items.Count; + + /// The currently-selected item, or null when nothing is selected. Handy in + /// an onSelectionChanged handler: `var item = list.SelectedItem;`. + public FigForgeListItem? SelectedItem => GetItem(_selected); + + /// Read one item, or null when `index` is out of range. Reads the data model + /// (SetItems/AddItem); for a rendered row with no backing data — preview rows — it + /// falls back to the row's visible Title/Subtitle text. + public FigForgeListItem? GetItem(int index) + { + if (index < 0) return null; + if (index < _items.Count) return _items[index]; + return ReadRenderedItem(index); // preview rows: scrape what's on screen + } + + /// Read an item's title — "" if blank, null if `index` is out of range. + public string GetTitle(int index) + { + var it = GetItem(index); + return it.HasValue ? (it.Value.title ?? "") : null; + } + + /// Read an item's subtitle — "" if blank, null if `index` is out of range. + public string GetSubtitle(int index) + { + var it = GetItem(index); + return it.HasValue ? (it.Value.subtitle ?? "") : null; + } + + // Reconstruct an item from the rendered Title/Subtitle (or styled-row Label) text. + // Used when the data model doesn't cover `index` (preview rows render but never + // populate _items). Null when the row isn't rendered either. + FigForgeListItem? ReadRenderedItem(int index) + { + if (content == null || index >= content.childCount) return null; + var titleT = GetTitleText(index); + var subT = GetSubtitleText(index); + return new FigForgeListItem(titleT != null ? titleT.text : "", subT != null ? subT.text : null); + } + + /// Replace one item, re-rendering its Title (and Subtitle, on captured rows + /// that have one) in place — no Rebuild. No-op if `index` is out of range. + public void SetItem(int index, FigForgeListItem item) + { + if (index < 0 || index >= _items.Count) return; + _items[index] = item; + var titleT = GetTitleText(index); + if (titleT != null) titleT.text = item.title ?? ""; + var subT = GetSubtitleText(index); + if (subT != null) subT.text = item.subtitle ?? ""; + } + + /// Replace one item by title (+ optional subtitle) — convenience overload. + public void SetItem(int index, string title, string subtitle = null) + => SetItem(index, new FigForgeListItem(title, subtitle)); + + /// The live Title TMP for a rendered row — use to restyle (colour, font). + /// Null if the row isn't rendered. Falls back to the styled-row "Label". For text + /// changes prefer SetItem (keeps the model in sync). + public TMP_Text GetTitleText(int index) + { + if (content == null || index < 0 || index >= content.childCount) return null; + var row = content.GetChild(index); + // Captured rows name it "Title"; the styled fallback renders the title in "Label". + var t = FindByName(row, "Title"); + if (t == null) t = FindByName(row, "Label"); + return t != null ? t.GetComponent() : null; + } + + /// The live Subtitle TMP for a rendered row — null if the row isn't rendered + /// or has no Subtitle (the styled fallback renders title only). + public TMP_Text GetSubtitleText(int index) + { + if (content == null || index < 0 || index >= content.childCount) return null; + var t = FindByName(content.GetChild(index), "Subtitle"); + return t != null ? t.GetComponent() : null; + } + + /// The live FigForgeListRow for a rendered row (state fills, selection, + /// GameObject), or null if not rendered. + public FigForgeListRow GetRowObject(int index) + { + if (content == null || index < 0 || index >= content.childCount) return null; + return content.GetChild(index).GetComponent(); + } + + /// Append an item. Row count changed, so the list Rebuilds. + public void AddItem(FigForgeListItem item) { _items.Add(item); Rebuild(); } + + /// Append a title (+ optional subtitle). + public void AddItem(string title, string subtitle = null) { _items.Add(new FigForgeListItem(title, subtitle)); Rebuild(); } + + /// Insert an item at `index` (clamped to 0..ItemCount). Rebuilds. Note this + /// shifts later indices — a held SelectedIndex now points at a different row. + public void InsertItem(int index, FigForgeListItem item) + { + index = Mathf.Clamp(index, 0, _items.Count); + _items.Insert(index, item); + Rebuild(); + } + + /// Remove the item at `index`. No-op if out of range. Rebuilds, and fixes up + /// the selection (clears it if the removed row was selected, else shifts it down). + public void RemoveItem(int index) + { + if (index < 0 || index >= _items.Count) return; + _items.RemoveAt(index); + if (_selected == index) _selected = -1; + else if (_selected > index) _selected--; + Rebuild(); + } + public void Configure(RectTransform contentRoot, float height, string label, FigForgeListRowStyle style, Color rollover, bool hasRollover) { content = contentRoot; @@ -301,32 +441,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/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