From ec90f35df81466f93ecade2efcd16d0d29a09ac0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 27 Feb 2026 17:16:21 +0300 Subject: [PATCH] PoC 2 & 3: Yoga WASM layout + Kiwi codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yoga WASM (yoga-layout v3.2.1): - Vertical/horizontal auto-layout with gap, padding, alignment ✅ - Nested frames with flex-grow ✅ - Space-between distribution ✅ - 1000-node layout in 3ms ✅ Kiwi binary codec (from figma-use multiplayer module): - 194-definition Figma schema (2178 lines) compiles in 6ms - Encode 4 nodes → 237 bytes Zstd-compressed - Full round-trip: encode → Zstd compress → decompress → decode ✅ - Patch kiwi-schema to allow sparse field IDs (Figma uses IDs > 312) Extracted files: schema.ts, codec.ts, protocol.ts, client.ts, index.ts --- bun.lock | 9 + package.json | 7 +- patches/kiwi-schema@0.5.0.patch | 13 + src/engine/poc-yoga.ts | 199 +++ src/kiwi/client.ts | 349 +++++ src/kiwi/codec.ts | 559 ++++++++ src/kiwi/index.ts | 70 + src/kiwi/poc-test.ts | 180 +++ src/kiwi/protocol.ts | 238 ++++ src/kiwi/schema.ts | 2178 +++++++++++++++++++++++++++++++ 10 files changed, 3801 insertions(+), 1 deletion(-) create mode 100644 patches/kiwi-schema@0.5.0.patch create mode 100644 src/engine/poc-yoga.ts create mode 100644 src/kiwi/client.ts create mode 100644 src/kiwi/codec.ts create mode 100644 src/kiwi/index.ts create mode 100644 src/kiwi/poc-test.ts create mode 100644 src/kiwi/protocol.ts create mode 100644 src/kiwi/schema.ts diff --git a/bun.lock b/bun.lock index 4f69ddc96..6a9cea306 100644 --- a/bun.lock +++ b/bun.lock @@ -8,8 +8,10 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "canvaskit-wasm": "^0.40.0", + "kiwi-schema": "^0.5.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "yoga-layout": "^3.2.1", }, "devDependencies": { "@tauri-apps/cli": "^2", @@ -24,6 +26,9 @@ }, }, }, + "patchedDependencies": { + "kiwi-schema@0.5.0": "patches/kiwi-schema@0.5.0.patch", + }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -347,6 +352,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "kiwi-schema": ["kiwi-schema@0.5.0", "", { "bin": { "kiwic": "cli.js" } }, "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -390,5 +397,7 @@ "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], } } diff --git a/package.json b/package.json index e069aaccc..438cf7a30 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "canvaskit-wasm": "^0.40.0", + "kiwi-schema": "^0.5.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "yoga-layout": "^3.2.1" }, "devDependencies": { "@tauri-apps/cli": "^2", @@ -30,5 +32,8 @@ "oxlint": "^1.50.0", "typescript": "~5.8.3", "vite": "^7.0.4" + }, + "patchedDependencies": { + "kiwi-schema@0.5.0": "patches/kiwi-schema@0.5.0.patch" } } diff --git a/patches/kiwi-schema@0.5.0.patch b/patches/kiwi-schema@0.5.0.patch new file mode 100644 index 000000000..659fd6d11 --- /dev/null +++ b/patches/kiwi-schema@0.5.0.patch @@ -0,0 +1,13 @@ +diff --git a/kiwi.js b/kiwi.js +index 26a46b6a2efbbdb9623a5275751eaaa10378e617..5a57b99fd2fd5fe087fc1c556672c58aa63b57ec 100644 +--- a/kiwi.js ++++ b/kiwi.js +@@ -2167,7 +2167,7 @@ function verify(root) { + if (field.value <= 0) { + error("The id for field " + quote(field.name) + " must be positive", field.line, field.column); + } +- if (field.value > fields.length) { ++ if (false && field.value > fields.length) { + error("The id for field " + quote(field.name) + " cannot be larger than " + fields.length, field.line, field.column); + } + values.push(field.value); diff --git a/src/engine/poc-yoga.ts b/src/engine/poc-yoga.ts new file mode 100644 index 000000000..daa3af86f --- /dev/null +++ b/src/engine/poc-yoga.ts @@ -0,0 +1,199 @@ +/** + * PoC 2: Yoga WASM Layout Engine + * + * Validates: + * 1. yoga-layout WASM loads and initializes + * 2. Flex layout (auto-layout) matches Figma's behavior + * 3. Nested frames with padding, gap, alignment + * 4. Performance: layout 1000 nodes + */ + +import Yoga, { + Align, + Direction, + FlexDirection, + Gutter, + Justify, + Edge, + Wrap, + type Node as YogaNode +} from 'yoga-layout' + +function runTest() { + console.log('=== PoC 2: Yoga WASM Layout ===\n') + + // 1. Basic flex layout (Figma auto-layout: vertical, gap=16, padding=24) + { + const root = Yoga.Node.create() + root.setWidth(400) + root.setHeight('auto') + root.setFlexDirection(FlexDirection.Column) + root.setPadding(Edge.All, 24) + root.setGap(Gutter.All, 16) + + const child1 = Yoga.Node.create() + child1.setWidth('100%') + child1.setHeight(48) + + const child2 = Yoga.Node.create() + child2.setWidth('100%') + child2.setHeight(120) + + const child3 = Yoga.Node.create() + child3.setWidth('100%') + child3.setHeight(48) + + root.insertChild(child1, 0) + root.insertChild(child2, 1) + root.insertChild(child3, 2) + + root.calculateLayout(undefined, undefined, Direction.LTR) + + console.log('Test 1: Vertical auto-layout (gap=16, padding=24)') + console.log(` Root: ${root.getComputedWidth()}×${root.getComputedHeight()}`) + // Expected: height = 24 + 48 + 16 + 120 + 16 + 48 + 24 = 296 + const expectedHeight = 24 + 48 + 16 + 120 + 16 + 48 + 24 + const actualHeight = root.getComputedHeight() + console.log( + ` Expected height: ${expectedHeight}, Actual: ${actualHeight} ${actualHeight === expectedHeight ? '✅' : '❌'}` + ) + + console.log(` Child 1: y=${child1.getComputedTop()}, h=${child1.getComputedHeight()}`) + console.log(` Child 2: y=${child2.getComputedTop()}, h=${child2.getComputedHeight()}`) + console.log(` Child 3: y=${child3.getComputedTop()}, h=${child3.getComputedHeight()}`) + + // Verify positions + const y1 = child1.getComputedTop() // Expected: 24 + const y2 = child2.getComputedTop() // Expected: 24 + 48 + 16 = 88 + const y3 = child3.getComputedTop() // Expected: 88 + 120 + 16 = 224 + console.log(` Positions: ${y1 === 24 && y2 === 88 && y3 === 224 ? '✅' : '❌'}`) + + root.free() + child1.free() + child2.free() + child3.free() + } + + // 2. Horizontal auto-layout with space-between (Figma: distribute) + { + const root = Yoga.Node.create() + root.setWidth(600) + root.setHeight(80) + root.setFlexDirection(FlexDirection.Row) + root.setJustifyContent(Justify.SpaceBetween) + root.setAlignItems(Align.Center) + root.setPadding(Edge.Horizontal, 16) + + for (let i = 0; i < 4; i++) { + const child = Yoga.Node.create() + child.setWidth(100) + child.setHeight(48) + root.insertChild(child, i) + } + + root.calculateLayout(undefined, undefined, Direction.LTR) + + console.log('\nTest 2: Horizontal space-between') + const positions: string[] = [] + for (let i = 0; i < 4; i++) { + const child = root.getChild(i) + positions.push(`x=${child.getComputedLeft()} y=${child.getComputedTop()}`) + } + console.log(` Children: ${positions.join(' | ')}`) + // Items should be centered vertically (y = (80-48)/2 = 16) + const centeredCorrectly = root.getChild(0).getComputedTop() === 16 + console.log(` Vertical centering: ${centeredCorrectly ? '✅' : '❌'}`) + // Space-between with padding: available = 600-32 = 568, items = 4*100 = 400, gaps = 168/3 = 56 + const gap = root.getChild(1).getComputedLeft() - root.getChild(0).getComputedLeft() - 100 + console.log(` Gap between items: ${gap}px ${Math.abs(gap - 56) < 1 ? '✅' : '❌'}`) + + for (let i = root.getChildCount() - 1; i >= 0; i--) root.getChild(i).free() + root.free() + } + + // 3. Nested frames (card with header + content) + { + const card = Yoga.Node.create() + card.setWidth(320) + card.setFlexDirection(FlexDirection.Column) + card.setPadding(Edge.All, 0) + + const header = Yoga.Node.create() + header.setWidth('100%') + header.setHeight(56) + header.setFlexDirection(FlexDirection.Row) + header.setAlignItems(Align.Center) + header.setPadding(Edge.Horizontal, 16) + header.setGap(Gutter.All, 12) + + const avatar = Yoga.Node.create() + avatar.setWidth(32) + avatar.setHeight(32) + + const title = Yoga.Node.create() + title.setFlexGrow(1) + title.setHeight(20) + + header.insertChild(avatar, 0) + header.insertChild(title, 1) + + const content = Yoga.Node.create() + content.setWidth('100%') + content.setHeight(200) + + card.insertChild(header, 0) + card.insertChild(content, 1) + + card.calculateLayout(undefined, undefined, Direction.LTR) + + console.log('\nTest 3: Nested card layout') + console.log(` Card: ${card.getComputedWidth()}×${card.getComputedHeight()}`) + console.log(` Header: y=${header.getComputedTop()}, h=${header.getComputedHeight()}`) + console.log( + ` Avatar: x=${avatar.getComputedLeft()}, y=${avatar.getComputedTop()}, centered=${avatar.getComputedTop() === 12 ? '✅' : '❌'}` + ) + console.log( + ` Title: x=${title.getComputedLeft()}, w=${title.getComputedWidth()}, grows=${title.getComputedWidth() === 320 - 16 - 32 - 12 - 16 ? '✅' : '❌'}` + ) + console.log(` Content: y=${content.getComputedTop()}, h=${content.getComputedHeight()}`) + + avatar.free() + title.free() + header.free() + content.free() + card.free() + } + + // 4. Performance: layout 1000 nodes + { + const root = Yoga.Node.create() + root.setWidth(1200) + root.setFlexDirection(FlexDirection.Row) + root.setFlexWrap(Wrap.Wrap) // Wrap + root.setGap(Gutter.All, 8) + root.setPadding(Edge.All, 16) + + const nodes: YogaNode[] = [root] + for (let i = 0; i < 1000; i++) { + const child = Yoga.Node.create() + child.setWidth(80) + child.setHeight(80) + root.insertChild(child, i) + nodes.push(child) + } + + const t0 = performance.now() + root.calculateLayout(undefined, undefined, Direction.LTR) + const layoutTime = performance.now() - t0 + + console.log(`\nTest 4: Performance (1000 nodes)`) + console.log(` Layout time: ${layoutTime.toFixed(2)}ms ${layoutTime < 50 ? '✅' : '⚠️'}`) + console.log(` Root height: ${root.getComputedHeight()}px`) + + for (const n of nodes) n.free() + } + + console.log('\n✅ PoC 2 PASSED: Yoga WASM layout works correctly') +} + +runTest() diff --git a/src/kiwi/client.ts b/src/kiwi/client.ts new file mode 100644 index 000000000..6797f5f8e --- /dev/null +++ b/src/kiwi/client.ts @@ -0,0 +1,349 @@ +/** + * Figma Multiplayer WebSocket Client + * + * High-level interface for connecting to Figma's multiplayer server + * and creating/modifying nodes directly via WebSocket. + * + * Performance: ~1000 nodes in 15-20ms (vs 50-100s via plugin API) + */ + +import { + initCodec, + encodeMessage, + decodeMessage, + decompress, + createNodeChangesMessage, + type NodeChange, + type FigmaMessage +} from './codec.ts' +import { + buildMultiplayerUrl, + MESSAGE_TYPES, + isZstdCompressed, + hasFigWireHeader, + skipFigWireHeader, + isKiwiMessage, + getKiwiMessageType +} from './protocol.ts' + +import type { ChromeDevToolsTarget } from '../types.ts' + +export interface SessionInfo { + sessionID: number + reconnectSequenceNumber: number +} + +export interface ConnectionOptions { + connectionTimeout?: number + onMessage?: (message: FigmaMessage) => void + onError?: (error: Error) => void +} + +type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'ready' + +export class FigmaMultiplayerClient { + private fileKey: string + private ws: WebSocket | null = null + private state: ConnectionState = 'disconnected' + private sessionInfo: SessionInfo | null = null + private localIDCounter: number + private options: ConnectionOptions + + constructor(fileKey: string, options: ConnectionOptions = {}) { + this.fileKey = fileKey + this.localIDCounter = Date.now() % 10000000 + this.options = { + connectionTimeout: 30000, + ...options + } + } + + /** + * Connect to Figma multiplayer server + */ + async connect(cookies: string): Promise { + if (this.state !== 'disconnected') { + throw new Error(`Cannot connect: state is ${this.state}`) + } + + await initCodec() + this.state = 'connecting' + + return new Promise((resolve, reject) => { + const url = buildMultiplayerUrl(this.fileKey) + + this.ws = new WebSocket(url, { + headers: { + Cookie: cookies, + Origin: 'https://www.figma.com' + } + } as WebSocketInit) + + this.ws.binaryType = 'arraybuffer' + + const timeout = setTimeout(() => { + this.close() + reject(new Error('Connection timeout')) + }, this.options.connectionTimeout) + + let sessionID = 0 + let reconnectSequenceNumber = 0 + let joinEndReceived = false + + this.ws.onmessage = (event) => { + if (!(event.data instanceof ArrayBuffer)) return + + let data: Uint8Array = new Uint8Array(event.data as ArrayBuffer) + + // Skip fig-wire header if present + if (hasFigWireHeader(data)) { + data = new Uint8Array(skipFigWireHeader(data)) + } + + if (!isZstdCompressed(data)) return + + try { + const decompressed = decompress(data) + + // Skip non-Kiwi message data (e.g., schema definitions) + if (!isKiwiMessage(decompressed)) return + + const msgType = getKiwiMessageType(decompressed)! + + // JOIN_END: sync complete + if (msgType === MESSAGE_TYPES.JOIN_END) { + joinEndReceived = true + } + + // SIGNAL: extract reconnect-sequence-number + if (msgType === MESSAGE_TYPES.SIGNAL) { + const str = new TextDecoder().decode(decompressed) + const match = str.match(/reconnect-sequence-number[^\d]*(\d+)/) + if (match?.[1]) { + reconnectSequenceNumber = parseInt(match[1]) + } + } + + // Forward messages when ready + if (this.options.onMessage && this.state === 'ready') { + try { + const message = decodeMessage(data) + this.options.onMessage(message) + } catch { + // Ignore decode errors + } + } + + // Check if handshake complete + // Note: sessionID may be 0 if Figma changed protocol; caller gets it from plugin API + if (joinEndReceived && (this.state === 'connecting' || this.state === 'connected')) { + clearTimeout(timeout) + this.state = 'ready' + this.sessionInfo = { sessionID: sessionID || 0, reconnectSequenceNumber } + resolve(this.sessionInfo) + } + } catch { + // Ignore parse errors + } + } + + this.ws.onopen = () => { + this.state = 'connected' + } + + this.ws.onerror = () => { + clearTimeout(timeout) + const error = new Error('WebSocket connection failed') + this.options.onError?.(error) + if (this.state === 'connecting') { + reject(error) + } + } + + this.ws.onclose = () => { + clearTimeout(timeout) + const wasConnecting = this.state === 'connecting' + this.state = 'disconnected' + this.ws = null + if (wasConnecting) { + reject(new Error('Connection closed during handshake')) + } + } + }) + } + + /** + * Send node changes to Figma (fire and forget) + */ + async sendNodeChanges(nodeChanges: NodeChange[]): Promise { + if (this.state !== 'ready' || !this.ws || !this.sessionInfo) { + throw new Error('Not connected') + } + + const message = createNodeChangesMessage( + this.sessionInfo.sessionID, + this.sessionInfo.reconnectSequenceNumber, + nodeChanges + ) + + const encoded = encodeMessage(message) + this.ws.send(encoded) + } + + /** + * Send node changes and wait for server ACK (NODE_CHANGES echo) + * This guarantees nodes are synced before returning + */ + async sendNodeChangesSync(nodeChanges: NodeChange[], timeout = 5000): Promise { + if (this.state !== 'ready' || !this.ws || !this.sessionInfo) { + throw new Error('Not connected') + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.ws?.removeEventListener('message', handler) + reject(new Error('ACK timeout')) + }, timeout) + + const handler = (event: MessageEvent) => { + if (!(event.data instanceof ArrayBuffer)) return + + let data: Uint8Array = new Uint8Array(event.data as ArrayBuffer) + if (hasFigWireHeader(data)) data = new Uint8Array(skipFigWireHeader(data)) + if (!isZstdCompressed(data)) return + + try { + const dec = decompress(data) + if (isKiwiMessage(dec) && getKiwiMessageType(dec) === MESSAGE_TYPES.NODE_CHANGES) { + clearTimeout(timer) + this.ws?.removeEventListener('message', handler) + resolve() + } + } catch {} + } + + this.ws!.addEventListener('message', handler) + this.sendNodeChanges(nodeChanges) + }) + } + + /** + * Generate unique local ID for new nodes + */ + nextLocalID(): number { + return this.localIDCounter++ + } + + /** + * Get current session info + */ + getSession(): SessionInfo | null { + return this.sessionInfo + } + + /** + * Check if connected and ready + */ + isReady(): boolean { + return this.state === 'ready' + } + + /** + * Check if connection is still alive + */ + isConnected(): boolean { + return this.state === 'connected' && this.ws !== null && this.ws.readyState === WebSocket.OPEN + } + + /** + * Close the connection + */ + close(): void { + if (this.ws) { + this.ws.close() + this.ws = null + } + this.state = 'disconnected' + this.sessionInfo = null + } +} + +// WebSocket with headers support (Bun/Node specific) +interface WebSocketInit { + headers?: Record +} + +/** + * Get Figma cookies from Chrome DevTools Protocol + * + * Requires Chrome/Figma to be running with: + * --remote-debugging-port=9222 + */ +export async function getCookiesFromDevTools(pageId?: string): Promise { + // Get list of pages if no ID provided + if (!pageId) { + const listResponse = await fetch('http://localhost:9222/json') + const targets = (await listResponse.json()) as ChromeDevToolsTarget[] + const figmaTarget = targets.find((t) => t.url.includes('figma.com')) + if (!figmaTarget) { + throw new Error('No Figma tab found. Open Figma in Chrome with --remote-debugging-port=9222') + } + pageId = figmaTarget.id + } + + const wsUrl = `ws://localhost:9222/devtools/page/${pageId}` + + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl) + + const timeout = setTimeout(() => { + ws.close() + reject(new Error('DevTools connection timeout')) + }, 5000) + + ws.onopen = () => { + ws.send( + JSON.stringify({ + id: 1, + method: 'Network.getCookies', + params: { urls: ['https://www.figma.com'] } + }) + ) + } + + ws.onmessage = (e) => { + clearTimeout(timeout) + const data = JSON.parse(e.data as string) + if (data.result?.cookies) { + ws.close() + const cookieString = data.result.cookies + .map((c: { name: string; value: string }) => `${c.name}=${c.value}`) + .join('; ') + resolve(cookieString) + } + } + + ws.onerror = () => { + clearTimeout(timeout) + reject( + new Error( + 'Cannot connect to Chrome DevTools. Is Chrome running with --remote-debugging-port=9222?' + ) + ) + } + }) +} + +/** + * Parse Figma file key from URL + */ +export function parseFileKey(urlOrKey: string): string { + if (!urlOrKey.includes('/')) { + return urlOrKey + } + const match = urlOrKey.match(/figma\.com\/(?:file|design)\/([a-zA-Z0-9]+)/) + if (!match?.[1]) { + throw new Error('Invalid Figma URL') + } + return match[1] +} diff --git a/src/kiwi/codec.ts b/src/kiwi/codec.ts new file mode 100644 index 000000000..027861724 --- /dev/null +++ b/src/kiwi/codec.ts @@ -0,0 +1,559 @@ +/** + * Message Encoding/Decoding for Figma Multiplayer + * + * Uses: + * - kiwi-schema: Binary serialization (by Evan Wallace, Figma co-founder) + * - Bun.zstd*: Native Zstd compression (built into Bun) + */ + +import { compileSchema, type Schema } from 'kiwi-schema' + +import { isZstdCompressed, getKiwiMessageType } from './protocol.ts' +import figmaSchema from './schema.ts' + +function parseColor(color: string): Color { + if (color.startsWith('#')) { + const hex = color.slice(1) + return { + r: parseInt(hex.slice(0, 2), 16) / 255, + g: parseInt(hex.slice(2, 4), 16) / 255, + b: parseInt(hex.slice(4, 6), 16) / 255, + a: hex.length > 6 ? parseInt(hex.slice(6, 8), 16) / 255 : 1 + } + } + return { r: 0, g: 0, b: 0, a: 1 } +} + +interface CompiledSchema { + encodeMessage(message: unknown): Uint8Array + decodeMessage(data: Uint8Array): unknown +} + +let compiledSchema: CompiledSchema | null = null + +/** + * Initialize the codec (compiles Kiwi schema) + */ +export async function initCodec(): Promise { + if (compiledSchema) return + compiledSchema = compileSchema(figmaSchema as Schema) as CompiledSchema +} + +/** + * Check if codec is initialized + */ +export function isCodecReady(): boolean { + return compiledSchema !== null +} + +/** + * Compress data using Zstd (Bun native) + */ +export function compress(data: Uint8Array): Uint8Array { + return Bun.zstdCompressSync(data) +} + +/** + * Decompress Zstd data (Bun native) + */ +export function decompress(data: Uint8Array): Uint8Array { + if (!isZstdCompressed(data)) return data + return Bun.zstdDecompressSync(data) +} + +/** + * Encode a message for sending to Figma + * Handles variable bindings in fillPaints which require custom encoding + */ +export function encodeMessage(message: FigmaMessage): Uint8Array { + if (!compiledSchema) { + throw new Error('Codec not initialized. Call initCodec() first.') + } + + // Check if any nodeChange has variable bindings (fill or stroke) + const hasVariables = message.nodeChanges?.some( + (nc) => + nc.fillPaints?.some((p) => p.colorVariableBinding) || + nc.strokePaints?.some((p) => p.colorVariableBinding) + ) + + if (!hasVariables) { + // Standard encoding + const encoded = compiledSchema.encodeMessage(message) + return compress(encoded) + } + + // Need custom encoding for variable bindings + // Strategy: encode each nodeChange separately, then combine + const messageWithoutNodes = { ...message, nodeChanges: [] } + const baseEncoded = compiledSchema.encodeMessage(messageWithoutNodes) + const baseHex = Buffer.from(baseEncoded).toString('hex') + + // Encode nodeChanges with variable support + const nodeChangeBytes: Uint8Array[] = [] + for (const nc of message.nodeChanges || []) { + const encoded = encodeNodeChangeWithVariables(nc) + nodeChangeBytes.push(encoded) + } + + // Combine: base message + nodeChanges + // Message structure: type, sessionID, ackID, reconnectSeqNum, nodeChanges[] + // nodeChanges is field 5 + + // Message structure in kiwi: + // - Field 1 (type): enum MessageType + // - Field 2 (sessionID): uint + // - Field 3 (ackID): uint + // - Field 4 (nodeChanges): NodeChange[] - this is what we need to replace + // - Field 25 (reconnectSequenceNumber): uint + // + // Empty array: "04 00" (field 4, length 0) + // We need to replace "04 00" with "04 " + + const emptyArrayPattern = '0400' // field 4, length 0 + const emptyArrayIdx = baseHex.indexOf(emptyArrayPattern) + + if (emptyArrayIdx === -1) { + // Fallback to standard encoding + const encoded = compiledSchema.encodeMessage(message) + return compress(encoded) + } + + // Build nodeChanges array with our encoded nodes + const ncBytes: number[] = [0x04] // field 4 + ncBytes.push(...encodeVarint(nodeChangeBytes.length)) // array length + for (const ncArr of nodeChangeBytes) { + ncBytes.push(...Array.from(ncArr)) + } + + // Replace "0400" with our nodeChanges + const beforeArray = baseHex.slice(0, emptyArrayIdx) + const afterArray = baseHex.slice(emptyArrayIdx + 4) // skip "0400" + + const ncHex = Buffer.from(ncBytes).toString('hex') + const finalHex = beforeArray + ncHex + afterArray + + const finalBytes = new Uint8Array(finalHex.match(/.{2}/g)!.map((b) => parseInt(b, 16))) + return compress(finalBytes) +} + +/** + * Decode a message received from Figma + */ +export function decodeMessage(data: Uint8Array): FigmaMessage { + if (!compiledSchema) { + throw new Error('Codec not initialized. Call initCodec() first.') + } + + const decompressed = decompress(data) + return compiledSchema.decodeMessage(decompressed) as FigmaMessage +} + +/** + * Quick peek at message type without full decoding + */ +export function peekMessageType(data: Uint8Array): number | null { + try { + const decompressed = decompress(data) + return getKiwiMessageType(decompressed) + } catch { + return null + } +} + +// Type definitions + +export interface GUID { + sessionID: number + localID: number +} + +export interface Color { + r: number + g: number + b: number + a: number +} + +export interface Vector { + x: number + y: number +} + +export interface Matrix { + m00: number + m01: number + m02: number + m10: number + m11: number + m12: number +} + +export interface ParentIndex { + guid: GUID + position: string +} + +export interface VariableBinding { + variableID: GUID +} + +export interface Paint { + type: 'SOLID' | 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'IMAGE' + color?: Color + opacity?: number + visible?: boolean + blendMode?: string + colorVariableBinding?: VariableBinding // Binds color to a Figma variable +} + +export interface Effect { + type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'BACKGROUND_BLUR' | 'FOREGROUND_BLUR' + color?: Color + offset?: Vector + radius?: number + visible?: boolean + spread?: number +} + +export interface NodeChange { + guid: GUID + phase?: 'CREATED' | 'REMOVED' + parentIndex?: ParentIndex + type?: string + name?: string + visible?: boolean + locked?: boolean + opacity?: number + blendMode?: string + size?: Vector + transform?: Matrix + cornerRadius?: number + fillPaints?: Paint[] + strokePaints?: Paint[] + strokeWeight?: number + strokeAlign?: string + effects?: Effect[] + // Layout + stackMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL' + stackSpacing?: number + stackPadding?: number + stackPaddingRight?: number + stackPaddingBottom?: number + stackCounterAlign?: string + stackJustify?: string + stackCounterAlignItems?: string + stackPrimaryAlignItems?: string + stackPrimarySizing?: 'FIXED' | 'RESIZE_TO_FIT' + stackCounterSizing?: 'FIXED' | 'RESIZE_TO_FIT' + stackVerticalPadding?: number + stackHorizontalPadding?: number + // Frame + clipsContent?: boolean + // Text + fontSize?: number + fontName?: { family: string; style: string; postscript?: string } + textAlignHorizontal?: string + textAlignVertical?: string + textAutoResize?: string + textData?: { characters: string } + lineHeight?: { value: number; units: string } + letterSpacing?: { value: number; units: string } + // Symbol/Instance + symbolData?: { symbolID: { sessionID: number; localID: number } } + // ComponentSet + isStateGroup?: boolean + stateGroupPropertyValueOrders?: Array<{ property: string; values: string[] }> + // Corners + rectangleTopLeftCornerRadius?: number + rectangleTopRightCornerRadius?: number + rectangleBottomLeftCornerRadius?: number + rectangleBottomRightCornerRadius?: number + rectangleCornerRadiiIndependent?: boolean + cornerSmoothing?: number + // Constraints + horizontalConstraint?: string + verticalConstraint?: string +} + +export interface FigmaMessage { + type: string + sessionID?: number + ackID?: number + reconnectSequenceNumber?: number + nodeChanges?: NodeChange[] +} + +/** + * Create a NODE_CHANGES message + */ +export function createNodeChangesMessage( + sessionID: number, + reconnectSequenceNumber: number, + nodeChanges: NodeChange[], + ackID = 1 +): FigmaMessage { + return { + type: 'NODE_CHANGES', + sessionID, + ackID, + reconnectSequenceNumber, + nodeChanges + } +} + +/** + * Create a node change for a new shape + */ +export function createNodeChange(opts: { + sessionID: number + localID: number + parentSessionID: number + parentLocalID: number + position?: string + type: string + name: string + x: number + y: number + width: number + height: number + fill?: Color | string + stroke?: Color | string + strokeWeight?: number + cornerRadius?: number + opacity?: number +}): NodeChange { + const change: NodeChange = { + guid: { sessionID: opts.sessionID, localID: opts.localID }, + phase: 'CREATED', + parentIndex: { + guid: { sessionID: opts.parentSessionID, localID: opts.parentLocalID }, + position: opts.position || '!' + }, + type: opts.type, + name: opts.name, + visible: true, + opacity: opts.opacity ?? 1.0, + size: { x: opts.width, y: opts.height }, + transform: { + m00: 1, + m01: 0, + m02: opts.x, + m10: 0, + m11: 1, + m12: opts.y + } + } + + if (opts.fill) { + const color = typeof opts.fill === 'string' ? parseColor(opts.fill) : opts.fill + change.fillPaints = [ + { + type: 'SOLID', + color, + opacity: 1.0, + visible: true, + blendMode: 'NORMAL' + } + ] + } + + if (opts.stroke) { + const color = typeof opts.stroke === 'string' ? parseColor(opts.stroke) : opts.stroke + change.strokePaints = [ + { + type: 'SOLID', + color, + opacity: 1.0, + visible: true, + blendMode: 'NORMAL' + } + ] + change.strokeWeight = opts.strokeWeight ?? 1 + } + + if (opts.cornerRadius !== undefined) { + change.cornerRadius = opts.cornerRadius + } + + return change +} + +/** + * Encode a varint (variable-length integer) + */ +function encodeVarint(value: number): number[] { + const bytes: number[] = [] + while (value > 0x7f) { + bytes.push((value & 0x7f) | 0x80) + value >>>= 7 + } + bytes.push(value) + return bytes +} + +/** + * Encode a Paint with optional variable binding + * + * Figma's variable binding format (discovered via WS traffic analysis): + * - Field 21 = 1 (binding type) + * - Field 4 = 1 (flag) + * - Raw sessionID varint (no field number) + * - Raw localID varint (no field number) + * - Terminators: 00 00 02 03 03 04 00 00 + */ +export function encodePaintWithVariableBinding( + paint: Paint, + variableSessionID: number, + variableLocalID: number +): Uint8Array { + if (!compiledSchema) { + throw new Error('Codec not initialized. Call initCodec() first.') + } + + // Encode base paint without variable binding + const basePaint = { ...paint } + delete (basePaint as any).colorVariableBinding + + const baseBytes = (compiledSchema as any).encodePaint(basePaint) + const baseArray = Array.from(baseBytes) as number[] + + // Remove trailing 00 + if (baseArray[baseArray.length - 1] === 0) { + baseArray.pop() + } + + // Add variable binding in Figma's exact format: + // Field 21 (0x15) = 1 (binding type) + baseArray.push(0x15, 0x01) + // Field 4 = 1 (flag) + baseArray.push(0x04, 0x01) + // Raw varints: sessionID, localID (no field numbers!) + baseArray.push(...encodeVarint(variableSessionID)) + baseArray.push(...encodeVarint(variableLocalID)) + // Terminators observed in Figma traffic + baseArray.push(0x00, 0x00, 0x02, 0x03, 0x03, 0x04) + // Final terminators + baseArray.push(0x00, 0x00) + + return new Uint8Array(baseArray) +} + +/** + * Parse a variable ID string (e.g., "VariableID:38448:122296") + * Returns sessionID and localID + */ +export function parseVariableId(variableId: string): { sessionID: number; localID: number } | null { + const match = variableId.match(/VariableID:(\d+):(\d+)/) + if (!match) return null + return { + sessionID: parseInt(match[1]!, 10), + localID: parseInt(match[2]!, 10) + } +} + +/** + * Encode a NodeChange with variable bindings in fillPaints and/or strokePaints + * This is needed because kiwi-schema cannot produce Figma's exact variable binding format + */ +export function encodeNodeChangeWithVariables(nodeChange: NodeChange): Uint8Array { + if (!compiledSchema) { + throw new Error('Codec not initialized. Call initCodec() first.') + } + + const hasFillBinding = nodeChange.fillPaints?.some((p) => p.colorVariableBinding) + const hasStrokeBinding = nodeChange.strokePaints?.some((p) => p.colorVariableBinding) + + if (!hasFillBinding && !hasStrokeBinding) { + return (compiledSchema as any).encodeNodeChange(nodeChange) + } + + // Create a copy without variable bindings for base encoding + const cleanNodeChange = { ...nodeChange } + if (cleanNodeChange.fillPaints) { + cleanNodeChange.fillPaints = cleanNodeChange.fillPaints.map((p) => { + const clean = { ...p } + delete (clean as any).colorVariableBinding + return clean + }) + } + if (cleanNodeChange.strokePaints) { + cleanNodeChange.strokePaints = cleanNodeChange.strokePaints.map((p) => { + const clean = { ...p } + delete (clean as any).colorVariableBinding + return clean + }) + } + + // Encode clean version + const baseBytes = (compiledSchema as any).encodeNodeChange(cleanNodeChange) + let hex = Buffer.from(baseBytes).toString('hex') + + // Inject fill variable binding (field 38 = 0x26) + const fillBinding = nodeChange.fillPaints?.[0]?.colorVariableBinding + if (hasFillBinding && fillBinding) { + hex = injectVariableBinding(hex, '2601', fillBinding) + } + + // Inject stroke variable binding (field 39 = 0x27) + const strokeBinding = nodeChange.strokePaints?.[0]?.colorVariableBinding + if (hasStrokeBinding && strokeBinding) { + hex = injectVariableBinding(hex, '2701', strokeBinding) + } + + return new Uint8Array(hex.match(/.{2}/g)!.map((b) => parseInt(b, 16))) +} + +/** + * Inject variable binding into a paint at the specified marker + */ +function injectVariableBinding( + hex: string, + marker: string, + binding: { variableID: { sessionID: number; localID: number } } +): string { + const markerIdx = hex.indexOf(marker) + if (markerIdx === -1) return hex + + // Find visible=true pattern (04 01) after the marker + const visiblePattern = '0401' + let patternIdx = hex.indexOf(visiblePattern, markerIdx) + if (patternIdx === -1) return hex + + // Move past 0401 to find where to insert + let insertPoint = patternIdx + visiblePattern.length + + // Check if blendMode follows (05 01) + if (hex.slice(insertPoint, insertPoint + 4) === '0501') { + insertPoint += 4 + } + + // Build variable binding bytes + const varBytes = [ + 0x15, + 0x01, // Field 21 (variableBinding) = 1 + 0x04, + 0x01, // Nested field 4 = 1 + ...encodeVarint(binding.variableID.sessionID), + ...encodeVarint(binding.variableID.localID), + 0x00, + 0x00, + 0x02, + 0x03, + 0x03, + 0x04, + 0x00, + 0x00 // Terminators + ] + const varHex = Buffer.from(varBytes).toString('hex') + + const beforeVar = hex.slice(0, insertPoint) + // Skip original paint terminator (00) - our varHex includes terminators + let afterIdx = insertPoint + if (hex.slice(afterIdx, afterIdx + 2) === '00') { + afterIdx += 2 + } + const afterVar = hex.slice(afterIdx) + + return beforeVar + varHex + afterVar +} diff --git a/src/kiwi/index.ts b/src/kiwi/index.ts new file mode 100644 index 000000000..2ff2b124d --- /dev/null +++ b/src/kiwi/index.ts @@ -0,0 +1,70 @@ +/** + * Figma Multiplayer Module + * + * Direct WebSocket access to Figma's multiplayer protocol. + * Enables creating nodes 1000-5000x faster than the plugin API. + * + * @example + * ```ts + * import { FigmaMultiplayerClient, getCookiesFromDevTools } from './multiplayer' + * + * const cookies = await getCookiesFromDevTools() + * const client = new FigmaMultiplayerClient('fileKey') + * await client.connect(cookies) + * + * await client.createNodes([{ + * guid: { sessionID: client.getSessionInfo()!.sessionID, localID: 1 }, + * phase: 'CREATED', + * type: 'RECTANGLE', + * name: 'My Rect', + * // ... + * }]) + * + * client.close() + * ``` + */ + +export { + FigmaMultiplayerClient, + getCookiesFromDevTools, + parseFileKey, + type SessionInfo, + type ConnectionOptions +} from './client.ts' + +export { + initCodec, + encodeMessage, + decodeMessage, + createNodeChangesMessage, + createNodeChange, + parseVariableId, + encodePaintWithVariableBinding, + encodeNodeChangeWithVariables, + type GUID, + type Color, + type Paint, + type VariableBinding, + type ParentIndex, + type NodeChange, + type FigmaMessage +} from './codec.ts' + +export { + MESSAGE_TYPES, + NODE_TYPES, + NODE_PHASES, + BLEND_MODES, + PAINT_TYPES, + PROTOCOL_VERSION, + KIWI, + SESSION_ID, + ZSTD_MAGIC, + buildMultiplayerUrl, + isZstdCompressed, + hasFigWireHeader, + skipFigWireHeader, + isKiwiMessage, + getKiwiMessageType, + parseVarint +} from './protocol.ts' diff --git a/src/kiwi/poc-test.ts b/src/kiwi/poc-test.ts new file mode 100644 index 000000000..3be08d222 --- /dev/null +++ b/src/kiwi/poc-test.ts @@ -0,0 +1,180 @@ +/** + * PoC 3: Kiwi codec round-trip test + * + * Validates: + * 1. Schema compiles (194 definitions, 2178 lines) + * 2. Encode a NODE_CHANGES message with shapes + * 3. Decode it back — verify data integrity + * 4. Zstd compress/decompress round-trip + */ + +import { + initCodec, + encodeMessage, + decodeMessage, + createNodeChange, + createNodeChangesMessage +} from './codec.ts' + +import type { NodeChange } from './codec.ts' + +async function runTest() { + console.log('=== PoC 3: Kiwi Codec Round-Trip ===\n') + + // 1. Init codec (compiles schema) + const t0 = performance.now() + await initCodec() + console.log(`✅ Schema compiled in ${(performance.now() - t0).toFixed(1)}ms`) + + // 2. Create test node changes + const sessionID = 99999 + const changes: NodeChange[] = [ + createNodeChange({ + sessionID, + localID: 1, + parentSessionID: 0, + parentLocalID: 0, + type: 'FRAME', + name: 'Test Frame', + x: 0, + y: 0, + width: 800, + height: 600, + fill: '#FFFFFF' + }), + createNodeChange({ + sessionID, + localID: 2, + parentSessionID: sessionID, + parentLocalID: 1, + type: 'RECTANGLE', + name: 'Blue Card', + x: 50, + y: 50, + width: 200, + height: 150, + fill: '#3B82F6', + cornerRadius: 12 + }), + createNodeChange({ + sessionID, + localID: 3, + parentSessionID: sessionID, + parentLocalID: 1, + type: 'ELLIPSE', + name: 'Green Circle', + x: 300, + y: 80, + width: 100, + height: 100, + fill: '#22C55E' + }), + createNodeChange({ + sessionID, + localID: 4, + parentSessionID: sessionID, + parentLocalID: 1, + type: 'TEXT', + name: 'Hello World', + x: 50, + y: 250, + width: 200, + height: 40, + fill: '#000000' + }) + ] + + console.log(`✅ Created ${changes.length} node changes`) + + // 3. Encode + const message = createNodeChangesMessage(sessionID, 1, changes) + const t1 = performance.now() + const encoded = encodeMessage(message) + const encodeTime = performance.now() - t1 + console.log(`✅ Encoded: ${encoded.byteLength} bytes (${encodeTime.toFixed(2)}ms)`) + console.log( + ` Zstd compressed: starts with 0x${encoded[0]?.toString(16)}${encoded[1]?.toString(16)}${encoded[2]?.toString(16)}${encoded[3]?.toString(16)}` + ) + + // 4. Decode + const t2 = performance.now() + const decoded = decodeMessage(encoded) + const decodeTime = performance.now() - t2 + console.log(`✅ Decoded in ${decodeTime.toFixed(2)}ms`) + + // 5. Verify round-trip + const nc = decoded.nodeChanges + if (!nc || nc.length !== changes.length) { + console.error(`❌ Node count mismatch: expected ${changes.length}, got ${nc?.length}`) + process.exit(1) + } + + for (let i = 0; i < nc.length; i++) { + const original = changes[i]! + const roundtripped = nc[i]! + + // Verify GUID + if ( + roundtripped.guid?.sessionID !== original.guid.sessionID || + roundtripped.guid?.localID !== original.guid.localID + ) { + console.error(`❌ Node ${i} GUID mismatch`) + process.exit(1) + } + + // Verify name + if (roundtripped.name !== original.name) { + console.error(`❌ Node ${i} name mismatch: "${roundtripped.name}" !== "${original.name}"`) + process.exit(1) + } + + // Verify type + if (roundtripped.type !== original.type) { + console.error(`❌ Node ${i} type mismatch: "${roundtripped.type}" !== "${original.type}"`) + process.exit(1) + } + + // Verify size + if (roundtripped.size?.x !== original.size?.x || roundtripped.size?.y !== original.size?.y) { + console.error(`❌ Node ${i} size mismatch`) + process.exit(1) + } + + // Verify transform (position) + if ( + roundtripped.transform?.m02 !== original.transform?.m02 || + roundtripped.transform?.m12 !== original.transform?.m12 + ) { + console.error(`❌ Node ${i} position mismatch`) + process.exit(1) + } + + // Verify fill + if (original.fillPaints && original.fillPaints.length > 0) { + const origFill = original.fillPaints[0]! + const rtFill = roundtripped.fillPaints?.[0] + if (!rtFill) { + console.error(`❌ Node ${i} missing fill`) + process.exit(1) + } + if (rtFill.type !== origFill.type) { + console.error(`❌ Node ${i} fill type mismatch`) + process.exit(1) + } + } + + console.log( + ` ✓ Node ${i}: ${roundtripped.name} (${roundtripped.type}) @ ${roundtripped.transform?.m02},${roundtripped.transform?.m12} [${roundtripped.size?.x}×${roundtripped.size?.y}]` + ) + } + + console.log(`\n✅ PoC 3 PASSED: ${changes.length} nodes round-tripped through Kiwi+Zstd`) + console.log(` Schema: 194 definitions`) + console.log(` Encode: ${encodeTime.toFixed(2)}ms → ${encoded.byteLength} bytes`) + console.log(` Decode: ${decodeTime.toFixed(2)}ms`) +} + +runTest().catch((e) => { + console.error('❌ PoC 3 FAILED:', e) + process.exit(1) +}) diff --git a/src/kiwi/protocol.ts b/src/kiwi/protocol.ts new file mode 100644 index 000000000..d44aee9c9 --- /dev/null +++ b/src/kiwi/protocol.ts @@ -0,0 +1,238 @@ +/** + * Figma Multiplayer Protocol + * + * This module handles the low-level WebSocket communication with Figma's + * multiplayer server. The protocol uses: + * + * - Kiwi binary serialization (schema-based, like Protocol Buffers) + * - Zstd compression for all messages + * - Session-based authentication via cookies + * + * Message types (from Figma's schema): + * 0 = JOIN_START - Server sends session info + * 1 = NODE_CHANGES - Create/update/delete nodes + * 2 = USER_CHANGES - User presence updates + * 3 = JOIN_END - Initial sync complete + * 4 = SIGNAL - Various metadata (reconnect info, etc.) + * 5 = STYLE - Style updates + * ...and more + * + * Wire format: + * All messages are Zstd-compressed Kiwi-encoded binary data. + * Zstd magic bytes: 0x28 0xB5 0x2F 0xFD + */ + +export const MESSAGE_TYPES = { + JOIN_START: 0, + NODE_CHANGES: 1, + USER_CHANGES: 2, + JOIN_END: 3, + SIGNAL: 4, + STYLE: 5, + STYLE_SET: 6, + JOIN_START_SKIP_RELOAD: 7, + NOTIFY_SHOULD_UPGRADE: 8, + UPGRADE_DONE: 9, + UPGRADE_REFRESH: 10, + SCENE_GRAPH_QUERY: 11, + SCENE_GRAPH_REPLY: 12, + DIFF: 13, + CLIENT_BROADCAST: 14 +} as const + +export const NODE_TYPES = { + NONE: 0, + DOCUMENT: 1, + CANVAS: 2, + GROUP: 3, + FRAME: 4, + BOOLEAN_OPERATION: 5, + VECTOR: 6, + STAR: 7, + LINE: 8, + ELLIPSE: 9, + RECTANGLE: 10, + REGULAR_POLYGON: 11, + ROUNDED_RECTANGLE: 12, + TEXT: 13, + SLICE: 14, + SYMBOL: 15, + INSTANCE: 16, + STICKY: 17, + SHAPE_WITH_TEXT: 18, + CONNECTOR: 19, + CODE_BLOCK: 20, + WIDGET: 21, + STAMP: 22, + MEDIA: 23, + HIGHLIGHT: 24, + SECTION: 25, + SECTION_OVERLAY: 26, + WASHI_TAPE: 27, + VARIABLE: 28 +} as const + +export const NODE_PHASES = { + CREATED: 0, + REMOVED: 1 +} as const + +export const BLEND_MODES = { + PASS_THROUGH: 0, + NORMAL: 1, + DARKEN: 2, + MULTIPLY: 3, + LINEAR_BURN: 4, + COLOR_BURN: 5, + LIGHTEN: 6, + SCREEN: 7, + LINEAR_DODGE: 8, + COLOR_DODGE: 9, + OVERLAY: 10, + SOFT_LIGHT: 11, + HARD_LIGHT: 12, + DIFFERENCE: 13, + EXCLUSION: 14, + HUE: 15, + SATURATION: 16, + COLOR: 17, + LUMINOSITY: 18 +} as const + +export const PAINT_TYPES = { + SOLID: 0, + GRADIENT_LINEAR: 1, + GRADIENT_RADIAL: 2, + GRADIENT_ANGULAR: 3, + GRADIENT_DIAMOND: 4, + IMAGE: 5, + EMOJI: 6, + VIDEO: 7 +} as const + +/** + * Zstd magic bytes + */ +export const ZSTD_MAGIC = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd]) + +// ============================================================================ +// Kiwi Binary Format Constants +// ============================================================================ + +/** + * Kiwi uses field numbers to identify message fields. + * Field 1 with value = message type indicates the message kind. + */ +export const KIWI = { + /** First byte of valid Kiwi messages (field number 1) */ + MESSAGE_MARKER: 1, + + /** Field number for sessionID in JOIN_START message */ + SESSION_ID_FIELD: 2, + + /** Varint continuation bit (MSB set = more bytes follow) */ + VARINT_CONTINUE_BIT: 0x80, + + /** Varint value mask (lower 7 bits contain data) */ + VARINT_VALUE_MASK: 0x7f, + + /** Bits per varint byte */ + VARINT_BITS_PER_BYTE: 7 +} as const + +/** + * Valid session ID range (based on observed Figma behavior) + */ +export const SESSION_ID = { + MIN: 10000, + MAX: 1000000 +} as const + +/** + * Parse a varint from a Uint8Array at given position + * Returns [value, newPosition] + */ +export function parseVarint(data: Uint8Array, pos: number): [number, number] { + let value = 0 + let shift = 0 + + while (pos < data.length) { + const byte = data[pos] + if (byte === undefined) break + pos++ + value |= (byte & KIWI.VARINT_VALUE_MASK) << shift + + if (!(byte & KIWI.VARINT_CONTINUE_BIT)) { + break + } + shift += KIWI.VARINT_BITS_PER_BYTE + } + + return [value, pos] +} + +/** + * Check if data is a valid Kiwi message + */ +export function isKiwiMessage(data: Uint8Array): boolean { + return data.length >= 2 && data[0] === KIWI.MESSAGE_MARKER +} + +/** + * Get message type from Kiwi message + */ +export function getKiwiMessageType(data: Uint8Array): number | null { + if (!isKiwiMessage(data)) return null + return data[1] ?? null +} + +/** + * fig-wire header magic (first 8 bytes of some messages) + */ +export const FIG_WIRE_MAGIC = 'fig-wire' + +/** + * Check if data is Zstd-compressed + */ +export function isZstdCompressed(data: Uint8Array): boolean { + return ( + data.length >= 4 && data[0] === 0x28 && data[1] === 0xb5 && data[2] === 0x2f && data[3] === 0xfd + ) +} + +/** + * Check if data has fig-wire header + */ +export function hasFigWireHeader(data: Uint8Array): boolean { + if (data.length < 8) return false + const header = new TextDecoder().decode(data.slice(0, 8)) + return header === FIG_WIRE_MAGIC +} + +/** + * Skip fig-wire header and find zstd data + * Header format: "fig-wire" (8 bytes) + version (4 bytes LE) + zstd data + */ +export function skipFigWireHeader(data: Uint8Array): Uint8Array { + if (!hasFigWireHeader(data)) return data + // Skip 8 bytes header + 4 bytes version + return data.slice(12) +} + +/** + * Current multiplayer protocol version + */ +export const PROTOCOL_VERSION = 151 + +/** + * Build WebSocket URL for Figma multiplayer + */ +export function buildMultiplayerUrl(fileKey: string, trackingId?: string): string { + const params = new URLSearchParams({ + role: 'editor', + version: String(PROTOCOL_VERSION), + recentReload: '0', + tracking_session_id: trackingId || `ws-${Date.now()}` + }) + return `wss://www.figma.com/api/multiplayer/${fileKey}?${params}` +} diff --git a/src/kiwi/schema.ts b/src/kiwi/schema.ts new file mode 100644 index 000000000..20120c876 --- /dev/null +++ b/src/kiwi/schema.ts @@ -0,0 +1,2178 @@ +import { parseSchema } from 'kiwi-schema' + +const schemaText = ` +package Fig; + +enum MessageType { + JOIN_START = 0; + NODE_CHANGES = 1; + USER_CHANGES = 2; + JOIN_END = 3; + SIGNAL = 4; + STYLE = 5; + STYLE_SET = 6; + JOIN_START_SKIP_RELOAD = 7; + NOTIFY_SHOULD_UPGRADE = 8; + UPGRADE_DONE = 9; + UPGRADE_REFRESH = 10; + SCENE_GRAPH_QUERY = 11; + SCENE_GRAPH_REPLY = 12; + DIFF = 13; + CLIENT_BROADCAST = 14; +} + +enum Axis { + X = 0; + Y = 1; +} + +enum Access { + READ_ONLY = 0; + READ_WRITE = 1; +} + +enum NodePhase { + CREATED = 0; + REMOVED = 1; +} + +enum WindingRule { + NONZERO = 0; + ODD = 1; +} + +enum NodeType { + NONE = 0; + DOCUMENT = 1; + CANVAS = 2; + GROUP = 3; + FRAME = 4; + BOOLEAN_OPERATION = 5; + VECTOR = 6; + STAR = 7; + LINE = 8; + ELLIPSE = 9; + RECTANGLE = 10; + REGULAR_POLYGON = 11; + ROUNDED_RECTANGLE = 12; + TEXT = 13; + SLICE = 14; + SYMBOL = 15; + INSTANCE = 16; + STICKY = 17; + SHAPE_WITH_TEXT = 18; + CONNECTOR = 19; + CODE_BLOCK = 20; + WIDGET = 21; + STAMP = 22; + MEDIA = 23; + HIGHLIGHT = 24; + SECTION = 25; + SECTION_OVERLAY = 26; + WASHI_TAPE = 27; + VARIABLE = 28; +} + +enum ShapeWithTextType { + SQUARE = 0; + ELLIPSE = 1; + DIAMOND = 2; + TRIANGLE_UP = 3; + TRIANGLE_DOWN = 4; + ROUNDED_RECTANGLE = 5; + PARALLELOGRAM_RIGHT = 6; + PARALLELOGRAM_LEFT = 7; + ENG_DATABASE = 8; + ENG_QUEUE = 9; + ENG_FILE = 10; + ENG_FOLDER = 11; +} + +enum BlendMode { + PASS_THROUGH = 0; + NORMAL = 1; + DARKEN = 2; + MULTIPLY = 3; + LINEAR_BURN = 4; + COLOR_BURN = 5; + LIGHTEN = 6; + SCREEN = 7; + LINEAR_DODGE = 8; + COLOR_DODGE = 9; + OVERLAY = 10; + SOFT_LIGHT = 11; + HARD_LIGHT = 12; + DIFFERENCE = 13; + EXCLUSION = 14; + HUE = 15; + SATURATION = 16; + COLOR = 17; + LUMINOSITY = 18; +} + +enum PaintType { + SOLID = 0; + GRADIENT_LINEAR = 1; + GRADIENT_RADIAL = 2; + GRADIENT_ANGULAR = 3; + GRADIENT_DIAMOND = 4; + IMAGE = 5; + EMOJI = 6; +} + +enum ImageScaleMode { + STRETCH = 0; + FIT = 1; + FILL = 2; + TILE = 3; +} + +enum EffectType { + INNER_SHADOW = 0; + DROP_SHADOW = 1; + FOREGROUND_BLUR = 2; + BACKGROUND_BLUR = 3; +} + +enum TextCase { + ORIGINAL = 0; + UPPER = 1; + LOWER = 2; + TITLE = 3; + SMALL_CAPS = 4; + SMALL_CAPS_FORCED = 5; +} + +enum TextDecoration { + NONE = 0; + UNDERLINE = 1; + STRIKETHROUGH = 2; +} + +enum NumberUnits { + RAW = 0; + PIXELS = 1; + PERCENT = 2; +} + +enum ConstraintType { + MIN = 0; + CENTER = 1; + MAX = 2; + STRETCH = 3; + SCALE = 4; + FIXED_MIN = 5; + FIXED_MAX = 6; +} + +enum StrokeAlign { + CENTER = 0; + INSIDE = 1; + OUTSIDE = 2; +} + +enum StrokeCap { + NONE = 0; + ROUND = 1; + SQUARE = 2; + ARROW_LINES = 3; + ARROW_EQUILATERAL = 4; + DIAMOND_FILLED = 5; + TRIANGLE_FILLED = 6; + HIGHLIGHT = 7; + WASHI_TAPE_1 = 8; + WASHI_TAPE_2 = 9; + WASHI_TAPE_3 = 10; + WASHI_TAPE_4 = 11; + WASHI_TAPE_5 = 12; + WASHI_TAPE_6 = 13; + CIRCLE_FILLED = 14; +} + +enum StrokeJoin { + MITER = 0; + BEVEL = 1; + ROUND = 2; +} + +enum BooleanOperation { + UNION = 0; + INTERSECT = 1; + SUBTRACT = 2; + XOR = 3; +} + +enum TextAlignHorizontal { + LEFT = 0; + CENTER = 1; + RIGHT = 2; + JUSTIFIED = 3; +} + +enum TextAlignVertical { + TOP = 0; + CENTER = 1; + BOTTOM = 2; +} + +enum MouseCursor { + DEFAULT = 0; + CROSSHAIR = 1; + EYEDROPPER = 2; + HAND = 3; + PAINT_BUCKET = 4; + PEN = 5; + PENCIL = 6; + MARKER = 7; + ERASER = 8; + HIGHLIGHTER = 9; +} + +enum VectorMirror { + NONE = 0; + ANGLE = 1; + ANGLE_AND_LENGTH = 2; +} + +enum DashMode { + CLIP = 0; + STRETCH = 1; +} + +enum ImageType { + PNG = 0; + JPEG = 1; + SVG = 2; + PDF = 3; +} + +enum ExportConstraintType { + CONTENT_SCALE = 0; + CONTENT_WIDTH = 1; + CONTENT_HEIGHT = 2; +} + +enum LayoutGridType { + MIN = 0; + CENTER = 1; + STRETCH = 2; + MAX = 3; +} + +enum LayoutGridPattern { + STRIPES = 0; + GRID = 1; +} + +enum TextAutoResize { + NONE = 0; + WIDTH_AND_HEIGHT = 1; + HEIGHT = 2; +} + +enum TextTruncation { + DISABLED = 0; + ENDING = 1; +} + +enum StyleSetType { + PERSONAL = 0; + TEAM = 1; + CUSTOM = 2; + FREQUENCY = 3; + TEMPORARY = 4; +} + +enum StyleSetContentType { + SOLID = 0; + GRADIENT = 1; + IMAGE = 2; +} + +enum StackMode { + NONE = 0; + HORIZONTAL = 1; + VERTICAL = 2; + GRID = 3; +} + +enum GridTrackSizeType { + FIXED = 0; + FLEX = 1; + HUG = 2; +} + +message GridTrackSize { + GridTrackSizeType type = 1; + float value = 2; +} + +enum StackAlign { + MIN = 0; + CENTER = 1; + MAX = 2; + BASELINE = 3; +} + +enum StackCounterAlign { + MIN = 0; + CENTER = 1; + MAX = 2; + STRETCH = 3; + AUTO = 4; + BASELINE = 5; +} + +enum StackJustify { + MIN = 0; + CENTER = 1; + MAX = 2; + SPACE_BETWEEN = 3; +} + +enum StackSize { + FIXED = 0; + RESIZE_TO_FIT = 1; + RESIZE_TO_FIT_WITH_IMPLICIT_SIZE = 2; +} + +enum StackPositioning { + AUTO = 0; + ABSOLUTE = 1; +} + +enum ConnectionType { + NONE = 0; + INTERNAL_NODE = 1; + URL = 2; + BACK = 3; + CLOSE = 4; + SET_VARIABLE = 5; +} + +enum InteractionType { + ON_CLICK = 0; + AFTER_TIMEOUT = 1; + MOUSE_IN = 2; + MOUSE_OUT = 3; + ON_HOVER = 4; + MOUSE_DOWN = 5; + MOUSE_UP = 6; + ON_PRESS = 7; + NONE = 8; + DRAG = 9; + ON_KEY_DOWN = 10; + ON_VOICE = 11; +} + +enum TransitionType { + INSTANT_TRANSITION = 0; + DISSOLVE = 1; + FADE = 2; + SLIDE_FROM_LEFT = 3; + SLIDE_FROM_RIGHT = 4; + SLIDE_FROM_TOP = 5; + SLIDE_FROM_BOTTOM = 6; + PUSH_FROM_LEFT = 7; + PUSH_FROM_RIGHT = 8; + PUSH_FROM_TOP = 9; + PUSH_FROM_BOTTOM = 10; + MOVE_FROM_LEFT = 11; + MOVE_FROM_RIGHT = 12; + MOVE_FROM_TOP = 13; + MOVE_FROM_BOTTOM = 14; + SLIDE_OUT_TO_LEFT = 15; + SLIDE_OUT_TO_RIGHT = 16; + SLIDE_OUT_TO_TOP = 17; + SLIDE_OUT_TO_BOTTOM = 18; + MOVE_OUT_TO_LEFT = 19; + MOVE_OUT_TO_RIGHT = 20; + MOVE_OUT_TO_TOP = 21; + MOVE_OUT_TO_BOTTOM = 22; + MAGIC_MOVE = 23; + SMART_ANIMATE = 24; + SCROLL_ANIMATE = 25; +} + +enum EasingType { + IN_CUBIC = 0; + OUT_CUBIC = 1; + INOUT_CUBIC = 2; + LINEAR = 3; + IN_BACK_CUBIC = 4; + OUT_BACK_CUBIC = 5; + INOUT_BACK_CUBIC = 6; + CUSTOM_CUBIC = 7; + SPRING = 8; + GENTLE_SPRING = 9; + CUSTOM_SPRING = 10; + SPRING_PRESET_ONE = 11; + SPRING_PRESET_TWO = 12; + SPRING_PRESET_THREE = 13; +} + +enum ScrollDirection { + NONE = 0; + HORIZONTAL = 1; + VERTICAL = 2; + BOTH = 3; +} + +enum ScrollContractedState { + EXPANDED = 0; + CONTRACTED = 1; +} + +struct GUID { + uint sessionID; + uint localID; +} + +struct Color { + float r; + float g; + float b; + float a; +} + +struct Vector { + float x; + float y; +} + +struct Rect { + float x; + float y; + float w; + float h; +} + +struct ColorStop { + Color color; + float position; +} + +struct Matrix { + float m00; + float m01; + float m02; + float m10; + float m11; + float m12; +} + +struct ParentIndex { + GUID guid; + string position; +} + +struct Number { + float value; + NumberUnits units; +} + +struct FontName { + string family; + string style; + string postscript; +} + +enum FontVariantNumericFigure { + NORMAL = 0; + LINING = 1; + OLDSTYLE = 2; +} + +enum FontVariantNumericSpacing { + NORMAL = 0; + PROPORTIONAL = 1; + TABULAR = 2; +} + +enum FontVariantNumericFraction { + NORMAL = 0; + DIAGONAL = 1; + STACKED = 2; +} + +enum FontVariantCaps { + NORMAL = 0; + SMALL = 1; + ALL_SMALL = 2; + PETITE = 3; + ALL_PETITE = 4; + UNICASE = 5; + TITLING = 6; +} + +enum FontVariantPosition { + NORMAL = 0; + SUB = 1; + SUPER = 2; +} + +enum FontStyle { + NORMAL = 0; + ITALIC = 1; +} + +enum OpenTypeFeature { + PCAP = 0; + C2PC = 1; + CASE = 2; + CPSP = 3; + TITL = 4; + UNIC = 5; + ZERO = 6; + SINF = 7; + ORDN = 8; + AFRC = 9; + DNOM = 10; + NUMR = 11; + LIGA = 12; + CLIG = 13; + DLIG = 14; + HLIG = 15; + RLIG = 16; + AALT = 17; + CALT = 18; + RCLT = 19; + SALT = 20; + RVRN = 21; + VERT = 22; + SWSH = 23; + CSWH = 24; + NALT = 25; + CCMP = 26; + STCH = 27; + HIST = 28; + SIZE = 29; + ORNM = 30; + ITAL = 31; + RAND = 32; + DTLS = 33; + FLAC = 34; + MGRK = 35; + SSTY = 36; + KERN = 37; + FWID = 38; + HWID = 39; + HALT = 40; + TWID = 41; + QWID = 42; + PWID = 43; + JUST = 44; + LFBD = 45; + OPBD = 46; + RTBD = 47; + PALT = 48; + PKNA = 49; + LTRA = 50; + LTRM = 51; + RTLA = 52; + RTLM = 53; + ABRV = 54; + ABVM = 55; + ABVS = 56; + VALT = 57; + VHAL = 58; + BLWF = 59; + BLWM = 60; + BLWS = 61; + AKHN = 62; + CJCT = 63; + CFAR = 64; + CPCT = 65; + CURS = 66; + DIST = 67; + EXPT = 68; + FALT = 69; + FINA = 70; + FIN2 = 71; + FIN3 = 72; + HALF = 73; + HALN = 74; + HKNA = 75; + HNGL = 76; + HOJO = 77; + INIT = 78; + ISOL = 79; + JP78 = 80; + JP83 = 81; + JP90 = 82; + JP04 = 83; + LJMO = 84; + LOCL = 85; + MARK = 86; + MEDI = 87; + MED2 = 88; + MKMK = 89; + NLCK = 90; + NUKT = 91; + PREF = 92; + PRES = 93; + VPAL = 94; + PSTF = 95; + PSTS = 96; + RKRF = 97; + RPHF = 98; + RUBY = 99; + SMPL = 100; + TJMO = 101; + TNAM = 102; + TRAD = 103; + VATU = 104; + VJMO = 105; + VKNA = 106; + VKRN = 107; + VRTR = 108; + VRT2 = 109; + SS01 = 110; + SS02 = 111; + SS03 = 112; + SS04 = 113; + SS05 = 114; + SS06 = 115; + SS07 = 116; + SS08 = 117; + SS09 = 118; + SS10 = 119; + SS11 = 120; + SS12 = 121; + SS13 = 122; + SS14 = 123; + SS15 = 124; + SS16 = 125; + SS17 = 126; + SS18 = 127; + SS19 = 128; + SS20 = 129; + CV01 = 130; + CV02 = 131; + CV03 = 132; + CV04 = 133; + CV05 = 134; + CV06 = 135; + CV07 = 136; + CV08 = 137; + CV09 = 138; + CV10 = 139; + CV11 = 140; + CV12 = 141; + CV13 = 142; + CV14 = 143; + CV15 = 144; + CV16 = 145; + CV17 = 146; + CV18 = 147; + CV19 = 148; + CV20 = 149; + CV21 = 150; + CV22 = 151; + CV23 = 152; + CV24 = 153; + CV25 = 154; + CV26 = 155; + CV27 = 156; + CV28 = 157; + CV29 = 158; + CV30 = 159; + CV31 = 160; + CV32 = 161; + CV33 = 162; + CV34 = 163; + CV35 = 164; + CV36 = 165; + CV37 = 166; + CV38 = 167; + CV39 = 168; + CV40 = 169; + CV41 = 170; + CV42 = 171; + CV43 = 172; + CV44 = 173; + CV45 = 174; + CV46 = 175; + CV47 = 176; + CV48 = 177; + CV49 = 178; + CV50 = 179; + CV51 = 180; + CV52 = 181; + CV53 = 182; + CV54 = 183; + CV55 = 184; + CV56 = 185; + CV57 = 186; + CV58 = 187; + CV59 = 188; + CV60 = 189; + CV61 = 190; + CV62 = 191; + CV63 = 192; + CV64 = 193; + CV65 = 194; + CV66 = 195; + CV67 = 196; + CV68 = 197; + CV69 = 198; + CV70 = 199; + CV71 = 200; + CV72 = 201; + CV73 = 202; + CV74 = 203; + CV75 = 204; + CV76 = 205; + CV77 = 206; + CV78 = 207; + CV79 = 208; + CV80 = 209; + CV81 = 210; + CV82 = 211; + CV83 = 212; + CV84 = 213; + CV85 = 214; + CV86 = 215; + CV87 = 216; + CV88 = 217; + CV89 = 218; + CV90 = 219; + CV91 = 220; + CV92 = 221; + CV93 = 222; + CV94 = 223; + CV95 = 224; + CV96 = 225; + CV97 = 226; + CV98 = 227; + CV99 = 228; +} + +struct ExportConstraint { + ExportConstraintType type; + float value; +} + +struct GUIDMapping { + GUID from; + GUID to; +} + +struct Blob { + byte[] bytes; +} + +message Image { + byte[] hash = 1; + string name = 2; + uint dataBlob = 3; +} + +message Video { + byte[] hash = 1; + string s3Url = 2; +} + +struct FilterColorAdjust { + float tint; + float shadows; + float highlights; + float detail; + float exposure; + float vignette; + float temperature; + float vibrance; +} + +message PaintFilterMessage { + float tint = 1; + float shadows = 2; + float highlights = 3; + float detail = 4 [deprecated]; + float exposure = 5; + float vignette = 6 [deprecated]; + float temperature = 7; + float vibrance = 8; + float contrast = 9; + float brightness = 10 [deprecated]; +} + +// Variable binding embedded in Paint - discovered via WS sniffing 2026-01 +// Figma wire format: 15 01 04 01 {sessionID varint} {localID varint} +// - 15 = field 21 in Paint +// - 01 = always 1 (binding type for color) +// - 04 = field 4 +// - 01 = always 1 (flag) +// - then GUID as raw sessionID + localID varints (no field numbers) +// NOTE: kiwi-schema cannot produce this exact format, handled manually in codec +message PaintVariableBinding { + uint bindingType = 1; // Always 1 for color + uint flag = 2; // Placeholder + uint flag2 = 3; // Placeholder + GUID variableID = 4; // Variable GUID +} + +message Paint { + PaintType type = 1; + Color color = 2; + float opacity = 3; + bool visible = 4; + BlendMode blendMode = 5; + ColorStop[] stops = 6; + Matrix transform = 7; + Image image = 8; + Image imageThumbnail = 9; + Image animatedImage = 16; + uint animationFrame = 17; + ImageScaleMode imageScaleMode = 10; + float rotation = 11; + float scale = 12; + FilterColorAdjust filterColorAdjust = 13 [deprecated]; + PaintFilterMessage paintFilter = 14; + uint[] emojiCodePoints = 15; + Video video = 18; + uint originalImageWidth = 19; + uint originalImageHeight = 20; + PaintVariableBinding variableBinding = 21; // Not in fig-kiwi. Discovered via WS sniffing 2026-01 +} + +message FontMetaData { + FontName key = 1; + float fontLineHeight = 2; + byte[] fontDigest = 3; + FontStyle fontStyle = 4; + int fontWeight = 5; +} + +message FontVariation { + uint axisTag = 1; + string axisName = 2; + float value = 3; +} + +message TextData { + string characters = 1; + uint[] characterStyleIDs = 2; + NodeChange[] styleOverrideTable = 3; + Vector layoutSize = 4; + Baseline[] baselines = 5; + Glyph[] glyphs = 6; + Decoration[] decorations = 7; + uint layoutVersion = 8; + FontMetaData[] fontMetaData = 9; + FontName[] fallbackFonts = 10; + HyperlinkBox[] hyperlinkBoxes = 11; + TextLineData[] lines = 12; + int truncationStartIndex = 13; + float truncatedHeight = 14; +} + +message HyperlinkBox { + Rect bounds = 1; + string url = 2; + GUID guid = 3; + int hyperlinkID = 4; +} + +message Baseline { + Vector position = 1; + float width = 2; + float lineY = 3; + float lineHeight = 4; + float lineAscent = 7; + uint firstCharacter = 5; + uint endCharacter = 6; +} + +message Glyph { + uint commandsBlob = 1; + Vector position = 2; + uint styleID = 3; + float fontSize = 4; + uint firstCharacter = 5; + float advance = 6; +} + +message Decoration { + Rect[] rects = 1; + uint styleID = 2; +} + +message VectorData { + uint vectorNetworkBlob = 1; + Vector normalizedSize = 2; + NodeChange[] styleOverrideTable = 3; +} + +message GUIDPath { + GUID[] guids = 1; +} + +message SymbolData { + GUID symbolID = 1; + NodeChange[] symbolOverrides = 2; + float uniformScaleFactor = 3; +} + +message GUIDPathMapping { + GUID id = 1; + GUIDPath path = 2; +} + +message NodeGenerationData { + NodeChange[] overrides = 1; +} + +message DerivedImmutableFrameData { + NodeChange[] overrides = 1; + uint version = 2; +} + +message SharedSymbolReference { + string fileKey = 1; + GUID symbolID = 2; + string versionHash = 3; + GUIDPathMapping[] guidPathMappings = 4; + byte[] bytes = 5; + GUIDMapping[] libraryGUIDToSubscribingGUID = 6 [deprecated]; + string componentKey = 7; + GUIDPathMapping[] unflatteningMappings = 8; + bool isUnflattened = 9; +} + +message SharedComponentMasterData { + string componentKey = 1; + GUIDPathMapping[] publishingGUIDPathToTeamLibraryGUID = 2; + bool isUnflattened = 3; +} + +message InstanceOverrideStash { + GUIDPath overridePathOfSwappedInstance = 1; + string componentKey = 2; + NodeChange[] overrides = 3; +} + +message InstanceOverrideStashV2 { + GUIDPath overridePathOfSwappedInstance = 1; + GUID localSymbolID = 2; + NodeChange[] overrides = 3; +} + +message Effect { + EffectType type = 1; + Color color = 2; + Vector offset = 3; + float radius = 4; + bool visible = 5; + BlendMode blendMode = 6; + float spread = 7; + bool showShadowBehindNode = 8; +} + +message TransitionInfo { + TransitionType type = 1; + float duration = 2; +} + +enum PrototypeDeviceType { + NONE = 0; + PRESET = 1; + CUSTOM = 2; + PRESENTATION = 3; +} + +enum DeviceRotation { + NONE = 0; + CCW_90 = 1; +} + +message PrototypeDevice { + PrototypeDeviceType type = 1; + Vector size = 2; + string presetIdentifier = 3; + DeviceRotation rotation = 4; +} + +enum OverlayPositionType { + CENTER = 0; + TOP_LEFT = 1; + TOP_CENTER = 2; + TOP_RIGHT = 3; + BOTTOM_LEFT = 4; + BOTTOM_CENTER = 5; + BOTTOM_RIGHT = 6; + MANUAL = 7; +} + +enum OverlayBackgroundInteraction { + NONE = 0; + CLOSE_ON_CLICK_OUTSIDE = 1; +} + +enum OverlayBackgroundType { + NONE = 0; + SOLID_COLOR = 1; +} + +message OverlayBackgroundAppearance { + OverlayBackgroundType backgroundType = 1; + Color backgroundColor = 2; +} + +enum NavigationType { + NAVIGATE = 0; + OVERLAY = 1; + SWAP = 2; + SWAP_STATE = 3; + SCROLL_TO = 4; +} + +message ExportSettings { + string suffix = 1; + ImageType imageType = 2; + ExportConstraint constraint = 3; + bool svgDataName = 4; + ExportSVGIDMode svgIDMode = 5; + bool svgOutlineText = 6; + bool contentsOnly = 7; + bool svgForceStrokeMasks = 8; + bool useAbsoluteBounds = 9; +} + +enum ExportSVGIDMode { + IF_NEEDED = 0; + ALWAYS = 1; +} + +message LayoutGrid { + LayoutGridType type = 1; + Axis axis = 2; + bool visible = 3; + int numSections = 4; + float offset = 5; + float sectionSize = 6; + float gutterSize = 7; + Color color = 8; + LayoutGridPattern pattern = 9; +} + +message Guide { + Axis axis = 1; + float offset = 2; + GUID guid = 3; +} + +message Path { + WindingRule windingRule = 1; + uint commandsBlob = 2; + uint styleID = 3; +} + +enum StyleType { + NONE = 0; + FILL = 1; + STROKE = 2; + TEXT = 3; + EFFECT = 4; + EXPORT = 5; + GRID = 6; +} + +message SharedStyleReference { + string styleKey = 1; + string versionHash = 2; +} + +message SharedStyleMasterData { + string styleKey = 1; + string sortPosition = 2; + string fileKey = 3; +} + +enum ScrollBehavior { + SCROLLS = 0; + FIXED_WHEN_CHILD_OF_SCROLLING_FRAME = 1; +} + +message ArcData { + float startingAngle = 1; + float endingAngle = 2; + float innerRadius = 3; +} + +message SymbolLink { + string uri = 1; + string displayName = 2; + string displayText = 3; +} + +message PluginData { + string pluginID = 1; + string value = 2; + string key = 3; +} + +message PluginRelaunchData { + string pluginID = 1; + string message = 2; + string command = 3; + bool isDeleted = 4; +} + +enum ConnectorMagnet { + NONE = 0; + AUTO = 1; + TOP = 2; + LEFT = 3; + BOTTOM = 4; + RIGHT = 5; +} + +message ConnectorEndpoint { + GUID endpointNodeID = 1; + Vector position = 2; + ConnectorMagnet magnet = 3; +} + +message ConnectorControlPoint { + Vector position = 1; + Vector axis = 2; +} + +enum ConnectorTextSection { + MIDDLE_TO_START = 0; + MIDDLE_TO_END = 1; +} + +message ConnectorTextMidpoint { + ConnectorTextSection section = 1; + float offset = 2; +} + +enum ConnectorLineStyle { + ELBOWED = 0; + STRAIGHT = 1; +} + +message LibraryMoveInfo { + string oldKey = 1; + string pasteFileKey = 2; +} + +message LibraryMoveHistoryItem { + GUID sourceNodeId = 1; + string sourceComponentKey = 2; +} + +message WidgetPointer { + GUID nodeId = 1; +} + +enum EditorType { + DESIGN = 0; + WHITEBOARD = 1; +} + +message NodeChange { + GUID guid = 1; + uint guidTag = 53 [deprecated]; + NodePhase phase = 2; + uint phaseTag = 54 [deprecated]; + ParentIndex parentIndex = 3; + uint parentIndexTag = 55 [deprecated]; + NodeType type = 4; + uint typeTag = 56 [deprecated]; + string name = 5; + uint nameTag = 57 [deprecated]; + bool visible = 6; + uint visibleTag = 58 [deprecated]; + bool locked = 7; + uint lockedTag = 59 [deprecated]; + float opacity = 8; + uint opacityTag = 60 [deprecated]; + BlendMode blendMode = 9; + uint blendModeTag = 61 [deprecated]; + uint count = 10; + uint countTag = 62 [deprecated]; + Vector size = 11; + uint sizeTag = 63 [deprecated]; + Matrix transform = 12; + uint transformTag = 64 [deprecated]; + float[] dashPattern = 13; + uint dashPatternTag = 65 [deprecated]; + bool autoRename = 14; + uint autoRenameTag = 66 [deprecated]; + bool backgroundEnabled = 15; + uint backgroundEnabledTag = 67 [deprecated]; + bool mask = 16; + uint maskTag = 68 [deprecated]; + bool exportContentsOnly = 17; + uint exportContentsOnlyTag = 69 [deprecated]; + bool maskIsOutline = 18; + uint maskIsOutlineTag = 70 [deprecated]; + float backgroundOpacity = 19; + uint backgroundOpacityTag = 71 [deprecated]; + float cornerRadius = 20; + uint cornerRadiusTag = 72 [deprecated]; + float fontSize = 21; + uint fontSizeTag = 73 [deprecated]; + float paragraphIndent = 22; + uint paragraphIndentTag = 74 [deprecated]; + float paragraphSpacing = 23; + uint paragraphSpacingTag = 75 [deprecated]; + float starInnerScale = 24; + uint starInnerScaleTag = 76 [deprecated]; + float miterLimit = 25; + uint miterLimitTag = 77 [deprecated]; + float strokeWeight = 26; + uint strokeWeightTag = 78 [deprecated]; + float textTracking = 27; + uint textTrackingTag = 79 [deprecated]; + ConstraintType horizontalConstraint = 28; + uint horizontalConstraintTag = 80 [deprecated]; + StrokeAlign strokeAlign = 29; + uint strokeAlignTag = 81 [deprecated]; + StrokeCap strokeCap = 30; + uint strokeCapTag = 82 [deprecated]; + StrokeJoin strokeJoin = 31; + uint strokeJoinTag = 83 [deprecated]; + TextAlignHorizontal textAlignHorizontal = 32; + uint textAlignHorizontalTag = 84 [deprecated]; + TextAlignVertical textAlignVertical = 33; + uint textAlignVerticalTag = 85 [deprecated]; + TextCase textCase = 34; + uint textCaseTag = 86 [deprecated]; + TextDecoration textDecoration = 35; + uint textDecorationTag = 87 [deprecated]; + BooleanOperation booleanOperation = 36; + uint booleanOperationTag = 88 [deprecated]; + ConstraintType verticalConstraint = 37; + uint verticalConstraintTag = 89 [deprecated]; + Paint[] fillPaints = 38; + uint fillPaintsTag = 90 [deprecated]; + Paint[] strokePaints = 39; + uint strokePaintsTag = 91 [deprecated]; + Number lineHeight = 40; + uint lineHeightTag = 92 [deprecated]; + FontName fontName = 41; + uint fontNameTag = 93 [deprecated]; + TextData textData = 42; + uint textDataTag = 94 [deprecated]; + Effect[] effects = 43; + uint effectsTag = 95 [deprecated]; + VectorMirror handleMirroring = 44; + uint handleMirroringTag = 96 [deprecated]; + ExportSettings[] exportSettings = 45; + uint exportSettingsTag = 97 [deprecated]; + TextAutoResize textAutoResize = 46; + uint textAutoResizeTag = 98 [deprecated]; + LayoutGrid[] layoutGrids = 47; + uint layoutGridsTag = 99 [deprecated]; + VectorData vectorData = 48; + uint vectorDataTag = 100 [deprecated]; + uint styleID = 49; + uint styleIDTag = 101 [deprecated]; + Color backgroundColor = 50; + uint backgroundColorTag = 102 [deprecated]; + Path[] fillGeometry = 51; + uint fillGeometryTag = 103 [deprecated]; + Path[] strokeGeometry = 52; + uint strokeGeometryTag = 104 [deprecated]; + StackMode stackMode = 105; + uint stackModeTag = 106 [deprecated]; + float stackSpacing = 107; + uint stackSpacingTag = 108 [deprecated]; + float stackPadding = 109; + uint stackPaddingTag = 110 [deprecated]; + GUIDPath guidPath = 111; + uint guidPathTag = 112 [deprecated]; + SymbolData symbolData = 113; + uint symbolDataTag = 114 [deprecated]; + bool frameMaskDisabled = 115; + uint frameMaskDisabledTag = 116 [deprecated]; + bool resizeToFit = 117; + uint resizeToFitTag = 118 [deprecated]; + bool exportBackgroundDisabled = 119; + StackCounterAlign stackCounterAlign = 120; + StackJustify stackJustify = 121; + SharedSymbolReference sharedSymbolReference = 122; + bool isSymbolPublishable = 123; + GUIDPathMapping[] sharedSymbolMappings = 124; + NodeChange[] derivedSymbolData = 125; + string sharedSymbolVersion = 126; + bool fontVariantCommonLigatures = 127; + bool fontVariantContextualLigatures = 128; + bool fontVariantDiscretionaryLigatures = 129; + bool fontVariantHistoricalLigatures = 130; + bool fontVariantOrdinal = 131; + bool fontVariantSlashedZero = 132; + FontVariantNumericFigure fontVariantNumericFigure = 133; + FontVariantNumericSpacing fontVariantNumericSpacing = 134; + FontVariantNumericFraction fontVariantNumericFraction = 135; + FontVariantCaps fontVariantCaps = 136; + FontVariantPosition fontVariantPosition = 137; + Guide[] guides = 138; + GUID transitionNodeID = 139; + GUID prototypeStartNodeID = 140; + Color prototypeBackgroundColor = 141; + bool internalOnly = 142; + GUID overriddenSymbolID = 143; + string symbolDescription = 144; + float rectangleTopLeftCornerRadius = 145; + float rectangleTopRightCornerRadius = 146; + float rectangleBottomLeftCornerRadius = 147; + float rectangleBottomRightCornerRadius = 148; + bool rectangleCornerRadiiIndependent = 149; + bool rectangleCornerToolIndependent = 150; + bool proportionsConstrained = 151; + SharedComponentMasterData sharedComponentMasterData = 152; + TransitionInfo transitionInfo = 153 [deprecated]; + TransitionType transitionType = 154; + float transitionDuration = 155; + EasingType easingType = 156; + ScrollDirection scrollDirection = 159; + float cornerSmoothing = 160; + GUID inheritFillStyleID = 158; + GUID inheritStrokeStyleID = 162; + GUID inheritTextStyleID = 167; + GUID inheritExportStyleID = 168; + GUID inheritEffectStyleID = 169; + GUID inheritGridStyleID = 170; + GUID inheritFillStyleIDForStroke = 185; + bool isFillStyle = 157 [deprecated]; + bool isStrokeStyle = 161 [deprecated]; + StyleType styleType = 163; + string styleDescription = 191; + GUIDPathMapping[] unflatteningMappings = 164; + Number letterSpacing = 165; + Vector scrollOffset = 166 [deprecated]; + string version = 171; + SharedStyleMasterData sharedStyleMasterData = 172; + SharedStyleReference sharedStyleReference = 173; + bool isPublishable = 174; + bool exportTextAsSVGText = 175; + bool isSoftDeletedStyle = 176; + bool isNonUpdateable = 177; + ScrollContractedState scrollContractedState = 178 [deprecated]; + Vector contractedSize = 179 [deprecated]; + string fixedChildrenDivider = 180 [deprecated]; + bool transitionPreserveScroll = 181; + ConnectionType connectionType = 182; + string connectionURL = 183; + PrototypeDevice prototypeDevice = 184; + ScrollBehavior scrollBehavior = 186; + InteractionType interactionType = 187; + float transitionTimeout = 188; + bool interactionMaintained = 189; + float interactionDuration = 190; + bool destinationIsOverlay = 192 [deprecated]; + Paint[] backgroundPaints = 193; + GUID inheritFillStyleIDForBackground = 194; + ArcData arcData = 195; + int derivedSymbolDataLayoutVersion = 196; + NavigationType navigationType = 197; + OverlayPositionType overlayPositionType = 198; + Vector overlayRelativePosition = 199; + OverlayBackgroundInteraction overlayBackgroundInteraction = 200; + OverlayBackgroundAppearance overlayBackgroundAppearance = 201; + string fontVersion = 202; + uint textUserLayoutVersion = 203; + PluginData[] pluginData = 204; + OpenTypeFeature[] toggledOnOTFeatures = 205; + OpenTypeFeature[] toggledOffOTFeatures = 206; + bool transitionShouldSmartAnimate = 207; + StackAlign stackAlign = 208 [deprecated]; + float stackHorizontalPadding = 209; + float stackVerticalPadding = 210; + StackSize stackWidth = 211 [deprecated]; + StackSize stackHeight = 212 [deprecated]; + GUID overrideKey = 213; + string publishFile = 214; + GUID publishID = 215; + string componentKey = 216; + bool isC2 = 217; + string publishedVersion = 218; + PluginRelaunchData[] pluginRelaunchData = 219; + bool containerSupportsFillStrokeAndCorners = 220; + StackSize stackCounterSizing = 221; + bool containersSupportFillStrokeAndCorners = 222 [deprecated]; + Hyperlink hyperlink = 223; + KeyTrigger keyTrigger = 224; + bool isStateGroup = 225; + PrototypeInteraction[] prototypeInteractions = 226; + string voiceEventPhrase = 227; + GUIDPathMapping[] forceUnflatteningMappings = 228; + StackSize stackPrimarySizing = 229; + StackJustify stackPrimaryAlignItems = 230; + StackAlign stackCounterAlignItems = 231; + float stackChildPrimaryGrow = 232; + float stackPaddingRight = 233; + float stackPaddingBottom = 234; + GUID[] ancestorPathBeforeDeletion = 235; + StackCounterAlign stackChildAlignSelf = 236; + SymbolLink[] symbolLinks = 237; + StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders = 238; + TextListData textListData = 239; + NodeGenerationData nodeGenerationData = 240; + ShapeWithTextType shapeWithTextType = 241; + ConnectorEndpoint connectorStart = 242; + ConnectorEndpoint connectorEnd = 243; + ConnectorLineStyle connectorLineStyle = 244; + StrokeCap connectorStartCap = 245; + StrokeCap connectorEndCap = 246; + float shapeUserHeight = 247; + InstanceOverrideStash[] overrideStash = 248; + PrototypeStartingPoint prototypeStartingPoint = 249; + InstanceOverrideStashV2[] overrideStashV2 = 250 [deprecated]; + InternalEnumForTest internalEnumForTest = 251; + string originComponentKey = 252 [deprecated]; + ConnectorControlPoint[] connectorControlPoints = 253; + DerivedImmutableFrameData derivedImmutableFrameData = 254; + ConnectorTextMidpoint connectorTextMidpoint = 255; + LibraryMoveInfo libraryMoveInfo = 256; + InternalDataForTest internalDataForTest = 257; + bool useAbsoluteBounds = 258 [deprecated]; + CodeBlockLanguage codeBlockLanguage = 259; + FontVariation[] fontVariations = 260; + bool detachOpticalSizeFromFontSize = 261; + WidgetMetadata widgetMetadata = 262; + WidgetEvent[] widgetEvents = 263; + float listSpacing = 264; + WidgetPropertyMenuItem[] widgetPropertyMenuItems = 265; + ComponentPropDef[] componentPropDefs = 266; + ComponentPropRef[] componentPropRefs = 267; + ComponentPropAssignment[] componentPropAssignments = 268; + StackPositioning stackPositioning = 269; + EmbedData embedData = 270; + bool stackReverseZIndex = 271; + RichMediaData richMediaData = 272; + MultiplayerMap widgetSyncedState = 273; + uint widgetSyncCursor = 274 [deprecated]; + WidgetDerivedSubtreeCursor widgetDerivedSubtreeCursor = 275 [deprecated]; + WidgetPointer widgetCachedAncestor = 276; + MultiplayerMap renderedSyncedState = 277; + LinkPreviewData linkPreviewData = 278; + uint textBidiVersion = 279; + TextTruncation textTruncation = 280; + LibraryMoveHistoryItem[] libraryMoveHistory = 281; + bool shapeTruncates = 282; + bool sectionContentsHidden = 283; + bool simplifyInstancePanels = 284; + WidgetInputBehavior widgetInputBehavior = 285; + string widgetTooltip = 286; + bool borderTopHidden = 287 [deprecated]; + bool borderBottomHidden = 288 [deprecated]; + bool borderLeftHidden = 289 [deprecated]; + bool borderRightHidden = 290 [deprecated]; + WidgetHoverStyle widgetHoverStyle = 291; + bool hasHadRTLText = 292; + bool isWidgetStickable = 293; + bool bordersTakeSpace = 294; + float borderTopWeight = 295; + float borderBottomWeight = 296; + float borderLeftWeight = 297; + float borderRightWeight = 298; + bool borderStrokeWeightsIndependent = 299; + VideoPlayback videoPlayback = 300; + StampData stampData = 301; + HTMLTag htmlTag = 302; + ARIARole ariaRole = 303; + string accessibleLabel = 304; + bool propsAreBubbled = 305; + VariableData variableData = 306; + uint gridRowCount = 435; + uint gridColumnCount = 436; + float gridRowGap = 437; + float gridColumnGap = 438; + GridTrackSize[] gridColumnSizes = 474; + GridTrackSize[] gridRowSizes = 475; +} + +message VideoPlayback { + bool autoplay = 1; + bool mediaLoop = 2; + bool muted = 3; +} + +message WidgetHoverStyle { + Paint[] fillPaints = 1; + Paint[] strokePaints = 2; + float opacity = 3; + bool areFillPaintsSet = 4; + bool areStrokePaintsSet = 5; + bool isOpacitySet = 6; +} + +message WidgetDerivedSubtreeCursor { + uint sessionID = 1; + uint counter = 2; +} + +message MultiplayerMap { + MultiplayerMapEntry[] entries = 1; +} + +message MultiplayerMapEntry { + string key = 1; + string value = 2; +} + +message ComponentPropRef { + uint nodeField = 1; + GUID defID = 2; + string zombieFallbackName = 3; + ComponentPropNodeField componentPropNodeField = 4; + bool isDeleted = 5; +} + +enum ComponentPropNodeField { + VISIBLE = 0; + TEXT_DATA = 1; + OVERRIDDEN_SYMBOL_ID = 2; + INHERIT_FILL_STYLE_ID = 3; +} + +message ComponentPropAssignment { + GUID defID = 1; + ComponentPropValue value = 2; +} + +message ComponentPropDef { + GUID id = 1; + string name = 2; + ComponentPropValue initialValue = 3; + string sortPosition = 4; + GUID parentPropDefId = 5; + ComponentPropType type = 6; + bool isDeleted = 7; + ComponentPropPreferredValues preferredValues = 8; +} + +message ComponentPropValue { + bool boolValue = 1; + TextData textValue = 2; + GUID guidValue = 3; +} + +enum ComponentPropType { + BOOL = 0; + TEXT = 1; + COLOR = 2; + INSTANCE_SWAP = 3; +} + +message ComponentPropPreferredValues { + string[] stringValues = 1; +} + +enum WidgetEvent { + MOUSE_DOWN = 0; + CLICK = 1; + TEXT_EDIT_END = 2; + ATTACHED_STICKABLES_CHANGED = 3; + STUCK_STATUS_CHANGED = 4; +} + +enum WidgetInputBehavior { + WRAP = 0; + TRUNCATE = 1; + MULTILINE = 2; +} + +message WidgetMetadata { + string pluginID = 1; + string pluginVersionID = 2; + string widgetName = 3; +} + +enum WidgetPropertyMenuItemType { + ACTION = 0; + SEPARATOR = 1; + COLOR = 2; + DROPDOWN = 3; + COLOR_SELECTOR = 4; + TOGGLE = 5; + LINK = 6; +} + +message WidgetPropertyMenuSelectorOption { + string option = 1; + string tooltip = 2; +} + +message WidgetPropertyMenuItem { + string propertyName = 1; + string tooltip = 2; + WidgetPropertyMenuItemType itemType = 3; + string icon = 4; + WidgetPropertyMenuSelectorOption[] options = 5; + string selectedOption = 6; + bool isToggled = 7; + string href = 8; +} + +enum CodeBlockLanguage { + TYPESCRIPT = 0; + CPP = 1; + RUBY = 2; + CSS = 3; + JAVASCRIPT = 4; + HTML = 5; + JSON = 6; + GRAPHQL = 7; + PYTHON = 8; + GO = 9; + SQL = 10; + SWIFT = 11; + KOTLIN = 12; + RUST = 13; +} + +enum InternalEnumForTest { + OLD = 1; +} + +message InternalDataForTest { + int testFieldA = 1; +} + +message StateGroupPropertyValueOrder { + string property = 1; + string[] values = 2; +} + +message TextListData { + int listID = 1 [deprecated]; + BulletType bulletType = 2; + int indentationLevel = 3; + int lineNumber = 4 [deprecated]; +} + +message TextLineData { + LineType lineType = 1; + int indentationLevel = 2; + Directionality directionality = 3; + DirectionalityIntent directionalityIntent = 4; +} + +enum BulletType { + ORDERED = 0; + UNORDERED = 1; + INDENT = 2; + NO_LIST = 3; +} + +enum LineType { + PLAIN = 0; + ORDERED_LIST = 1; + UNORDERED_LIST = 2; +} + +enum Directionality { + LTR = 0; + RTL = 1; +} + +enum DirectionalityIntent { + IMPLICIT = 0; + EXPLICIT = 1; +} + +message PrototypeInteraction { + GUID id = 1; + PrototypeEvent event = 2; + PrototypeAction[] actions = 3; + bool isDeleted = 4; +} + +message PrototypeEvent { + InteractionType interactionType = 1; + bool interactionMaintained = 2; + float interactionDuration = 3; + KeyTrigger keyTrigger = 4; + string voiceEventPhrase = 5; + float transitionTimeout = 6; +} + +message PrototypeAction { + GUID transitionNodeID = 1; + TransitionType transitionType = 2; + float transitionDuration = 3; + EasingType easingType = 4; + float transitionTimeout = 5 [deprecated]; + bool transitionShouldSmartAnimate = 6; + ConnectionType connectionType = 7; + string connectionURL = 8; + Vector overlayRelativePosition = 9; + NavigationType navigationType = 10; + bool transitionPreserveScroll = 11; + float[] easingFunction = 12; + Vector extraScrollOffset = 13; + GUID targetVariableID = 14; +} + +message PrototypeStartingPoint { + string name = 1; + string description = 2; + string position = 3; +} + +enum TriggerDevice { + KEYBOARD = 0; + UNKNOWN_CONTROLLER = 1; + XBOX_ONE = 2; + PS4 = 3; + SWITCH_PRO = 4; +} + +message KeyTrigger { + int[] keyCodes = 1; + TriggerDevice triggerDevice = 2; +} + +message Hyperlink { + string url = 1; + GUID guid = 2; +} + +message EmbedData { + string url = 1; + string srcUrl = 2; + string title = 3; + string thumbnailUrl = 4; + float width = 5; + float height = 6; + string embedType = 7; + string thumbnailImageHash = 8; + string faviconImageHash = 9; + string provider = 10; + string originalText = 11; + string description = 12; +} + +message StampData { + string userId = 1; +} + +message LinkPreviewData { + string url = 1; + string title = 2; + string provider = 3; + string description = 4; + string thumbnailImageHash = 5; + string faviconImageHash = 6; + float thumbnailImageWidth = 7; + float thumbnailImageHeight = 8; +} + +message Viewport { + Rect canvasSpaceBounds = 1; + bool pixelPreview = 2; + float pixelDensity = 3; + GUID canvasGuid = 4; +} + +message Mouse { + MouseCursor cursor = 1; + Vector canvasSpaceLocation = 2; + Rect canvasSpaceSelectionBox = 3; + GUID canvasGuid = 4; +} + +struct Click { + uint id; + Vector point; +} + +struct ScrollPosition { + GUID node; + Vector scrollOffset; +} + +struct TriggeredOverlay { + GUID overlayGuid; + GUID hotspotGuid; + GUID swapGuid; +} + +message TriggeredOverlayData { + GUID overlayGuid = 1; + GUID hotspotGuid = 2; + GUID swapGuid = 3; + GUID prototypeInteractionGuid = 4; + GUIDPath hotspotBlueprintId = 5; +} + +message PresentedState { + GUID baseScreenID = 1; + TriggeredOverlayData[] overlays = 2; +} + +enum TransitionDirection { + FORWARD = 0; + REVERSE = 1; +} + +message TopLevelPlaybackChange { + PresentedState oldState = 1; + PresentedState newState = 2; + GUIDPath hotspotBlueprintID = 3; + GUID interactionID = 4; + bool isHotspotInNewPresentedState = 5; + TransitionDirection direction = 6; + GUIDPath instanceStablePath = 7; +} + +message InstanceStateChange { + GUID stateID = 1; + GUID interactionID = 2; + GUIDPath hotspotStablePath = 3; + GUIDPath instanceStablePath = 4; + PlaybackChangePhase phase = 5; +} + +enum PlaybackChangePhase { + INITIATED = 0; + ABORTED = 1; + COMMITTED = 2; +} + +message PlaybackChangeKeyframe { + PlaybackChangePhase phase = 1; + float progress = 2; + float timestamp = 3; +} + +message StateMapping { + GUIDPath stablePath = 1; + TopLevelPlaybackChange lastTopLevelChange = 2; + PlaybackChangeKeyframe lastTopLevelChangeStatus = 3; + float timestamp = 4; +} + +message ScrollMapping { + GUIDPath blueprintID = 1; + uint overlayIndex = 2; + Vector scrollOffset = 3; +} + +message PlaybackUpdate { + TopLevelPlaybackChange lastTopLevelChange = 1; + PlaybackChangeKeyframe lastTopLevelChangeStatus = 2; + ScrollMapping[] scrollMappings = 3; + float timestamp = 4; + Vector pointerLocation = 5; + bool isTopLevelFrameChange = 6; + StateMapping[] stateMappings = 7; +} + +message ChatMessage { + string text = 1; + string previousText = 2; +} + +message VoiceMetadata { + string connectedCallId = 1; +} + +message UserChange { + uint sessionID = 1; + bool connected = 2; + string name = 3; + Color color = 4; + string imageURL = 5; + Viewport viewport = 6; + Mouse mouse = 7; + GUID[] selection = 8; + uint[] observing = 9; + string deviceName = 10; + Click[] recentClicks = 11; + ScrollPosition[] scrollPositions = 12; + TriggeredOverlay[] triggeredOverlays = 13 [deprecated]; + string userID = 14; + GUID lastTriggeredHotspot = 15; + GUID lastTriggeredPrototypeInteractionID = 16; + TriggeredOverlayData[] triggeredOverlaysData = 17; + PlaybackUpdate[] playbackUpdates = 18 [deprecated]; + ChatMessage chatMessage = 19; + VoiceMetadata voiceMetadata = 20; + bool canWrite = 21; + bool highFiveStatus = 22; + InstanceStateChange[] instanceStateChanges = 23; +} + +message SceneGraphQuery { + GUID startingNode = 1; + uint depth = 2; +} + +message NodeChangesMetadata { + uint blobsFieldOffset = 1; +} + +message CursorReaction { + string imageUrl = 1; +} + +message TimerInfo { + bool isPaused = 1; + uint timeRemainingMs = 2; + uint totalTimeMs = 3; + uint timerID = 4; + string setBy = 5; +} + +message PresenterInfo { + uint sessionID = 1; +} + +message ClientBroadcast { + uint sessionID = 1; + CursorReaction cursorReaction = 2; + TimerInfo timer = 3; + PresenterInfo presenter = 4; +} + +message Message { + MessageType type = 1; + uint sessionID = 2; + uint ackID = 3; + NodeChange[] nodeChanges = 4; + UserChange[] userChanges = 5; + Blob[] blobs = 6; + string signalName = 7; + Access access = 8; + string styleSetName = 9; + StyleSetType styleSetType = 10; + StyleSetContentType styleSetContentType = 11; + int pasteID = 12; + Vector pasteOffset = 13; + string pasteFileKey = 14; + string signalPayload = 15; + SceneGraphQuery[] sceneGraphQueries = 16; + NodeChangesMetadata nodeChangesMetadata = 17 [deprecated]; + uint fileVersion = 18; + bool pasteIsPartiallyOutsideEnclosingFrame = 19; + GUID pastePageId = 20; + bool isCut = 21; + Message[] localUndoStack = 22; + Message[] localRedoStack = 23; + ClientBroadcast[] broadcasts = 24; + uint reconnectSequenceNumber = 25; + string pasteBranchSourceFileKey = 26; + EditorType pasteEditorType = 27; + uint reserved28 = 28; + uint64 timestamp = 38; +} + +message DiffChunk { + uint[] nodeChanges = 1; + NodePhase phase = 2 [deprecated]; + NodeChange displayNode = 3; + GUID canvasId = 4; + string canvasName = 5; + bool canvasIsInternal = 6; + uint[] chunksAffectingThisChunk = 7; + NodeChange[] basisParentHierarchy = 8; + NodeChange[] parentHierarchy = 9; +} + +message DiffPayload { + NodeChange[] nodeChanges = 1; + Blob[] blobs = 2; + DiffChunk[] diffChunks = 3; + NodeChange[] diffBasis = 4; +} + +enum RichMediaType { + ANIMATED_IMAGE = 0; +} + +message RichMediaData { + string mediaHash = 1; + RichMediaType richMediaType = 2; +} + +enum VariableDataType { + BOOLEAN = 0; + FLOAT = 1; + STRING = 2; +} + +message VariableAnyValue { + bool boolValue = 1; + string textValue = 2; + float floatValue = 3; +} + +message VariableData { + VariableAnyValue value = 1; + VariableDataType dataType = 2; +} + +enum HTMLTag { + AUTO = 0; + ARTICLE = 1; + SECTION = 2; + NAV = 3; + ASIDE = 4; + H1 = 5; + H2 = 6; + H3 = 7; + H4 = 8; + H5 = 9; + H6 = 10; + HGROUP = 11; + HEADER = 12; + FOOTER = 13; + ADDRESS = 14; + P = 15; + HR = 16; + PRE = 17; + BLOCKQUOTE = 18; + OL = 19; + UL = 20; + MENU = 21; + LI = 22; + DL = 23; + DT = 24; + DD = 25; + FIGURE = 26; + FIGCAPTION = 27; + MAIN = 28; + DIV = 29; + A = 30; + EM = 31; + STRONG = 32; + SMALL = 33; + S = 34; + CITE = 35; + Q = 36; + DFN = 37; + ABBR = 38; + RUBY = 39; + RT = 40; + RP = 41; + DATA = 42; + TIME = 43; + CODE = 44; + VAR = 45; + SAMP = 46; + KBD = 47; + SUB = 48; + SUP = 49; + I = 50; + B = 51; + U = 52; + MARK = 53; + BDI = 54; + BDO = 55; + SPAN = 56; + BR = 57; + WBR = 58; + PICTURE = 59; + SOURCE = 60; + IMG = 61; + FORM = 62; + LABEL = 63; + INPUT = 64; + BUTTON = 65; + SELECT = 66; + DATALIST = 67; + OPTGROUP = 68; + OPTION = 69; + TEXTAREA = 70; + OUTPUT = 71; + PROGRESS = 72; + METER = 73; + FIELDSET = 74; + LEGEND = 75; +} + +enum ARIARole { + AUTO = 0; + BUTTON = 1; + CHECKBOX = 2; + GRIDCELL = 3; + LINK = 4; + MENUITEM = 5; + MENUITEMCHECKBOX = 6; + MENUITEMRADIO = 7; + OPTION = 8; + PROGRESSBAR = 9; + RADIO = 10; + SCROLLBAR = 11; + SEARCHBOX = 12; + SEPARATOR = 13; + SLIDER = 14; + SPINBUTTON = 15; + SWITCH = 16; + TAB = 17; + TABPANEL = 18; + TEXTBOX = 19; + TREEITEM = 20; + COMBOBOX = 21; + GRID = 22; + LISTBOX = 23; + MENU = 24; + MENUBAR = 25; + RADIOGROUP = 26; + TABLIST = 27; + TREE = 28; + TREEGRID = 29; + APPLICATION = 30; + ARTICLE = 31; + BLOCKQUOTE = 32; + CAPTION = 33; + CELL = 34; + COLUMNHEADER = 35; + DEFINITION = 36; + DELETION = 37; + DIRECTORY = 38; + DOCUMENT = 39; + EMPHASIS = 40; + FEED = 41; + FIGURE = 42; + GENERIC = 43; + GROUP = 44; + HEADING = 45; + IMG = 46; + INSERTION = 47; + LIST = 48; + LISTITEM = 49; + MATH = 50; + METER = 51; + NONE = 52; + NOTE = 53; + PARAGRAPH = 54; + PRESENTATION = 55; + ROW = 56; + ROWGROUP = 57; + ROWHEADER = 58; + STRONG = 59; + SUBSCRIPT = 60; + SUPERSCRIPT = 61; + TABLE = 62; + TERM = 63; + TIME = 64; + TOOLBAR = 65; + TOOLTIP = 66; + BANNER = 67; + COMPLEMENTARY = 68; + CONTENTINFO = 69; + FORM = 70; + MAIN = 71; + NAVIGATION = 72; + REGION = 73; + SEARCH = 74; + ALERT = 75; + LOG = 76; + MARQUEE = 77; + STATUS = 78; + TIMER = 79; + ALERTDIALOG = 80; + DIALOG = 81; +} +` + +export default parseSchema(schemaText)