From 8cedd46938dc34fc9e346b8d3fcdf5cd91cd3672 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 25 Sep 2026 22:07:39 +0900 Subject: [PATCH 01/10] feat(figma): parse figma-bridge://current and build every returned link through one helper A link devup-mcp hands back has to route to the same place. Three identical private canonical_url helpers become FigmaTarget::link, which keeps the Figma URL for a Figma file and answers figma-bridge://current for a file only the bridge can read - never a Figma URL around a key no Figma file has. figma-bridge://current[?node-id=] parses to a bridge-only placeholder the server binds to the attached plugin. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp-figma/src/explore.rs | 21 +----- crates/devup-mcp-figma/src/lib.rs | 2 +- crates/devup-mcp-figma/src/search.rs | 17 +---- crates/devup-mcp-figma/src/section.rs | 26 +++---- crates/devup-mcp-figma/src/url.rs | 89 +++++++++++++++++++++-- crates/devup-mcp-figma/tests/figma_url.rs | 55 ++++++++++++++ 6 files changed, 151 insertions(+), 59 deletions(-) diff --git a/crates/devup-mcp-figma/src/explore.rs b/crates/devup-mcp-figma/src/explore.rs index 2c80616b..95ad1090 100644 --- a/crates/devup-mcp-figma/src/explore.rs +++ b/crates/devup-mcp-figma/src/explore.rs @@ -357,7 +357,7 @@ pub fn explore_snapshot( notes: String::new(), }), candidates: vec![ExploreCandidate { - canonical_url: canonical_url(target, &anchor.node_id), + canonical_url: target.link(Some(&anchor.node_id)), node: anchor.clone(), score: 1_000, selection_reasons: vec!["exact-screen-anchor".to_owned()], @@ -405,7 +405,7 @@ pub fn explore_snapshot( let candidates = nodes .into_iter() .map(|node| ExploreCandidate { - canonical_url: canonical_url(target, &node.node_id), + canonical_url: target.link(Some(&node.node_id)), node, score: 900, selection_reasons: vec!["screen-like".to_owned(), "inside-section".to_owned()], @@ -484,7 +484,7 @@ pub fn explore_snapshot( reasons.push("before-next-heading".to_owned()); } ExploreCandidate { - canonical_url: canonical_url(target, &node.node_id), + canonical_url: target.link(Some(&node.node_id)), score: 400 + (overlap_ratio.clamp(0.0, 1.0) * 100.0).round() as u32, node, selection_reasons: reasons, @@ -658,18 +658,3 @@ fn looks_like_requirement_heading(name: &str) -> bool { && !number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit()) } - -fn canonical_url(target: &FigmaTarget, node_id: &str) -> String { - let node_id = node_id.replace(':', "-"); - if let Some(branch_key) = &target.branch_key { - format!( - "https://www.figma.com/branch/{}/{branch_key}/devup?node-id={node_id}", - target.file_key - ) - } else { - format!( - "https://www.figma.com/design/{}/devup?node-id={node_id}", - target.file_key - ) - } -} diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index e4f128d1..dba42ba1 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -75,7 +75,7 @@ pub use upstream::{ BatchBudget, BuiltinScript, ExploreReadOptions, FigmaUpstream, ReadToolCall, RemoteFigmaClient, SearchReadOptions, SnapshotReadOptions, UpstreamResult, }; -pub use url::FigmaTarget; +pub use url::{BRIDGE_CURRENT_KEY, BRIDGE_CURRENT_URL, FigmaTarget, is_bridge_only_key}; pub use variables::{ResourceBatch, ResourceStyleRef, UnresolvedResource}; mod metadata; mod original_image; diff --git a/crates/devup-mcp-figma/src/search.rs b/crates/devup-mcp-figma/src/search.rs index 800365b5..198ce0e3 100644 --- a/crates/devup-mcp-figma/src/search.rs +++ b/crates/devup-mcp-figma/src/search.rs @@ -92,7 +92,7 @@ pub fn search_snapshot( node_type: node.node_type.clone(), page_name, breadcrumb, - canonical_url: canonical_url(target, &node.id), + canonical_url: target.link(Some(&node.id)), match_kind: kind.to_owned(), score, }) @@ -205,21 +205,6 @@ fn ancestor_page( None } -fn canonical_url(target: &FigmaTarget, node_id: &str) -> String { - let node_id = node_id.replace(':', "-"); - if let Some(branch_key) = &target.branch_key { - format!( - "https://www.figma.com/branch/{}/{branch_key}/devup?node-id={node_id}", - target.file_key - ) - } else { - format!( - "https://www.figma.com/design/{}/devup?node-id={node_id}", - target.file_key - ) - } -} - fn levenshtein(left: &str, right: &str) -> usize { let right = right.chars().collect::>(); let mut previous = (0..=right.len()).collect::>(); diff --git a/crates/devup-mcp-figma/src/section.rs b/crates/devup-mcp-figma/src/section.rs index 3606f0ec..a14cf4df 100644 --- a/crates/devup-mcp-figma/src/section.rs +++ b/crates/devup-mcp-figma/src/section.rs @@ -144,7 +144,14 @@ impl SectionIndex { .unwrap_or(&c.canonical_url) .to_owned() }) - .unwrap_or_else(|| format!("https://www.figma.com/design/{}", self.file_key)); + .unwrap_or_else(|| { + FigmaTarget { + file_key: self.file_key.clone(), + node_id: None, + branch_key: None, + } + .link(None) + }); let next = correctable.then(|| serde_json::json!({"tool":"devup_figma_export", "arguments":{"url":format!("{base}?node-id={}", self.section.node_id.replace(':', "-")),"frameIds":corrected}, "how":"The requested node IDs are descendants, not screen IDs. Retry with the containing screens listed here; no selection was changed or exported."})); @@ -311,7 +318,7 @@ pub fn build_section_index( ] }; Ok(SectionCandidate { - canonical_url: canonical_url(target, &node.node_id), + canonical_url: target.link(Some(&node.node_id)), node_id: node.node_id, name: node.name, node_type: node.node_type, @@ -570,21 +577,6 @@ fn breadcrumb(snapshot: &Snapshot, node_id: &str) -> Vec { .collect() } -fn canonical_url(target: &FigmaTarget, node_id: &str) -> String { - let node_id = node_id.replace(':', "-"); - if let Some(branch_key) = &target.branch_key { - format!( - "https://www.figma.com/branch/{}/{branch_key}/devup?node-id={node_id}", - target.file_key - ) - } else { - format!( - "https://www.figma.com/design/{}/devup?node-id={node_id}", - target.file_key - ) - } -} - fn invalid_selection(message: impl Into) -> DevupError { DevupError::new(ErrorCode::DevupFigmaHandoffInvalid, message, false) } diff --git a/crates/devup-mcp-figma/src/url.rs b/crates/devup-mcp-figma/src/url.rs index 82bf7e1c..1e1be440 100644 --- a/crates/devup-mcp-figma/src/url.rs +++ b/crates/devup-mcp-figma/src/url.rs @@ -4,6 +4,30 @@ use serde::{Deserialize, Serialize}; use super::DevupError; +/// The link for the file the one attached Devup Bridge plugin has open. +/// +/// A plugin often cannot say which file that is - `figma.fileKey` comes back +/// empty in Dev Mode - so there is no Figma link to hand an agent. The agent +/// that needed one invented `/design/bridge/bridge` to get past the url field +/// and was then told the invented key was the file's. This is the link to use +/// instead: it routes to the plugin rather than to Figma, `?node-id=` narrows +/// it as on any Figma link, and without one the node selected in Figma is +/// meant. +pub const BRIDGE_CURRENT_URL: &str = "figma-bridge://current"; + +/// Figma file keys never contain `:`, so a key that starts with this can only +/// ever name a bridge plugin - never a file the direct path could fetch. +pub(crate) const BRIDGE_KEY_PREFIX: &str = "bridge:"; + +/// What `figma-bridge://current` parses to: a placeholder the server binds to +/// the attached plugin before anything is read. +pub const BRIDGE_CURRENT_KEY: &str = "bridge:current"; + +/// Whether only a bridge plugin can serve `file_key`. +pub fn is_bridge_only_key(file_key: &str) -> bool { + file_key.starts_with(BRIDGE_KEY_PREFIX) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FigmaTarget { @@ -13,9 +37,30 @@ pub struct FigmaTarget { } impl FigmaTarget { + /// The file the one attached bridge plugin has open, which is what a + /// request made with no url means. + pub fn bridge_current() -> Self { + Self { + file_key: BRIDGE_CURRENT_KEY.to_owned(), + node_id: None, + branch_key: None, + } + } + pub fn parse(input: &str) -> Result { let url = Url::parse(input) .map_err(|_| DevupError::unsupported_file("Not a valid Figma link."))?; + if url.scheme() == "figma-bridge" { + if url.host_str() != Some("current") || !matches!(url.path(), "" | "/") { + return Err(DevupError::unsupported_file( + "The only bridge link is figma-bridge://current, optionally with ?node-id=.", + )); + } + return Ok(Self { + node_id: node_id_param(&url)?, + ..Self::bridge_current() + }); + } if url.scheme() != "https" || !matches!(url.host_str(), Some("figma.com" | "www.figma.com")) { return Err(DevupError::unsupported_file( @@ -44,18 +89,48 @@ impl FigmaTarget { validate_key(branch_key)?; } - let node_id = url - .query_pairs() - .find_map(|(name, value)| (name == "node-id").then(|| value.into_owned())) - .map(|node_id| normalize_node_id(&node_id)) - .transpose()?; - Ok(Self { file_key, - node_id, + node_id: node_id_param(&url)?, branch_key, }) } + + /// Whether this names the file a bridge plugin has open rather than a + /// file Figma itself can serve. + pub fn is_bridge_only(&self) -> bool { + is_bridge_only_key(&self.file_key) + } + + /// A link that routes back to this target, narrowed to `node_id` when one + /// is given. + /// + /// A bridge-only target links as `figma-bridge://current`. Wrapping its key + /// in a Figma URL would hand the caller a link to a file that does not + /// exist, under a key it would then take for the file's own. + pub fn link(&self, node_id: Option<&str>) -> String { + let base = if self.is_bridge_only() { + BRIDGE_CURRENT_URL.to_owned() + } else if let Some(branch_key) = &self.branch_key { + format!( + "https://www.figma.com/branch/{}/{branch_key}/devup", + self.file_key + ) + } else { + format!("https://www.figma.com/design/{}/devup", self.file_key) + }; + match node_id { + Some(node_id) => format!("{base}?node-id={}", node_id.replace(':', "-")), + None => base, + } + } +} + +fn node_id_param(url: &Url) -> Result, DevupError> { + url.query_pairs() + .find_map(|(name, value)| (name == "node-id").then(|| value.into_owned())) + .map(|node_id| normalize_node_id(&node_id)) + .transpose() } fn validate_key(key: &str) -> Result<(), DevupError> { diff --git a/crates/devup-mcp-figma/tests/figma_url.rs b/crates/devup-mcp-figma/tests/figma_url.rs index c09a3c41..223e39ec 100644 --- a/crates/devup-mcp-figma/tests/figma_url.rs +++ b/crates/devup-mcp-figma/tests/figma_url.rs @@ -64,6 +64,61 @@ fn serializes_the_stable_public_error_code() { ); } +/// The placeholder is the file the attached bridge plugin has open, so it +/// parses to a key only the bridge can serve and keeps the node it names. +#[test] +fn the_bridge_link_parses_to_a_bridge_only_target() { + let current = FigmaTarget::parse("figma-bridge://current").expect("bare bridge link"); + assert!(current.is_bridge_only()); + assert_eq!(current, FigmaTarget::bridge_current()); + + let node = FigmaTarget::parse("figma-bridge://current?node-id=1-2").expect("with a node"); + assert!(node.is_bridge_only()); + assert_eq!(node.node_id.as_deref(), Some("1:2")); + + for url in [ + "figma-bridge://other", + "figma-bridge://current/path", + "figma-bridge://current?node-id=abc", + ] { + let error = FigmaTarget::parse(url).expect_err("only figma-bridge://current is a link"); + assert_eq!(error.code, ErrorCode::DevupFigmaUnsupportedFile, "{url}"); + } +} + +/// A link back to a target has to route to the same place. A Figma file keeps +/// its Figma URL; a file only the bridge can read keeps the bridge link, never +/// a Figma URL around a key no Figma file has. +#[test] +fn a_link_routes_back_to_its_own_target() { + let figma = FigmaTarget::parse("https://www.figma.com/design/FileKey123/Name?node-id=1-2") + .expect("Figma link"); + assert!(!figma.is_bridge_only()); + assert_eq!( + figma.link(Some("3:4")), + "https://www.figma.com/design/FileKey123/devup?node-id=3-4" + ); + assert_eq!( + FigmaTarget::parse(&figma.link(Some("3:4"))).expect("round trip"), + FigmaTarget { + node_id: Some("3:4".to_owned()), + ..figma + } + ); + + let bridge = FigmaTarget { + file_key: "bridge:7".to_owned(), + node_id: None, + branch_key: None, + }; + assert!(bridge.is_bridge_only()); + assert_eq!(bridge.link(None), "figma-bridge://current"); + assert_eq!( + bridge.link(Some("3:4")), + "figma-bridge://current?node-id=3-4" + ); +} + #[test] fn errors_do_not_echo_query_secrets() { let error = FigmaTarget::parse( From 2bfbe951d250b3beadd31f7183527bbd4232debb Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 25 Sep 2026 22:07:47 +0900 Subject: [PATCH 02/10] feat(plugin): report the page in view and the selection to devup-mcp The plugin now sends its current page and selected nodes (the first 20, plus the total) with hello, and again on every selectionchange and currentpagechange. devup-mcp uses them to show what an attached plugin has open, and to resolve a call made without a url to the node selected in Figma. dist/ is rebuilt from this source; the build is reproducible. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- plugin/README.md | 14 ++++++++++++ plugin/dist/code.js | 2 +- plugin/dist/ui.html | 2 +- plugin/src/code.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++- plugin/src/ui.ts | 47 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 115 insertions(+), 4 deletions(-) diff --git a/plugin/README.md b/plugin/README.md index f6319d0c..16c8fe64 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -56,6 +56,20 @@ devup-mcp 쪽은 아무 설정도 필요 없습니다. 플러그인이 붙어 스크립트 읽기를 브리지로 보내고, 안 붙어 있으면 호출마다 곧장 공식 MCP 로 넘어갑니다. 켜 두어서 잃는 것은 없습니다. +### 링크 없이 — 지금 선택한 것 + +플러그인은 붙을 때와 그 뒤 페이지·선택이 바뀔 때마다 **보고 있는 페이지와 +선택한 노드**(앞 20개와 전체 수)를 devup-mcp 에 알립니다. 그래서 플러그인이 하나만 +붙어 있으면 `devup_figma_export`·`devup_figma_search`·`devup_figma_explore` 에 +`url` 을 주지 않아도 됩니다 — 이 파일의, Figma 에서 선택한 노드가 대상입니다. +링크 자리가 꼭 필요하면 `figma-bridge://current`(`?node-id=1-2` 로 노드 지정)를 +씁니다. 지금 무엇이 붙어 있고 무엇을 선택했는지는 +`devup_figma_auth { "action": "status" }` 의 `paths.bridge.attachedFiles` 에 나옵니다. + +이 보고가 없는 예전 빌드는 `selection` 이 `null` 로 보이며, 이때는 `frameIds` 로 +노드를 직접 지정해야 합니다. 저장소를 받은 뒤 플러그인을 다시 실행하면 새 빌드가 +쓰입니다. + ## 알아 둘 것 **포트를 바꾸려면 세 곳을 함께 고쳐야 합니다.** Figma 는 플러그인이 접속할 수 있는 diff --git a/plugin/dist/code.js b/plugin/dist/code.js index 5c73c067..18b7c6b4 100644 --- a/plugin/dist/code.js +++ b/plugin/dist/code.js @@ -1 +1 @@ -(()=>{"use strict";let e={assets:async function(e){let t=e.asset;function r(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],l=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=l(r[e-2],17)^l(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,o,s,d,f,u,c,y]=a;for(let e=0;e<64;e+=1){let n=y+(l(f,6)^l(f,11)^l(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,a=(l(i,2)^l(i,13)^l(i,22))+(i&o^i&s^o&s)>>>0;y=c,c=u,u=f,f=d+n>>>0,d=s,s=o,o=i,i=n+a>>>0}for(let[e,t]of[i,o,s,d,f,u,c,y].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}function n(e){return{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:t.scale,status:"failed",byteLength:null,sha256:null,errorCode:e}}let i="string"==typeof t.field&&t.field.startsWith("$original-image/fills/");try{if(i&&"bridge"!==t.transport)return n("DEVUP_ORIGINAL_IMAGE_REQUIRES_BRIDGE");let e=await figma.getNodeByIdAsync(t.nodeId);if(!e||!i&&"function"!=typeof e.exportAsync)return n("DEVUP_ASSET_UNSUPPORTED_BY_UPSTREAM");if(i||"string"==typeof t.field&&t.field.startsWith("fills/")){let r=Number(t.field.slice(i?22:6)),a="fills"in e&&Array.isArray(e.fills)?e.fills:[],l=Number.isInteger(r)?a[r]:null,o=l&&"IMAGE"===l.type?l.imageHash||l.imageRef:null;if(!l||o!==t.imageHash)return n("DEVUP_ASSET_SOURCE_CHANGED")}else if("node"!==t.field)return n("DEVUP_ASSET_FIELD_UNSUPPORTED");if(i){let e=figma.getImageByHash(t.imageHash);if(!e)return n("DEVUP_ORIGINAL_IMAGE_NOT_FOUND");let i=await e.getBytesAsync();if(0===i.length||i.length>8388608)return n("DEVUP_ASSET_RESPONSE_TOO_LARGE");let a=e=>e.every((e,t)=>i[t]===e),l=a([137,80,78,71,13,10,26,10])?"image/png":a([255,216,255])?"image/jpeg":a([71,73,70,56])?"image/gif":a([82,73,70,70])&&87===i[8]&&69===i[9]&&66===i[10]&&80===i[11]?"image/webp":null;if(!l)return n("DEVUP_ORIGINAL_IMAGE_CODEC_UNSUPPORTED");let o=await e.getSizeAsync();return{...n(null),status:"exported",kind:"devupOriginalImage",representation:"original-image-v1",format:null,scale:null,mimeType:l,width:o.width,height:o.height,byteLength:i.length,sha256:r(i),data:figma.base64Encode(i)}}let a=String(t.format||"").toUpperCase();if(!["PNG","JPG","SVG","PDF"].includes(a))return n("DEVUP_ASSET_FORMAT_UNSUPPORTED");let l=Math.min(4,Math.max(1,Math.floor(Number(t.scale)||1))),o="SVG"===a,s={format:o?"SVG_STRING":a};("PNG"===a||"JPG"===a)&&(s.constraint={type:"SCALE",value:l});let d=await e.exportAsync(s),f=o&&"string"==typeof d?d:null,u=null===f?d instanceof Uint8Array?d:new Uint8Array(d):function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(f);if(0===u.length||u.length>8388608)return n("DEVUP_ASSET_RESPONSE_TOO_LARGE");let c=r(u),y=figma.io&&"function"==typeof figma.io.write,h="PNG"===a&&u.length>786432;if(y&&(null!==f&&u.length>12288||h))return{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:l,status:"chunked",byteLength:u.length,sha256:c,cursor:{nextOffset:0,maxChunkBytes:12288},errorCode:null};return y&&figma.io.write(`devup-asset-${t.assetId.replace(/[^A-Za-z0-9_-]/g,"_")}.${String(t.format).toLowerCase()}`,u),{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:l,status:"exported",byteLength:u.length,sha256:c,mimeType:y?null===f?null:"image/svg+xml":({PNG:"image/png",JPG:"image/jpeg",SVG:"image/svg+xml",PDF:"application/pdf"})[a],...!y&&null===f?{data:figma.base64Encode(u)}:{},text:f,errorCode:null}}catch(e){return n(i?"DEVUP_ORIGINAL_IMAGE_READ_FAILED":"DEVUP_ASSET_EXPORT_FAILED")}},explore:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=e.explore,n=Math.max(20,Math.min(400,Number.isInteger(r.projectionLimit)?r.projectionLimit:200)),i=Math.max(0,Math.min(500,Number.isInteger(r.textPreviewLimit)?r.textPreviewLimit:160));function a(e){return"string"==typeof e?e.slice(0,240):""}let l=t,o="SECTION"===t.type?t:null;for(;l.parent&&"PAGE"!==l.parent.type;)l=l.parent,o||"SECTION"!==l.type||(o=l);let s="PAGE"===t.type?t:l.parent;if(!s||"PAGE"!==s.type)throw Error("DEVUP_PAGE_NOT_FOUND");function d(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function f(e){let t=d(e);return t&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&t.width/Math.max(1,t.height)>=.25&&t.width/Math.max(1,t.height)<=2.5}function u(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type&&t.length<12;){let e=a(r.name);e&&t.push(e),r=r.parent}return t.reverse()}await figma.setCurrentPageAsync(s);let c=!f(t)&&o?o:l,y=d(c)||d(t);if(!y)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let h="children"in s?s.children:[],g=h.map((e,t)=>({node:e,pageChildIndex:t,bounds:d(e)})).filter(e=>e.bounds).filter(e=>{var t;return e.node.id===c.id||e.bounds.y>=y.y-240&&e.bounds.y<=y.y+12e3&&(t=e.bounds,Math.min(t.x+t.width,y.x+y.width)-Math.max(t.x,y.x)>0)}).sort((e,t)=>e.bounds.y-t.bounds.y||e.bounds.x-t.bounds.x||e.node.id.localeCompare(t.node.id)),p="SECTION"===c.type?g.filter(e=>e.node.id===c.id):g.slice(0,n),m=new Map(p.map(e=>[e.node.id,e]));m.has(l.id)||m.set(l.id,{node:l,pageChildIndex:-1,bounds:d(l)}),m.has(c.id)||m.set(c.id,{node:c,pageChildIndex:-1,bounds:d(c)}),m.has(t.id)||m.set(t.id,{node:t,pageChildIndex:-1,bounds:d(t)});let b=t.parent;for(;b&&"PAGE"!==b.type&&(m.has(b.id)||m.set(b.id,{node:b,pageChildIndex:-1,bounds:d(b)}),b.id!==c.id);)b=b.parent;if("children"in c)for(let e of c.children.slice(0,n))m.has(e.id)||m.set(e.id,{node:e,pageChildIndex:-1,bounds:d(e)});let x="SECTION"===c.type&&"children"in c&&c.children.length>n;if("SECTION"===c.type&&"children"in c){let e=8*n,t=c.children.filter(e=>!f(e)).map(e=>({node:e,ancestors:[]})),r=0;for(;t.length&&r!m.has(e.id));if(m.size+t.length>n+2){x=!0;continue}for(let e of t)m.set(e.id,{node:e,pageChildIndex:-1,bounds:d(e)});continue}if("children"in e)for(let r of e.children)t.push({node:r,ancestors:[...i,e]})}x||=t.length>0}let E=[...m.values()].filter(e=>e.bounds).slice(0,n+2).map(({node:e,pageChildIndex:t,bounds:r})=>({id:e.id,type:e.type,fields:{name:a(e.name),parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:[],x:r.x,y:r.y,width:r.width,height:r.height,childCount:"children"in e?e.children.length:0,textPreview:"",pageChildIndex:t>=0?t:null,visible:!("visible"in e)||!1!==e.visible,breadcrumb:u(e)},extra:{},fieldErrors:{}})),I=E.reduce((e,t)=>{let r=t.fields;return e?{x:Math.min(e.x,r.x),y:Math.min(e.y,r.y),right:Math.max(e.right,r.x+r.width),bottom:Math.max(e.bottom,r.y+r.height)}:{x:r.x,y:r.y,right:r.x+r.width,bottom:r.y+r.height}},null)||{x:0,y:0,right:0,bottom:0},S="SECTION"!==c.type&&g.length>p.length||m.size>n+2||x,A={id:s.id,type:s.type,fields:{name:a(s.name),parentId:null,childrenIds:[],x:I.x,y:I.y,width:I.right-I.x,height:I.bottom-I.y,childCount:h.length,textPreview:"",projectionTruncated:S,visible:!0,breadcrumb:u(s),pageChildIndex:null},extra:{},fieldErrors:{}},N={fileKey:figma.fileKey||"",version:null,rootIds:[s.id],nodes:[A,...E.filter(e=>e.id!==s.id)],diagnostics:[]},w=new Set([s.id,l.id,c.id,t.id]);for(;JSON.stringify(N).length>14e3;){let e=N.nodes.length-1;for(;e>=0&&w.has(N.nodes[e].id);)e-=1;if(e<0)break;N.nodes.splice(e,1),A.fields.projectionTruncated=!0}if(JSON.stringify(N).length>14e3&&(N.nodes=N.nodes.filter(e=>w.has(e.id)).map(e=>({...e,fields:{...e.fields,name:a(e.fields.name).slice(0,80),textPreview:"",breadcrumb:e.fields.breadcrumb.slice(-4).map(e=>e.slice(0,80)),projectionTruncated:e.id===s.id||e.fields.projectionTruncated}}))),JSON.stringify(N).length>14e3)throw Error("DEVUP_EXPLORE_PROJECTION_TOO_LARGE");let O=14e3-JSON.stringify(N).length;if(i>0&&O>0){let e=N.nodes.filter(e=>m.get(e.id)&&e.id!==s.id),t=e.length;for(let r of e){let e=Math.floor(O/t);if(t-=1,e<=0)continue;let n="",a=0;for(let t of function(e){if(0===i)return"";let t=[],r=[e],n=0;for(;r.length&&n<80&&t.join(" ").lengthe)break;n+=t,a+=r}r.fields.textPreview=n,O-=a}}return N},fastSnapshot:async function(e){let t=e.rootIds;if(!Array.isArray(t)||0===t.length)throw Error("DEVUP_ROOTS_INVALID");let r=await Promise.all(t.map(e=>figma.getNodeByIdAsync(e)));if(r.some(e=>!e))throw Error("DEVUP_NODE_NOT_FOUND");if(1===r.length&&"SECTION"===r[0].type)throw Error("DEVUP_TARGET_IS_SECTION");let n=["mobile","tablet","desktop"],i=e=>n.indexOf(String(e.name||"").trim().toLowerCase());if(1===r.length&&i(r[0])>=0){let e=r[0].parent;if(e&&"SECTION"===e.type&&"children"in e){let t=e.children.filter(e=>e.id===r[0].id||i(e)>=0).filter(e=>!1!==e.visible);t.length>1&&(r.length=0,r.push(...t))}}if(1===t.length){let e=[],t=new Set(r.map(e=>e.id)),n=[],i=e=>{if("reactions"in e&&Array.isArray(e.reactions)){for(let r of e.reactions)if(r&&r.trigger&&"AFTER_TIMEOUT"===r.trigger.type)for(let e of r.actions||[])e&&"NODE"===e.type&&e.transition&&"SMART_ANIMATE"===e.transition.type&&"string"==typeof e.destinationId&&!t.has(e.destinationId)&&(t.add(e.destinationId),n.push(e.destinationId));if("children"in e)for(let t of e.children)i(t)}};for(let e of r)i(e);for(;n.length>0;){let t=n.shift(),r=await figma.getNodeByIdAsync(t);r&&"DOCUMENT"!==r.type&&"PAGE"!==r.type&&(e.push(r),i(r))}r.push(...e)}let a=e.nodeId,l=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],o=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],s=e.snapshot,d=Math.max(0,Math.floor(Number(s.offset)||0)),f=Math.max(8192,Math.floor(Number(s.maxEnvelopeBytes)||19456)),u=Math.min(s.maxEnvelopeBytes?f-1024:18e3,Math.max(4096,Math.floor(Number(s.maxPayloadBytes)||15e3))),c=new Set(["backgroundStyleId","effectStyleId","fillStyleId","gridStyleId","strokeStyleId","textStyleId"]),y=new Set(["maxWidth","maxHeight","absoluteRenderBounds"]),h=new Map([["rotation",0],["cornerRadius",0],["isAsset",!1],["isMask",!1],["clipsContent",!1],["blendMode","PASS_THROUGH"],["strokeAlign","INSIDE"],["textCase","ORIGINAL"],["textDecoration","NONE"],["textAlignHorizontal","LEFT"],["textAlignVertical","TOP"],["counterAxisAlignItems","MIN"],["primaryAxisAlignItems","MIN"],["gridColumnCount",0],["gridRowCount",0],["gridColumnGap",0],["gridRowGap",0],["gridColumnAnchorIndex",-1],["gridRowAnchorIndex",-1],["gridColumnSpan",1],["gridRowSpan",1]]),g=new Set(["start","end","characters","fontWeight","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]),p=new Set(["parent","children","consumers"]);function m(e,t=!1,r=new WeakSet,n=0){let i;if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>m(e,t,r,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(r.has(e))return{$circular:!0};if(r.add(e),t){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}i=[...t].sort().filter(e=>!e.startsWith("_")&&!p.has(e))}else i=Object.keys(e).sort();let a={};for(let l of i)try{let i=m(e[l],t,r,n+1);i&&"function"===i.$unsupported||(a[l]=i)}catch(e){a[l]=t?{$error:"unavailable"}:{$error:String(e&&e.message?e.message:e)}}return r.delete(e),a}let b=[],x=[...r],E=new Set;for(let e=0;e=b.length&&b.length>0)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");function I(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1({id:e,styleType:t})).sort((e,t)=>e.id.localeCompare(t.id)),a=await Promise.all([...n.map(async e=>{try{let t=await figma.variables.getVariableByIdAsync(e);return t?{kind:"variable",value:m(t,!0),collectionId:t.variableCollectionId}:{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable"}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable"}}}}),...i.map(async({id:e,styleType:t})=>{try{let r=await figma.getStyleByIdAsync(e);if(!r)return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable"}};return{kind:"style",value:{...m(r,!0),styleType:t,value:m("PAINT"===t?r.paints:"EFFECT"===t?r.effects:"GRID"===t?r.layoutGrids:r,!0)}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable"}}}})]),l=[...new Set(a.filter(e=>"variable"===e.kind&&e.collectionId).map(e=>e.collectionId))].sort(),o=(await Promise.all(l.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?m(t,!0):null}catch(e){return null}}))).filter(e=>null!==e),s=a.filter(e=>"variable"===e.kind).map(e=>e.value),d=a.filter(e=>"style"===e.kind).map(e=>e.value),f=a.filter(e=>"unresolved"===e.kind).map(e=>e.value);return{collections:o,variables:s,styles:d,usedRemoteVariables:s.filter(e=>!0===e.remote),usedVariableIds:n,usedStyleIds:i.map(e=>e.id),localComplete:!1,usedRemoteComplete:0===f.length,unresolved:f,$variableRefCount:n.length,$styleRefCount:i.length}}let A=u-1024,N=null;for(let e=0;e<5;e+=1){let{pageNodes:e,packedBytes:t}=function(e){let t=[],r=2;for(let n=d;ne.id):[];for(let i of(n.length>0&&(t.childrenIds=n),l)){let n;try{if(!(i in e)){"overflowDirection"===i&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t[i]=null);continue}if(n=e[i],"function"==typeof n)continue;let r=m(n);if("overflowDirection"===i&&null==n){t[i]=null;continue}(null===r?!y.has(i):Array.isArray(r)?0===r.length:"object"==typeof r?0===Object.keys(r).length:!!(""===r&&c.has(i))||h.has(i)&&h.get(i)===r)||(t[i]=r)}catch(e){r[i]=String(e&&e.message?e.message:e)}}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{let r=m(e.getStyledTextSegments(o));if(1===r.length){let e=r[0];for(let t of Object.keys(e))g.has(t)||delete e[t]}r.length>0&&(t.styledTextSegments=r)}catch(e){r.styledTextSegments=String(e&&e.message?e.message:e)}let i={id:e.id,type:e.type,fields:t};return Object.keys(r).length>0&&(i.fieldErrors=r),i}(b[n]),a=I(JSON.stringify(i))+ +!!t.length;if(t.length&&r+a>e)break;t.push(i),r+=a}return{pageNodes:t,packedBytes:r}}(A);if(0===e.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let n=function(e,t){let n=Math.min(b.length,d+e.length),{$variableRefCount:i,$styleRefCount:l,...o}=t,s=[...e,{id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:d,nextOffset:n,complete:n>=b.length,totalNodes:b.length},extra:{},fieldErrors:{}}],f={kind:"devupFastSnapshotEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",rootId:a},snapshot:{fileKey:figma.fileKey||"",version:null,rootIds:r.map(e=>e.id),nodes:s,diagnostics:[]},resources:o,integrity:{nodeCount:s.length,variableRefCount:i,styleRefCount:l,utf8Bytes:0}},u=0;for(let e=0;e<8&&(u=I(JSON.stringify(f)),f.integrity.utf8Bytes!==u);e+=1)f.integrity.utf8Bytes=u;if(f.integrity.utf8Bytes!==I(JSON.stringify(f)))throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:f,bytes:u}}(e,await S(e));if(n.bytes<=f){N=n;break}if(1===e.length)throw Error("DEVUP_ENVELOPE_TOO_LARGE");A=Math.max(1,Math.min(A-1,t-(n.bytes-f)-256))}if(!N)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return N.envelope},fastTheme:async function(e){let t=Math.max(0,Math.floor(Number(e.theme.offset)||0));function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={};for(let a of function(e){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}return[...t].sort()}(e))if(!(a.startsWith("_")||["parent","children","consumers"].includes(a)))try{let l=r(e[a],t,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:"unavailable"}}return t.delete(e),i}let n=new Set,i=new Map;for(let e of[figma.root,...figma.root.findAll(()=>!0)])for(let t of["boundVariables","fills","strokes","effects","layoutGrids","textStyleId","fillStyleId","strokeStyleId","backgroundStyleId","effectStyleId","gridStyleId"])try{!function e(t,r="",a=new WeakSet,l=0){if(!(l>16)&&null!==t&&"object"==typeof t&&!a.has(t)){if(a.add(t),Array.isArray(t)){for(let n of t)e(n,r,a,l+1);return}for(let[o,s]of("VARIABLE_ALIAS"===t.type&&"string"==typeof t.id&&t.id&&"figma.mixed"!==t.id&&"MIXED"!==t.id&&n.add(t.id),Object.entries(t))){let t="textStyleId"===o?"TEXT":["fillStyleId","strokeStyleId","backgroundStyleId"].includes(o)?"PAINT":"effectStyleId"===o?"EFFECT":"gridStyleId"===o?"GRID":null;t&&"string"==typeof s&&s&&"figma.mixed"!==s&&"MIXED"!==s&&!i.has(s)&&i.set(s,t),e(s,o||r,a,l+1)}}}({[t]:e[t]})}catch(e){}let[a,l,o,s,d,f]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.variables.getLocalVariablesAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),u=new Set(l.map(e=>e.id)),c=new Set([...o,...s,...d,...f].map(e=>e.id)),y=[],h=[...n].filter(e=>!u.has(e)).sort().map(async e=>{try{return await figma.variables.getVariableByIdAsync(e)||null}catch(t){return y.push({id:e,kind:"variable",reason:"notFoundOrUnavailable"}),null}}),g=[...i.entries()].filter(([e])=>!c.has(e)).sort(([e],[t])=>e.localeCompare(t)).map(async([e,t])=>{try{let r=await figma.getStyleByIdAsync(e);return r?{style:r,styleType:t}:null}catch(t){return y.push({id:e,kind:"style",reason:"notFoundOrUnavailable"}),null}}),p=(await Promise.all(h)).filter(Boolean),m=(await Promise.all(g)).filter(Boolean);for(let e of[...n].filter(e=>!u.has(e)))p.some(t=>t.id===e)||y.some(t=>t.id===e)||y.push({id:e,kind:"variable",reason:"notFoundOrUnavailable"});for(let[e]of[...i.entries()].filter(([e])=>!c.has(e)))m.some(t=>t.style.id===e)||y.some(t=>t.id===e)||y.push({id:e,kind:"style",reason:"notFoundOrUnavailable"});let b=[...new Set(p.map(e=>e.variableCollectionId))].filter(e=>!a.some(t=>t.id===e)).sort(),x=(await Promise.all(b.map(async e=>{try{return await figma.variables.getVariableCollectionByIdAsync(e)}catch(e){return null}}))).filter(Boolean);function E(e,t){return{...r(e),styleType:t,value:r("PAINT"===t?e.paints:"EFFECT"===t?e.effects:"GRID"===t?e.layoutGrids:e)}}let I=[...o.map(e=>E(e,"PAINT")),...s.map(e=>E(e,"TEXT")),...d.map(e=>E(e,"EFFECT")),...f.map(e=>E(e,"GRID")),...m.map(({style:e,styleType:t})=>E(e,t))].sort((e,t)=>e.id.localeCompare(t.id)),S=[...l,...p].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id)),A=[...a,...x].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id));function N(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}y.sort((e,t)=>e.kind.localeCompare(t.kind)||e.id.localeCompare(t.id));let w=[...A.map(e=>({kind:"collection",value:e})),...S.map(e=>({kind:"variable",value:e})),...I.map(e=>({kind:"style",value:e})),...[...n].sort().map(e=>({kind:"usedVariableId",value:e})),...[...i.keys()].sort().map(e=>({kind:"usedStyleId",value:e})),...y.map(e=>({kind:"unresolved",value:e}))];if(t>w.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let O=w.map(e=>N(JSON.stringify(e.value)).length+1),T=18432,v=null;for(let e=0;e<6;e+=1){let{pageItems:e,packed:r}=function(e){let r=[],n=0;for(let i=t;i0)||!(n+O[i]>e));i+=1)r.push(w[i]),n+=O[i];return{pageItems:r,packed:n}}(T),n=function(e,r){let n=t=>e.filter(e=>e.kind===t).map(e=>e.value),i=n("collection"),a=n("variable"),l=n("style"),o=n("unresolved"),s={kind:"devupFastThemeEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",version:null},resources:{collections:i,variables:a,styles:l,usedRemoteVariables:[],usedVariableIds:n("usedVariableId"),usedStyleIds:n("usedStyleId"),localComplete:!0,usedRemoteComplete:0===y.length,unresolved:o},page:{offset:t,nextOffset:r,complete:r>=w.length,totalItems:w.length},integrity:{collectionCount:i.length,variableCount:a.length,styleCount:l.length,unresolvedCount:o.length,utf8Bytes:0}},d=new Uint8Array;for(let e=0;e<8&&(d=N(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length);e+=1)s.integrity.utf8Bytes=d.length;if(d=N(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length)throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:s,bytes:d.length}}(e,t+e.length);if(n.bytes<=19456){v=n;break}if(e.length<=1)break;T=Math.max(1,Math.min(T-1,r-(n.bytes-19456)-256))}if(null===v||v.bytes>8388608)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return v.envelope},largeValue:async function(e){let t,r=e.largeValue,n=await figma.getNodeByIdAsync(r.nodeId);if(!n)throw Error("DEVUP_NODE_NOT_FOUND");function i(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}let a=null;try{if("$export:svg"===r.field){if("function"!=typeof n.exportAsync)throw Error("unsupported");let e=await n.exportAsync({format:"SVG_STRING"});if("string"!=typeof e)throw Error("unsupported");a=i(e)}else if("string"==typeof r.field&&r.field.startsWith("$export:png")){if("function"!=typeof n.exportAsync)throw Error("unsupported");let e=Math.min(4,Math.max(1,Math.floor(Number(r.field.split("@")[1])||1))),t=await n.exportAsync({format:"PNG",constraint:{type:"SCALE",value:e}});a=t instanceof Uint8Array?t:new Uint8Array(t)}else if("styledTextSegments"===r.field&&"TEXT"===n.type&&"function"==typeof n.getStyledTextSegments)t=n.getStyledTextSegments(["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]);else if(r.field in n)t=n[r.field];else throw Error("unsupported")}catch(e){return{kind:"devupLargeValueUnsupported",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,byteLength:r.byteLength,sha256:r.sha256,errorCode:"DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM"}}let l=null===a?i(JSON.stringify(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let l=e(t[a],r,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(t))):a,o=function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=e.length,n=64*Math.ceil((r+9)/64),i=new Uint8Array(n);i.set(e),i[r]=128;let a=8*r;for(let e=0;e<8;e+=1)i[n-1-e]=255&Math.floor(a/2**(8*e));let l=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],o=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=o(r[e-2],17)^o(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[n,a,s,d,f,u,c,y]=l;for(let e=0;e<64;e+=1){let i=y+(o(f,6)^o(f,11)^o(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,l=(o(n,2)^o(n,13)^o(n,22))+(n&a^n&s^a&s)>>>0;y=c,c=u,u=f,f=d+i>>>0,d=s,s=a,a=n,n=i+l>>>0}for(let[e,t]of[n,a,s,d,f,u,c,y].entries())l[e]=l[e]+t>>>0}return l.map(e=>e.toString(16).padStart(8,"0")).join("")}(l);if(l.length!==r.byteLength||o!==r.sha256)throw Error("DEVUP_LARGE_VALUE_CHANGED");let s=Math.max(0,Math.floor(Number(r.offset)||0)),d=Math.min(65536,Math.max(1,Math.floor(Number(r.maxChunkBytes)||8192)));if(s>=l.length)throw Error("DEVUP_LARGE_VALUE_RANGE_INVALID");let f=Math.min(l.length,s+d);return{kind:"devupLargeValueFragment",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,offset:s,nextOffset:f,byteLength:l.length,sha256:o,dataBase64:function(e){let t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",r="";for(let n=0;n>2],r+=t[(3&i)<<4|a>>4],r+=n+1>6]:"=",r+=n+20;){let e=t[t.length-1],i=e.node,a="children"in i?i.children:[];if(!e.visited){e.visited=!0;for(let e=a.length-1;e>=0;e-=1)t.push({node:a[e],visited:!1});continue}t.pop();let l=0,o=[];for(let e of a)o.push(e.id),l+=1+(r.get(e.id)||0);r.set(i.id,l),n.push({id:i.id,type:i.type,name:i.name,childrenIds:o,descendantCount:l})}}(t),{fileKey:figma.fileKey||"",version:null,rootId:t.id,nodes:n}},pageCatalog:async function(e){let t=figma.root.children;return{fileKey:figma.fileKey||"",version:null,rootIds:t.map(e=>e.id),nodes:t.map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:null,childrenIds:[]},extra:{},fieldErrors:{}})),diagnostics:[]}},search:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=t;for(;r&&"PAGE"!==r.type;)r=r.parent;if(!r||"PAGE"!==r.type)throw Error("DEVUP_PAGE_NOT_FOUND");await figma.setCurrentPageAsync(r);let n=e.search,i=new Set((n.nodeTypes.length?n.nodeTypes:["PAGE","SECTION","FRAME","COMPONENT_SET","COMPONENT"]).map(e=>e.toUpperCase()));function a(e){return e.normalize("NFC").toLocaleLowerCase().replace(/\s/gu,"")}let l=t===r?r.findAll(()=>!0):"findAll"in t?t.findAll(()=>!0):[],o=[t,...l].filter(e=>i.has(e.type)&&"string"==typeof e.name).map(e=>({node:e,score:function(e){if(e===n.query)return 400;if("exact"===n.matchKind)return null;let t=a(e),r=a(n.query);if(t===r)return 300;if(t.startsWith(r))return 200;if(t.includes(r))return 100;if("fuzzy"!==n.matchKind)return null;let i=function(e,t){let r=Array.from({length:t.length+1},(e,t)=>t);for(let n=0;nnull!==e.score).sort((e,t)=>t.score-e.score||e.node.name.localeCompare(t.node.name)||e.node.id.localeCompare(t.node.id)).slice(0,n.limit),s=new Map([[r.id,r]]);for(let e of[t,...o.map(e=>e.node)]){let t=e;for(;t&&"DOCUMENT"!==t.type;)s.set(t.id,t),t=t.parent}return{fileKey:figma.fileKey||"",version:null,rootIds:[r.id],nodes:[...s.values()].map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:"children"in e?e.children.map(e=>e.id):[]},extra:{},fieldErrors:{}})),diagnostics:[]}},sectionIndex:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");if("SECTION"!==t.type){let e=t.parent,r=new Set;for(;e&&"SECTION"!==e.type&&!r.has(e.id);)r.add(e.id),e=e.parent;let n=e&&"SECTION"===e.type?e.id:null,i=n&&figma.fileKey?`https://www.figma.com/design/${figma.fileKey}?node-id=${n.replace(/:/g,"-")}`:null;throw Error("DEVUP_SECTION_REQUIRED "+JSON.stringify({pluginCode:"DEVUP_SECTION_REQUIRED",stage:"section-index",nodeId:t.id,nodeType:t.type,sectionId:n,nextAction:{tool:"devup_figma_export",how:n?`The url must point to a SECTION. This capture's SECTION is ${n}; select frames with frameIds.`:"The url must point to a SECTION. This node has no ancestor SECTION; choose the intended SECTION in Figma and copy its link.",arguments:i?"FRAME"===t.type&&t.parent===e?{url:i,frameIds:[t.id]}:{url:i}:null,requiredArguments:i?[]:["url"]}}))}let r=2048;function n(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function i(e,t){if("FRAME"!==e.type||!1===e.visible||!t)return!1;let r=t.width/Math.max(1,t.height);return t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&r>=.25&&r<=2.5}function a(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1[e.id,t.id])),s=[],d=0;for(let e=0;ee.id));for(let r of t.children){if(e.has(r.id))continue;let t=n(r);t&&!1!==r.visible&&s.push({node:r,box:t})}}s.sort((e,t)=>e.box.y-t.box.y||e.box.x-t.box.x||e.node.id.localeCompare(t.node.id));let f=l.length>d||s.length>100,u=s.slice(0,100),c=new Set(u.map(({node:e})=>e.id)),y=new Set(e.rootIds),h={},g=new Map([[t.id,null]]);for(let e=0;e{let r=e.parent;for(;r&&r.id!==t.id&&!c.has(r.id);)r=r.parent;return[e.id,r&&c.has(r.id)?r.id:t.id]})),m=e=>u.filter(({node:t})=>p.get(t.id)===e).map(({node:e})=>e.id),b=n(t);if(!b)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let x={id:t.id,type:t.type,fields:{name:t.name,parentId:t.parent&&"DOCUMENT"!==t.parent.type?t.parent.id:null,childrenIds:m(t.id),absoluteBoundingBox:b,visible:!1!==t.visible,projectionTruncated:f,nodeScreenIds:h},extra:{},fieldErrors:{}},E=u.length,I=u.map(({node:e,box:t})=>{let n=function(e){let t=[e],r=0,n=0;for(let e=0;er}}(e),l=Math.floor(r/E);E-=1;let o=function(e,t){let r=[e],n="",i=0,l=0,o=!1;if(t<=0)return{text:"",state:"budget-exhausted",spent:0};for(let e=0;e=120||l+e>t)return{text:n.trimEnd(),state:l+e>t?"budget-exhausted":"truncated",spent:l};n+=r,i+=1,l+=e}}if("children"in s){let e=Math.max(0,64-r.length);s.children.length>e&&(o=!0),r.push(...s.children.slice(0,e))}}}return{text:n,state:o?"truncated":n?"available":"no-text",spent:l}}(e,l);return r-=o.spent,{id:e.id,type:e.type,fields:{name:"string"==typeof e.name?e.name:"",parentId:p.get(e.id),childrenIds:m(e.id),absoluteBoundingBox:t,visible:!1!==e.visible,breadcrumb:function(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type;)"string"==typeof r.name&&r.name&&t.push(r.name),r=r.parent;return t.reverse()}(e),directChildCount:"children"in e?e.children.length:0,textPreview:o.text,textPreviewState:o.state,subtreeNodeCount:n.subtreeNodeCount,estimatedSerializedBytes:n.estimatedSerializedBytes,selectionReasons:[i(e,t)?"screen-like":"explicit-selection-only","inside-section"],estimateTruncated:n.truncated},extra:{},fieldErrors:{}}}),S={fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:[x,...I],diagnostics:[]},A=a(JSON.stringify(S));for(let e=I.length-1;e>=0&&A>19456;e-=1)I[e].fields.textPreview="",I[e].fields.textPreviewState="budget-exhausted",A=a(JSON.stringify(S));let N=()=>S.nodes.length-1,w=I.length;for(;A>19456&&N()>1;){let e=S.nodes.pop();x.fields.projectionTruncated=!0,x.fields.childrenIds=x.fields.childrenIds.filter(t=>t!==e.id),A=a(JSON.stringify(S))}if(A>19456)throw Error("DEVUP_SECTION_INDEX_TOO_LARGE "+JSON.stringify({pluginCode:"DEVUP_SECTION_INDEX_TOO_LARGE",stage:"section-index",responseBytes:A,maxResponseBytes:19456,discoveredCandidates:w,retainedCandidates:N()}));return S},snapshot:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],n=new Set(r),i=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],a=e.snapshot,l=Math.max(0,Math.floor(Number(a.offset)||0)),o=Math.min(a.maxEnvelopeBytes?Math.max(8192,Math.floor(Number(a.maxEnvelopeBytes))):16e3,Math.max(4096,Math.floor(Number(a.maxPayloadBytes)||15e3))),s=Math.min(o-1024,Math.max(512,Math.floor(Number(a.maxFieldBytes)||4096))),d=new Set(["id","type","parent","children"]),f=new Set(["parentId","childrenIds","name","characters","styledTextSegments","boundVariables"]);function u(e,t,r){let n=function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(JSON.stringify(r));return n.length>0x1000000?{$truncated:"max-large-value-bytes",byteLength:n.length}:{$largeValue:{nodeId:e,field:t,byteLength:n.length,sha256:function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],l=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=l(r[e-2],17)^l(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,o,s,d,f,u,c,y]=a;for(let e=0;e<64;e+=1){let n=y+(l(f,6)^l(f,11)^l(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,a=(l(i,2)^l(i,13)^l(i,22))+(i&o^i&s^o&s)>>>0;y=c,c=u,u=f,f=d+n>>>0,d=s,s=o,o=i,i=n+a>>>0}for(let[e,t]of[i,o,s,d,f,u,c,y].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}(n),cursor:{nextOffset:0,maxChunkBytes:12288}}}}function c(e){return function(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+112)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let l=e(t[a],r,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(r),a=c(i);if(a<=s)return i;let l=u(e,t,i);return l.$truncated&&(n[t]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${a}>16777216`),l}let h=[],g=[t];for(;g.length;){let e=g.shift();h.push(e),"children"in e&&g.push(...e.children)}let p=[],m=o-1024,b=2;for(let e=l;eNumber(e.protected)-Number(t.protected)||t.byteLength-e.byteLength),n)){if(r<=t)break;let n=u(e.id,i.name,e[i.sectionName][i.name]);e[i.sectionName][i.name]=n,n.$truncated&&(e.fieldErrors[i.name]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${i.byteLength}>16777216`),r=c(e)}return e}(function(e){let t={},a={},l={};for(let i of(t.parentId=e.parent?e.parent.id:null,e.parent&&("PAGE"===e.parent.type||"SECTION"===e.parent.type||"COMPONENT_SET"===e.parent.type)&&(t.parentType=e.parent.type,"SECTION"===e.parent.type&&(t.parentName=e.parent.name)),t.childrenIds="children"in e?e.children.map(e=>e.id):[],!("overflowDirection"in e)&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t.overflowDirection=null),function(e){let t=new Set,n=e;for(;n&&n!==Object.prototype;){for(let e of Object.getOwnPropertyNames(n))t.add(e);n=Object.getPrototypeOf(n)}for(let n of r)try{n in e&&t.add(n)}catch(e){}return[...t].sort()}(e)))if(!(d.has(i)||i.startsWith("_")))try{let r=e[i];if("function"==typeof r)continue;let o=y(e.id,i,r,l);(n.has(i)?t:a)[i]=o}catch(e){l[i]=String(e&&e.message?e.message:e)}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{t.styledTextSegments=y(e.id,"styledTextSegments",e.getStyledTextSegments(i),l)}catch(e){l.styledTextSegments=String(e&&e.message?e.message:e)}return{id:e.id,type:e.type,fields:t,extra:a,fieldErrors:l}}(h[e]),m),a=c(t)+ +!!p.length;if(p.length&&b+a>m)break;p.push(t),b+=a}let x=Math.min(h.length,l+p.length);return p.push({id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:l,nextOffset:x,complete:x>=h.length,totalNodes:h.length},extra:{},fieldErrors:{}}),{fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:p,diagnostics:[]}},usedResources:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),l=e;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!(l.startsWith("_")||["parent","children","consumers"].includes(l)))try{let a=r(e[l],t,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:"unavailable"}}return t.delete(e),i}let n=new Map,i="ok";try{for(let e of(await figma.variables.getLocalVariablesAsync()))n.set(e.id,e)}catch(e){i="unavailable"}let a=await Promise.all(t.variableIds.map(async e=>{let t=n.get(e);if(t)return{value:r(t),collectionId:t.variableCollectionId};try{let t=await figma.variables.getVariableByIdAsync(e);return t?{value:r(t),collectionId:t.variableCollectionId}:{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupEmpty"}}}catch(t){return{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupThrew"}}}})),l=[...new Set(a.flatMap(e=>e.collectionId?[e.collectionId]:[]))].sort(),o=await Promise.all(l.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?[r(t)]:[]}catch(e){return[]}})),s=await Promise.all(t.styles.map(async e=>{try{let t=await figma.getStyleByIdAsync(e.id);if(!t)return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable"}};return{value:{...r(t),styleType:e.styleType,value:r("PAINT"===e.styleType?t.paints:"EFFECT"===e.styleType?t.effects:"GRID"===e.styleType?t.layoutGrids:t)}}}catch(t){return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable"}}}}));return{collections:o.flat(),variables:a.flatMap(e=>e.value?[e.value]:[]),styles:s.flatMap(e=>e.value?[e.value]:[]),usedVariableIds:t.variableIds,usedStyleIds:t.styles.map(e=>e.id),localVariableListing:i,localVariableCount:n.size,unresolved:[...a,...s].flatMap(e=>e.unresolved?[e.unresolved]:[])}},variableCatalog:async function(e){let[t,r,n,i,a]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),l=["PAINT","TEXT","EFFECT","GRID"];return{collections:t.map(e=>(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(r.has(t))return{$circular:!0};r.add(t);let i={},a=new Set(Object.keys(t)),l=t;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!l.startsWith("_"))try{let a=e(t[l],r,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i})(e)),variableIds:[...new Set(t.flatMap(e=>e.variableIds))].sort(),styles:[r,n,i,a].flatMap((e,t)=>e.map(e=>({id:e.id,styleType:l[t]}))).sort((e,t)=>e.id.localeCompare(t.id)),localComplete:!0,usedRemoteComplete:!1}},variables:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),l=e;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!(l.startsWith("_")||["parent","children","consumers"].includes(l)))try{let a=r(e[l],t,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:String(e&&e.message?e.message:e)}}return t.delete(e),i}let[n,i]=await Promise.all([Promise.all(t.variableIds.map(e=>figma.variables.getVariableByIdAsync(e))),Promise.all(t.styles.map(e=>figma.getStyleByIdAsync(e.id)))]),a=new Map(t.styles.map(e=>[e.id,e])),l=await Promise.all(i.filter(Boolean).map(async e=>{let t=a.get(e.id),n=t.styleType;if(Number.isInteger(t.consumerStart)&&Number.isInteger(t.consumerEnd)){let i=await e.getStyleConsumersAsync();return{id:e.id,styleType:n,$consumerStart:t.consumerStart,$consumerEntries:i.slice(t.consumerStart,t.consumerEnd).map(e=>[e.node.id,e.node.type,r(e.fields)])}}let i=await e.getStyleConsumersAsync();return{...r(e),styleType:n,$consumerCount:i.length,value:r("PAINT"===n?e.paints:"EFFECT"===n?e.effects:"GRID"===n?e.layoutGrids:e)}}));return{variables:n.filter(Boolean).map(e=>r(e)),styles:l}}};async function t(t){let r=e[t.script];if(!r)return{kind:"devup-result",requestId:t.requestId,error:`DEVUP_BRIDGE_UNKNOWN_SCRIPT: ${t.script} (아는 스크립트: ${Object.keys(e).join(", ")})`};try{var n;let e=await r({nodeId:(n=t.params).nodeId??"",rootIds:n.rootIds??[],snapshot:n.snapshot??{},search:n.search??{},explore:n.explore??{},resources:n.resources??{variableIds:[],styles:[]},largeValue:n.largeValue??{},asset:n.asset??{},theme:n.theme??{offset:0}});return{kind:"devup-result",requestId:t.requestId,data:e}}catch(e){return{kind:"devup-result",requestId:t.requestId,error:e instanceof Error?e.message:String(e)}}}let r=[],n=!1;async function i(){if(!n){n=!0;try{for(;r.length>0;){let e=r.shift(),n=await t(e).catch(t=>({kind:"devup-result",requestId:e.requestId,error:t instanceof Error?t.message:String(t)}));figma.ui.postMessage(n)}}finally{n=!1}}}figma.showUI(__html__,{width:320,height:220}),figma.ui.onmessage=e=>{if("object"==typeof e&&null!==e){if("devup-ready"===e.kind){let e={kind:"devup-status",fileKey:figma.fileKey??null,fileName:figma.root.name,port:1993};figma.ui.postMessage(e);return}"devup-job"===e.kind&&(r.push(e),i())}}})(); \ No newline at end of file +(()=>{"use strict";let e={assets:async function(e){let t=e.asset;function r(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],l=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=l(r[e-2],17)^l(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,o,s,d,f,u,c,y]=a;for(let e=0;e<64;e+=1){let n=y+(l(f,6)^l(f,11)^l(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,a=(l(i,2)^l(i,13)^l(i,22))+(i&o^i&s^o&s)>>>0;y=c,c=u,u=f,f=d+n>>>0,d=s,s=o,o=i,i=n+a>>>0}for(let[e,t]of[i,o,s,d,f,u,c,y].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}function n(e){return{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:t.scale,status:"failed",byteLength:null,sha256:null,errorCode:e}}let i="string"==typeof t.field&&t.field.startsWith("$original-image/fills/");try{if(i&&"bridge"!==t.transport)return n("DEVUP_ORIGINAL_IMAGE_REQUIRES_BRIDGE");let e=await figma.getNodeByIdAsync(t.nodeId);if(!e||!i&&"function"!=typeof e.exportAsync)return n("DEVUP_ASSET_UNSUPPORTED_BY_UPSTREAM");if(i||"string"==typeof t.field&&t.field.startsWith("fills/")){let r=Number(t.field.slice(i?22:6)),a="fills"in e&&Array.isArray(e.fills)?e.fills:[],l=Number.isInteger(r)?a[r]:null,o=l&&"IMAGE"===l.type?l.imageHash||l.imageRef:null;if(!l||o!==t.imageHash)return n("DEVUP_ASSET_SOURCE_CHANGED")}else if("node"!==t.field)return n("DEVUP_ASSET_FIELD_UNSUPPORTED");if(i){let e=figma.getImageByHash(t.imageHash);if(!e)return n("DEVUP_ORIGINAL_IMAGE_NOT_FOUND");let i=await e.getBytesAsync();if(0===i.length||i.length>8388608)return n("DEVUP_ASSET_RESPONSE_TOO_LARGE");let a=e=>e.every((e,t)=>i[t]===e),l=a([137,80,78,71,13,10,26,10])?"image/png":a([255,216,255])?"image/jpeg":a([71,73,70,56])?"image/gif":a([82,73,70,70])&&87===i[8]&&69===i[9]&&66===i[10]&&80===i[11]?"image/webp":null;if(!l)return n("DEVUP_ORIGINAL_IMAGE_CODEC_UNSUPPORTED");let o=await e.getSizeAsync();return{...n(null),status:"exported",kind:"devupOriginalImage",representation:"original-image-v1",format:null,scale:null,mimeType:l,width:o.width,height:o.height,byteLength:i.length,sha256:r(i),data:figma.base64Encode(i)}}let a=String(t.format||"").toUpperCase();if(!["PNG","JPG","SVG","PDF"].includes(a))return n("DEVUP_ASSET_FORMAT_UNSUPPORTED");let l=Math.min(4,Math.max(1,Math.floor(Number(t.scale)||1))),o="SVG"===a,s={format:o?"SVG_STRING":a};("PNG"===a||"JPG"===a)&&(s.constraint={type:"SCALE",value:l});let d=await e.exportAsync(s),f=o&&"string"==typeof d?d:null,u=null===f?d instanceof Uint8Array?d:new Uint8Array(d):function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(f);if(0===u.length||u.length>8388608)return n("DEVUP_ASSET_RESPONSE_TOO_LARGE");let c=r(u),y=figma.io&&"function"==typeof figma.io.write,g="PNG"===a&&u.length>786432;if(y&&(null!==f&&u.length>12288||g))return{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:l,status:"chunked",byteLength:u.length,sha256:c,cursor:{nextOffset:0,maxChunkBytes:12288},errorCode:null};return y&&figma.io.write(`devup-asset-${t.assetId.replace(/[^A-Za-z0-9_-]/g,"_")}.${String(t.format).toLowerCase()}`,u),{kind:"devupAssetExport",fileKey:figma.fileKey||"",version:t.version,assetId:t.assetId,nodeId:t.nodeId,field:t.field,imageHash:t.imageHash,format:t.format,scale:l,status:"exported",byteLength:u.length,sha256:c,mimeType:y?null===f?null:"image/svg+xml":({PNG:"image/png",JPG:"image/jpeg",SVG:"image/svg+xml",PDF:"application/pdf"})[a],...!y&&null===f?{data:figma.base64Encode(u)}:{},text:f,errorCode:null}}catch(e){return n(i?"DEVUP_ORIGINAL_IMAGE_READ_FAILED":"DEVUP_ASSET_EXPORT_FAILED")}},explore:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=e.explore,n=Math.max(20,Math.min(400,Number.isInteger(r.projectionLimit)?r.projectionLimit:200)),i=Math.max(0,Math.min(500,Number.isInteger(r.textPreviewLimit)?r.textPreviewLimit:160));function a(e){return"string"==typeof e?e.slice(0,240):""}let l=t,o="SECTION"===t.type?t:null;for(;l.parent&&"PAGE"!==l.parent.type;)l=l.parent,o||"SECTION"!==l.type||(o=l);let s="PAGE"===t.type?t:l.parent;if(!s||"PAGE"!==s.type)throw Error("DEVUP_PAGE_NOT_FOUND");function d(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function f(e){let t=d(e);return t&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&t.width/Math.max(1,t.height)>=.25&&t.width/Math.max(1,t.height)<=2.5}function u(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type&&t.length<12;){let e=a(r.name);e&&t.push(e),r=r.parent}return t.reverse()}await figma.setCurrentPageAsync(s);let c=!f(t)&&o?o:l,y=d(c)||d(t);if(!y)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let g="children"in s?s.children:[],h=g.map((e,t)=>({node:e,pageChildIndex:t,bounds:d(e)})).filter(e=>e.bounds).filter(e=>{var t;return e.node.id===c.id||e.bounds.y>=y.y-240&&e.bounds.y<=y.y+12e3&&(t=e.bounds,Math.min(t.x+t.width,y.x+y.width)-Math.max(t.x,y.x)>0)}).sort((e,t)=>e.bounds.y-t.bounds.y||e.bounds.x-t.bounds.x||e.node.id.localeCompare(t.node.id)),p="SECTION"===c.type?h.filter(e=>e.node.id===c.id):h.slice(0,n),m=new Map(p.map(e=>[e.node.id,e]));m.has(l.id)||m.set(l.id,{node:l,pageChildIndex:-1,bounds:d(l)}),m.has(c.id)||m.set(c.id,{node:c,pageChildIndex:-1,bounds:d(c)}),m.has(t.id)||m.set(t.id,{node:t,pageChildIndex:-1,bounds:d(t)});let b=t.parent;for(;b&&"PAGE"!==b.type&&(m.has(b.id)||m.set(b.id,{node:b,pageChildIndex:-1,bounds:d(b)}),b.id!==c.id);)b=b.parent;if("children"in c)for(let e of c.children.slice(0,n))m.has(e.id)||m.set(e.id,{node:e,pageChildIndex:-1,bounds:d(e)});let x="SECTION"===c.type&&"children"in c&&c.children.length>n;if("SECTION"===c.type&&"children"in c){let e=8*n,t=c.children.filter(e=>!f(e)).map(e=>({node:e,ancestors:[]})),r=0;for(;t.length&&r!m.has(e.id));if(m.size+t.length>n+2){x=!0;continue}for(let e of t)m.set(e.id,{node:e,pageChildIndex:-1,bounds:d(e)});continue}if("children"in e)for(let r of e.children)t.push({node:r,ancestors:[...i,e]})}x||=t.length>0}let E=[...m.values()].filter(e=>e.bounds).slice(0,n+2).map(({node:e,pageChildIndex:t,bounds:r})=>({id:e.id,type:e.type,fields:{name:a(e.name),parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:[],x:r.x,y:r.y,width:r.width,height:r.height,childCount:"children"in e?e.children.length:0,textPreview:"",pageChildIndex:t>=0?t:null,visible:!("visible"in e)||!1!==e.visible,breadcrumb:u(e)},extra:{},fieldErrors:{}})),I=E.reduce((e,t)=>{let r=t.fields;return e?{x:Math.min(e.x,r.x),y:Math.min(e.y,r.y),right:Math.max(e.right,r.x+r.width),bottom:Math.max(e.bottom,r.y+r.height)}:{x:r.x,y:r.y,right:r.x+r.width,bottom:r.y+r.height}},null)||{x:0,y:0,right:0,bottom:0},S="SECTION"!==c.type&&h.length>p.length||m.size>n+2||x,A={id:s.id,type:s.type,fields:{name:a(s.name),parentId:null,childrenIds:[],x:I.x,y:I.y,width:I.right-I.x,height:I.bottom-I.y,childCount:g.length,textPreview:"",projectionTruncated:S,visible:!0,breadcrumb:u(s),pageChildIndex:null},extra:{},fieldErrors:{}},N={fileKey:figma.fileKey||"",version:null,rootIds:[s.id],nodes:[A,...E.filter(e=>e.id!==s.id)],diagnostics:[]},w=new Set([s.id,l.id,c.id,t.id]);for(;JSON.stringify(N).length>14e3;){let e=N.nodes.length-1;for(;e>=0&&w.has(N.nodes[e].id);)e-=1;if(e<0)break;N.nodes.splice(e,1),A.fields.projectionTruncated=!0}if(JSON.stringify(N).length>14e3&&(N.nodes=N.nodes.filter(e=>w.has(e.id)).map(e=>({...e,fields:{...e.fields,name:a(e.fields.name).slice(0,80),textPreview:"",breadcrumb:e.fields.breadcrumb.slice(-4).map(e=>e.slice(0,80)),projectionTruncated:e.id===s.id||e.fields.projectionTruncated}}))),JSON.stringify(N).length>14e3)throw Error("DEVUP_EXPLORE_PROJECTION_TOO_LARGE");let O=14e3-JSON.stringify(N).length;if(i>0&&O>0){let e=N.nodes.filter(e=>m.get(e.id)&&e.id!==s.id),t=e.length;for(let r of e){let e=Math.floor(O/t);if(t-=1,e<=0)continue;let n="",a=0;for(let t of function(e){if(0===i)return"";let t=[],r=[e],n=0;for(;r.length&&n<80&&t.join(" ").lengthe)break;n+=t,a+=r}r.fields.textPreview=n,O-=a}}return N},fastSnapshot:async function(e){let t=e.rootIds;if(!Array.isArray(t)||0===t.length)throw Error("DEVUP_ROOTS_INVALID");let r=await Promise.all(t.map(e=>figma.getNodeByIdAsync(e)));if(r.some(e=>!e))throw Error("DEVUP_NODE_NOT_FOUND");if(1===r.length&&"SECTION"===r[0].type)throw Error("DEVUP_TARGET_IS_SECTION");let n=["mobile","tablet","desktop"],i=e=>n.indexOf(String(e.name||"").trim().toLowerCase());if(1===r.length&&i(r[0])>=0){let e=r[0].parent;if(e&&"SECTION"===e.type&&"children"in e){let t=e.children.filter(e=>e.id===r[0].id||i(e)>=0).filter(e=>!1!==e.visible);t.length>1&&(r.length=0,r.push(...t))}}if(1===t.length){let e=[],t=new Set(r.map(e=>e.id)),n=[],i=e=>{if("reactions"in e&&Array.isArray(e.reactions)){for(let r of e.reactions)if(r&&r.trigger&&"AFTER_TIMEOUT"===r.trigger.type)for(let e of r.actions||[])e&&"NODE"===e.type&&e.transition&&"SMART_ANIMATE"===e.transition.type&&"string"==typeof e.destinationId&&!t.has(e.destinationId)&&(t.add(e.destinationId),n.push(e.destinationId));if("children"in e)for(let t of e.children)i(t)}};for(let e of r)i(e);for(;n.length>0;){let t=n.shift(),r=await figma.getNodeByIdAsync(t);r&&"DOCUMENT"!==r.type&&"PAGE"!==r.type&&(e.push(r),i(r))}r.push(...e)}let a=e.nodeId,l=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],o=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],s=e.snapshot,d=Math.max(0,Math.floor(Number(s.offset)||0)),f=Math.max(8192,Math.floor(Number(s.maxEnvelopeBytes)||19456)),u=Math.min(s.maxEnvelopeBytes?f-1024:18e3,Math.max(4096,Math.floor(Number(s.maxPayloadBytes)||15e3))),c=new Set(["backgroundStyleId","effectStyleId","fillStyleId","gridStyleId","strokeStyleId","textStyleId"]),y=new Set(["maxWidth","maxHeight","absoluteRenderBounds"]),g=new Map([["rotation",0],["cornerRadius",0],["isAsset",!1],["isMask",!1],["clipsContent",!1],["blendMode","PASS_THROUGH"],["strokeAlign","INSIDE"],["textCase","ORIGINAL"],["textDecoration","NONE"],["textAlignHorizontal","LEFT"],["textAlignVertical","TOP"],["counterAxisAlignItems","MIN"],["primaryAxisAlignItems","MIN"],["gridColumnCount",0],["gridRowCount",0],["gridColumnGap",0],["gridRowGap",0],["gridColumnAnchorIndex",-1],["gridRowAnchorIndex",-1],["gridColumnSpan",1],["gridRowSpan",1]]),h=new Set(["start","end","characters","fontWeight","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]),p=new Set(["parent","children","consumers"]);function m(e,t=!1,r=new WeakSet,n=0){let i;if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>m(e,t,r,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(r.has(e))return{$circular:!0};if(r.add(e),t){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}i=[...t].sort().filter(e=>!e.startsWith("_")&&!p.has(e))}else i=Object.keys(e).sort();let a={};for(let l of i)try{let i=m(e[l],t,r,n+1);i&&"function"===i.$unsupported||(a[l]=i)}catch(e){a[l]=t?{$error:"unavailable"}:{$error:String(e&&e.message?e.message:e)}}return r.delete(e),a}let b=[],x=[...r],E=new Set;for(let e=0;e=b.length&&b.length>0)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");function I(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1({id:e,styleType:t})).sort((e,t)=>e.id.localeCompare(t.id)),a=await Promise.all([...n.map(async e=>{try{let t=await figma.variables.getVariableByIdAsync(e);return t?{kind:"variable",value:m(t,!0),collectionId:t.variableCollectionId}:{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable"}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"variable",reason:"notFoundOrUnavailable"}}}}),...i.map(async({id:e,styleType:t})=>{try{let r=await figma.getStyleByIdAsync(e);if(!r)return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable"}};return{kind:"style",value:{...m(r,!0),styleType:t,value:m("PAINT"===t?r.paints:"EFFECT"===t?r.effects:"GRID"===t?r.layoutGrids:r,!0)}}}catch(t){return{kind:"unresolved",value:{id:e,kind:"style",reason:"notFoundOrUnavailable"}}}})]),l=[...new Set(a.filter(e=>"variable"===e.kind&&e.collectionId).map(e=>e.collectionId))].sort(),o=(await Promise.all(l.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?m(t,!0):null}catch(e){return null}}))).filter(e=>null!==e),s=a.filter(e=>"variable"===e.kind).map(e=>e.value),d=a.filter(e=>"style"===e.kind).map(e=>e.value),f=a.filter(e=>"unresolved"===e.kind).map(e=>e.value);return{collections:o,variables:s,styles:d,usedRemoteVariables:s.filter(e=>!0===e.remote),usedVariableIds:n,usedStyleIds:i.map(e=>e.id),localComplete:!1,usedRemoteComplete:0===f.length,unresolved:f,$variableRefCount:n.length,$styleRefCount:i.length}}let A=u-1024,N=null;for(let e=0;e<5;e+=1){let{pageNodes:e,packedBytes:t}=function(e){let t=[],r=2;for(let n=d;ne.id):[];for(let i of(n.length>0&&(t.childrenIds=n),l)){let n;try{if(!(i in e)){"overflowDirection"===i&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t[i]=null);continue}if(n=e[i],"function"==typeof n)continue;let r=m(n);if("overflowDirection"===i&&null==n){t[i]=null;continue}(null===r?!y.has(i):Array.isArray(r)?0===r.length:"object"==typeof r?0===Object.keys(r).length:!!(""===r&&c.has(i))||g.has(i)&&g.get(i)===r)||(t[i]=r)}catch(e){r[i]=String(e&&e.message?e.message:e)}}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{let r=m(e.getStyledTextSegments(o));if(1===r.length){let e=r[0];for(let t of Object.keys(e))h.has(t)||delete e[t]}r.length>0&&(t.styledTextSegments=r)}catch(e){r.styledTextSegments=String(e&&e.message?e.message:e)}let i={id:e.id,type:e.type,fields:t};return Object.keys(r).length>0&&(i.fieldErrors=r),i}(b[n]),a=I(JSON.stringify(i))+ +!!t.length;if(t.length&&r+a>e)break;t.push(i),r+=a}return{pageNodes:t,packedBytes:r}}(A);if(0===e.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let n=function(e,t){let n=Math.min(b.length,d+e.length),{$variableRefCount:i,$styleRefCount:l,...o}=t,s=[...e,{id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:d,nextOffset:n,complete:n>=b.length,totalNodes:b.length},extra:{},fieldErrors:{}}],f={kind:"devupFastSnapshotEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",rootId:a},snapshot:{fileKey:figma.fileKey||"",version:null,rootIds:r.map(e=>e.id),nodes:s,diagnostics:[]},resources:o,integrity:{nodeCount:s.length,variableRefCount:i,styleRefCount:l,utf8Bytes:0}},u=0;for(let e=0;e<8&&(u=I(JSON.stringify(f)),f.integrity.utf8Bytes!==u);e+=1)f.integrity.utf8Bytes=u;if(f.integrity.utf8Bytes!==I(JSON.stringify(f)))throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:f,bytes:u}}(e,await S(e));if(n.bytes<=f){N=n;break}if(1===e.length)throw Error("DEVUP_ENVELOPE_TOO_LARGE");A=Math.max(1,Math.min(A-1,t-(n.bytes-f)-256))}if(!N)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return N.envelope},fastTheme:async function(e){let t=Math.max(0,Math.floor(Number(e.theme.offset)||0));function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={};for(let a of function(e){let t=new Set(Object.keys(e)),r=e;for(;r&&r!==Object.prototype;){for(let e of Object.getOwnPropertyNames(r))t.add(e);r=Object.getPrototypeOf(r)}return[...t].sort()}(e))if(!(a.startsWith("_")||["parent","children","consumers"].includes(a)))try{let l=r(e[a],t,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:"unavailable"}}return t.delete(e),i}let n=new Set,i=new Map;for(let e of[figma.root,...figma.root.findAll(()=>!0)])for(let t of["boundVariables","fills","strokes","effects","layoutGrids","textStyleId","fillStyleId","strokeStyleId","backgroundStyleId","effectStyleId","gridStyleId"])try{!function e(t,r="",a=new WeakSet,l=0){if(!(l>16)&&null!==t&&"object"==typeof t&&!a.has(t)){if(a.add(t),Array.isArray(t)){for(let n of t)e(n,r,a,l+1);return}for(let[o,s]of("VARIABLE_ALIAS"===t.type&&"string"==typeof t.id&&t.id&&"figma.mixed"!==t.id&&"MIXED"!==t.id&&n.add(t.id),Object.entries(t))){let t="textStyleId"===o?"TEXT":["fillStyleId","strokeStyleId","backgroundStyleId"].includes(o)?"PAINT":"effectStyleId"===o?"EFFECT":"gridStyleId"===o?"GRID":null;t&&"string"==typeof s&&s&&"figma.mixed"!==s&&"MIXED"!==s&&!i.has(s)&&i.set(s,t),e(s,o||r,a,l+1)}}}({[t]:e[t]})}catch(e){}let[a,l,o,s,d,f]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.variables.getLocalVariablesAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),u=new Set(l.map(e=>e.id)),c=new Set([...o,...s,...d,...f].map(e=>e.id)),y=[],g=[...n].filter(e=>!u.has(e)).sort().map(async e=>{try{return await figma.variables.getVariableByIdAsync(e)||null}catch(t){return y.push({id:e,kind:"variable",reason:"notFoundOrUnavailable"}),null}}),h=[...i.entries()].filter(([e])=>!c.has(e)).sort(([e],[t])=>e.localeCompare(t)).map(async([e,t])=>{try{let r=await figma.getStyleByIdAsync(e);return r?{style:r,styleType:t}:null}catch(t){return y.push({id:e,kind:"style",reason:"notFoundOrUnavailable"}),null}}),p=(await Promise.all(g)).filter(Boolean),m=(await Promise.all(h)).filter(Boolean);for(let e of[...n].filter(e=>!u.has(e)))p.some(t=>t.id===e)||y.some(t=>t.id===e)||y.push({id:e,kind:"variable",reason:"notFoundOrUnavailable"});for(let[e]of[...i.entries()].filter(([e])=>!c.has(e)))m.some(t=>t.style.id===e)||y.some(t=>t.id===e)||y.push({id:e,kind:"style",reason:"notFoundOrUnavailable"});let b=[...new Set(p.map(e=>e.variableCollectionId))].filter(e=>!a.some(t=>t.id===e)).sort(),x=(await Promise.all(b.map(async e=>{try{return await figma.variables.getVariableCollectionByIdAsync(e)}catch(e){return null}}))).filter(Boolean);function E(e,t){return{...r(e),styleType:t,value:r("PAINT"===t?e.paints:"EFFECT"===t?e.effects:"GRID"===t?e.layoutGrids:e)}}let I=[...o.map(e=>E(e,"PAINT")),...s.map(e=>E(e,"TEXT")),...d.map(e=>E(e,"EFFECT")),...f.map(e=>E(e,"GRID")),...m.map(({style:e,styleType:t})=>E(e,t))].sort((e,t)=>e.id.localeCompare(t.id)),S=[...l,...p].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id)),A=[...a,...x].map(e=>r(e)).sort((e,t)=>e.id.localeCompare(t.id));function N(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}y.sort((e,t)=>e.kind.localeCompare(t.kind)||e.id.localeCompare(t.id));let w=[...A.map(e=>({kind:"collection",value:e})),...S.map(e=>({kind:"variable",value:e})),...I.map(e=>({kind:"style",value:e})),...[...n].sort().map(e=>({kind:"usedVariableId",value:e})),...[...i.keys()].sort().map(e=>({kind:"usedStyleId",value:e})),...y.map(e=>({kind:"unresolved",value:e}))];if(t>w.length)throw Error("DEVUP_SNAPSHOT_RANGE_INVALID");let O=w.map(e=>N(JSON.stringify(e.value)).length+1),T=18432,v=null;for(let e=0;e<6;e+=1){let{pageItems:e,packed:r}=function(e){let r=[],n=0;for(let i=t;i0)||!(n+O[i]>e));i+=1)r.push(w[i]),n+=O[i];return{pageItems:r,packed:n}}(T),n=function(e,r){let n=t=>e.filter(e=>e.kind===t).map(e=>e.value),i=n("collection"),a=n("variable"),l=n("style"),o=n("unresolved"),s={kind:"devupFastThemeEnvelope",schemaVersion:1,source:{fileKey:figma.fileKey||"",version:null},resources:{collections:i,variables:a,styles:l,usedRemoteVariables:[],usedVariableIds:n("usedVariableId"),usedStyleIds:n("usedStyleId"),localComplete:!0,usedRemoteComplete:0===y.length,unresolved:o},page:{offset:t,nextOffset:r,complete:r>=w.length,totalItems:w.length},integrity:{collectionCount:i.length,variableCount:a.length,styleCount:l.length,unresolvedCount:o.length,utf8Bytes:0}},d=new Uint8Array;for(let e=0;e<8&&(d=N(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length);e+=1)s.integrity.utf8Bytes=d.length;if(d=N(JSON.stringify(s)),s.integrity.utf8Bytes!==d.length)throw Error("DEVUP_ENVELOPE_LENGTH_UNSTABLE");return{envelope:s,bytes:d.length}}(e,t+e.length);if(n.bytes<=19456){v=n;break}if(e.length<=1)break;T=Math.max(1,Math.min(T-1,r-(n.bytes-19456)-256))}if(null===v||v.bytes>8388608)throw Error("DEVUP_ENVELOPE_TOO_LARGE");return v.envelope},largeValue:async function(e){let t,r=e.largeValue,n=await figma.getNodeByIdAsync(r.nodeId);if(!n)throw Error("DEVUP_NODE_NOT_FOUND");function i(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}let a=null;try{if("$export:svg"===r.field){if("function"!=typeof n.exportAsync)throw Error("unsupported");let e=await n.exportAsync({format:"SVG_STRING"});if("string"!=typeof e)throw Error("unsupported");a=i(e)}else if("string"==typeof r.field&&r.field.startsWith("$export:png")){if("function"!=typeof n.exportAsync)throw Error("unsupported");let e=Math.min(4,Math.max(1,Math.floor(Number(r.field.split("@")[1])||1))),t=await n.exportAsync({format:"PNG",constraint:{type:"SCALE",value:e}});a=t instanceof Uint8Array?t:new Uint8Array(t)}else if("styledTextSegments"===r.field&&"TEXT"===n.type&&"function"==typeof n.getStyledTextSegments)t=n.getStyledTextSegments(["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"]);else if(r.field in n)t=n[r.field];else throw Error("unsupported")}catch(e){return{kind:"devupLargeValueUnsupported",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,byteLength:r.byteLength,sha256:r.sha256,errorCode:"DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM"}}let l=null===a?i(JSON.stringify(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let l=e(t[a],r,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(t))):a,o=function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=e.length,n=64*Math.ceil((r+9)/64),i=new Uint8Array(n);i.set(e),i[r]=128;let a=8*r;for(let e=0;e<8;e+=1)i[n-1-e]=255&Math.floor(a/2**(8*e));let l=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],o=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=o(r[e-2],17)^o(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[n,a,s,d,f,u,c,y]=l;for(let e=0;e<64;e+=1){let i=y+(o(f,6)^o(f,11)^o(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,l=(o(n,2)^o(n,13)^o(n,22))+(n&a^n&s^a&s)>>>0;y=c,c=u,u=f,f=d+i>>>0,d=s,s=a,a=n,n=i+l>>>0}for(let[e,t]of[n,a,s,d,f,u,c,y].entries())l[e]=l[e]+t>>>0}return l.map(e=>e.toString(16).padStart(8,"0")).join("")}(l);if(l.length!==r.byteLength||o!==r.sha256)throw Error("DEVUP_LARGE_VALUE_CHANGED");let s=Math.max(0,Math.floor(Number(r.offset)||0)),d=Math.min(65536,Math.max(1,Math.floor(Number(r.maxChunkBytes)||8192)));if(s>=l.length)throw Error("DEVUP_LARGE_VALUE_RANGE_INVALID");let f=Math.min(l.length,s+d);return{kind:"devupLargeValueFragment",fileKey:figma.fileKey||"",version:r.version,nodeId:r.nodeId,field:r.field,offset:s,nextOffset:f,byteLength:l.length,sha256:o,dataBase64:function(e){let t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",r="";for(let n=0;n>2],r+=t[(3&i)<<4|a>>4],r+=n+1>6]:"=",r+=n+20;){let e=t[t.length-1],i=e.node,a="children"in i?i.children:[];if(!e.visited){e.visited=!0;for(let e=a.length-1;e>=0;e-=1)t.push({node:a[e],visited:!1});continue}t.pop();let l=0,o=[];for(let e of a)o.push(e.id),l+=1+(r.get(e.id)||0);r.set(i.id,l),n.push({id:i.id,type:i.type,name:i.name,childrenIds:o,descendantCount:l})}}(t),{fileKey:figma.fileKey||"",version:null,rootId:t.id,nodes:n}},pageCatalog:async function(e){let t=figma.root.children;return{fileKey:figma.fileKey||"",version:null,rootIds:t.map(e=>e.id),nodes:t.map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:null,childrenIds:[]},extra:{},fieldErrors:{}})),diagnostics:[]}},search:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=t;for(;r&&"PAGE"!==r.type;)r=r.parent;if(!r||"PAGE"!==r.type)throw Error("DEVUP_PAGE_NOT_FOUND");await figma.setCurrentPageAsync(r);let n=e.search,i=new Set((n.nodeTypes.length?n.nodeTypes:["PAGE","SECTION","FRAME","COMPONENT_SET","COMPONENT"]).map(e=>e.toUpperCase()));function a(e){return e.normalize("NFC").toLocaleLowerCase().replace(/\s/gu,"")}let l=t===r?r.findAll(()=>!0):"findAll"in t?t.findAll(()=>!0):[],o=[t,...l].filter(e=>i.has(e.type)&&"string"==typeof e.name).map(e=>({node:e,score:function(e){if(e===n.query)return 400;if("exact"===n.matchKind)return null;let t=a(e),r=a(n.query);if(t===r)return 300;if(t.startsWith(r))return 200;if(t.includes(r))return 100;if("fuzzy"!==n.matchKind)return null;let i=function(e,t){let r=Array.from({length:t.length+1},(e,t)=>t);for(let n=0;nnull!==e.score).sort((e,t)=>t.score-e.score||e.node.name.localeCompare(t.node.name)||e.node.id.localeCompare(t.node.id)).slice(0,n.limit),s=new Map([[r.id,r]]);for(let e of[t,...o.map(e=>e.node)]){let t=e;for(;t&&"DOCUMENT"!==t.type;)s.set(t.id,t),t=t.parent}return{fileKey:figma.fileKey||"",version:null,rootIds:[r.id],nodes:[...s.values()].map(e=>({id:e.id,type:e.type,fields:{name:e.name,parentId:e.parent&&"DOCUMENT"!==e.parent.type?e.parent.id:null,childrenIds:"children"in e?e.children.map(e=>e.id):[]},extra:{},fieldErrors:{}})),diagnostics:[]}},sectionIndex:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");if("SECTION"!==t.type){let e=t.parent,r=new Set;for(;e&&"SECTION"!==e.type&&!r.has(e.id);)r.add(e.id),e=e.parent;let n=e&&"SECTION"===e.type?e.id:null,i=n&&figma.fileKey?`https://www.figma.com/design/${figma.fileKey}?node-id=${n.replace(/:/g,"-")}`:null;throw Error("DEVUP_SECTION_REQUIRED "+JSON.stringify({pluginCode:"DEVUP_SECTION_REQUIRED",stage:"section-index",nodeId:t.id,nodeType:t.type,sectionId:n,nextAction:{tool:"devup_figma_export",how:n?`The url must point to a SECTION. This capture's SECTION is ${n}; select frames with frameIds.`:"The url must point to a SECTION. This node has no ancestor SECTION; choose the intended SECTION in Figma and copy its link.",arguments:i?"FRAME"===t.type&&t.parent===e?{url:i,frameIds:[t.id]}:{url:i}:null,requiredArguments:i?[]:["url"]}}))}let r=2048;function n(e){let t=e.absoluteBoundingBox||{x:"number"==typeof e.x?e.x:0,y:"number"==typeof e.y?e.y:0,width:"number"==typeof e.width?e.width:0,height:"number"==typeof e.height?e.height:0};return[t.x,t.y,t.width,t.height].every(Number.isFinite)?{x:t.x,y:t.y,width:t.width,height:t.height}:null}function i(e,t){if("FRAME"!==e.type||!1===e.visible||!t)return!1;let r=t.width/Math.max(1,t.height);return t.width>=240&&t.width<=1800&&t.height>=300&&t.height<=2e3&&r>=.25&&r<=2.5}function a(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+1[e.id,t.id])),s=[],d=0;for(let e=0;ee.id));for(let r of t.children){if(e.has(r.id))continue;let t=n(r);t&&!1!==r.visible&&s.push({node:r,box:t})}}s.sort((e,t)=>e.box.y-t.box.y||e.box.x-t.box.x||e.node.id.localeCompare(t.node.id));let f=l.length>d||s.length>100,u=s.slice(0,100),c=new Set(u.map(({node:e})=>e.id)),y=new Set(e.rootIds),g={},h=new Map([[t.id,null]]);for(let e=0;e{let r=e.parent;for(;r&&r.id!==t.id&&!c.has(r.id);)r=r.parent;return[e.id,r&&c.has(r.id)?r.id:t.id]})),m=e=>u.filter(({node:t})=>p.get(t.id)===e).map(({node:e})=>e.id),b=n(t);if(!b)throw Error("DEVUP_NODE_BOUNDS_UNAVAILABLE");let x={id:t.id,type:t.type,fields:{name:t.name,parentId:t.parent&&"DOCUMENT"!==t.parent.type?t.parent.id:null,childrenIds:m(t.id),absoluteBoundingBox:b,visible:!1!==t.visible,projectionTruncated:f,nodeScreenIds:g},extra:{},fieldErrors:{}},E=u.length,I=u.map(({node:e,box:t})=>{let n=function(e){let t=[e],r=0,n=0;for(let e=0;er}}(e),l=Math.floor(r/E);E-=1;let o=function(e,t){let r=[e],n="",i=0,l=0,o=!1;if(t<=0)return{text:"",state:"budget-exhausted",spent:0};for(let e=0;e=120||l+e>t)return{text:n.trimEnd(),state:l+e>t?"budget-exhausted":"truncated",spent:l};n+=r,i+=1,l+=e}}if("children"in s){let e=Math.max(0,64-r.length);s.children.length>e&&(o=!0),r.push(...s.children.slice(0,e))}}}return{text:n,state:o?"truncated":n?"available":"no-text",spent:l}}(e,l);return r-=o.spent,{id:e.id,type:e.type,fields:{name:"string"==typeof e.name?e.name:"",parentId:p.get(e.id),childrenIds:m(e.id),absoluteBoundingBox:t,visible:!1!==e.visible,breadcrumb:function(e){let t=[],r=e;for(;r&&"DOCUMENT"!==r.type;)"string"==typeof r.name&&r.name&&t.push(r.name),r=r.parent;return t.reverse()}(e),directChildCount:"children"in e?e.children.length:0,textPreview:o.text,textPreviewState:o.state,subtreeNodeCount:n.subtreeNodeCount,estimatedSerializedBytes:n.estimatedSerializedBytes,selectionReasons:[i(e,t)?"screen-like":"explicit-selection-only","inside-section"],estimateTruncated:n.truncated},extra:{},fieldErrors:{}}}),S={fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:[x,...I],diagnostics:[]},A=a(JSON.stringify(S));for(let e=I.length-1;e>=0&&A>19456;e-=1)I[e].fields.textPreview="",I[e].fields.textPreviewState="budget-exhausted",A=a(JSON.stringify(S));let N=()=>S.nodes.length-1,w=I.length;for(;A>19456&&N()>1;){let e=S.nodes.pop();x.fields.projectionTruncated=!0,x.fields.childrenIds=x.fields.childrenIds.filter(t=>t!==e.id),A=a(JSON.stringify(S))}if(A>19456)throw Error("DEVUP_SECTION_INDEX_TOO_LARGE "+JSON.stringify({pluginCode:"DEVUP_SECTION_INDEX_TOO_LARGE",stage:"section-index",responseBytes:A,maxResponseBytes:19456,discoveredCandidates:w,retainedCandidates:N()}));return S},snapshot:async function(e){let t=await figma.getNodeByIdAsync(e.nodeId);if(!t)throw Error("DEVUP_NODE_NOT_FOUND");let r=["absoluteBoundingBox","absoluteRenderBounds","arcData","backgroundStyleId","blendMode","bottomLeftRadius","bottomRightRadius","boundVariables","characters","clipsContent","componentProperties","componentPropertyDefinitions","componentPropertyReferences","constraints","cornerRadius","counterAxisAlignItems","dashPattern","defaultVariant","effectStyleId","effects","fillStyleId","fills","fontName","fontSize","gridColumnAnchorIndex","gridColumnCount","gridColumnGap","gridColumnSizes","gridColumnSpan","gridRowAnchorIndex","gridRowCount","gridRowGap","gridRowSizes","gridRowSpan","gridStyleId","height","inferredAutoLayout","isAsset","isMask","itemSpacing","layoutGrow","layoutMode","layoutWrap","layoutPositioning","layoutSizingHorizontal","layoutSizingVertical","letterSpacing","lineHeight","maxHeight","maxLines","maxWidth","minHeight","minWidth","name","opacity","overflowDirection","paddingBottom","paddingLeft","paddingRight","paddingTop","primaryAxisAlignItems","reactions","rotation","strokeAlign","strokeBottomWeight","strokeLeftWeight","strokeRightWeight","strokeStyleId","strokeTopWeight","strokeWeight","strokes","targetAspectRatio","textAlignHorizontal","textAlignVertical","textAutoResize","textCase","textDecoration","textStyleId","textTruncation","topLeftRadius","topRightRadius","variantProperties","visible","width","x","y"],n=new Set(r),i=["fontName","fontWeight","fontSize","textDecoration","textCase","lineHeight","letterSpacing","fills","textStyleId","fillStyleId","listOptions","indentation","hyperlink"],a=e.snapshot,l=Math.max(0,Math.floor(Number(a.offset)||0)),o=Math.min(a.maxEnvelopeBytes?Math.max(8192,Math.floor(Number(a.maxEnvelopeBytes))):16e3,Math.max(4096,Math.floor(Number(a.maxPayloadBytes)||15e3))),s=Math.min(o-1024,Math.max(512,Math.floor(Number(a.maxFieldBytes)||4096))),d=new Set(["id","type","parent","children"]),f=new Set(["parentId","childrenIds","name","characters","styledTextSegments","boundVariables"]);function u(e,t,r){let n=function(e){let t=[];for(let r=0;r=55296&&n<=56319){let t=r+1=56320&&t<=57343?(n=65536+(n-55296<<10)+(t-56320),r+=1):n=65533}else n>=56320&&n<=57343&&(n=65533);n<128?t.push(n):n<2048?t.push(192|n>>6,128|63&n):n<65536?t.push(224|n>>12,128|n>>6&63,128|63&n):t.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n)}return new Uint8Array(t)}(JSON.stringify(r));return n.length>0x1000000?{$truncated:"max-large-value-bytes",byteLength:n.length}:{$largeValue:{nodeId:e,field:t,byteLength:n.length,sha256:function(e){let t=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0xfc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x6ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2],r=64*Math.ceil((e.length+9)/64),n=new Uint8Array(r);n.set(e),n[e.length]=128;let i=8*e.length;for(let e=0;e<8;e+=1)n[r-1-e]=255&Math.floor(i/2**(8*e));let a=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],l=(e,t)=>e>>>t|e<<32-t;for(let e=0;e>>3,n=l(r[e-2],17)^l(r[e-2],19)^r[e-2]>>>10;r[e]=r[e-16]+t+r[e-7]+n>>>0}let[i,o,s,d,f,u,c,y]=a;for(let e=0;e<64;e+=1){let n=y+(l(f,6)^l(f,11)^l(f,25))+(f&u^~f&c)+t[e]+r[e]>>>0,a=(l(i,2)^l(i,13)^l(i,22))+(i&o^i&s^o&s)>>>0;y=c,c=u,u=f,f=d+n>>>0,d=s,s=o,o=i,i=n+a>>>0}for(let[e,t]of[i,o,s,d,f,u,c,y].entries())a[e]=a[e]+t>>>0}return a.map(e=>e.toString(16).padStart(8,"0")).join("")}(n),cursor:{nextOffset:0,maxChunkBytes:12288}}}}function c(e){return function(e){let t=0;for(let r=0;r=55296&&n<=56319&&r+112)return{$truncated:"max-depth"};if("object"==typeof t&&"parent"in t&&"string"==typeof t.id&&"string"==typeof t.type)return{$nodeId:t.id,$nodeType:t.type};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(ArrayBuffer.isView(t))return{$binary:t.constructor.name,byteLength:t.byteLength};if(t instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:t.byteLength};if(r.has(t))return{$circular:!0};r.add(t);let i={};for(let a of Object.keys(t).sort())try{let l=e(t[a],r,n+1);l&&"function"===l.$unsupported||(i[a]=l)}catch(e){i[a]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i}(r),a=c(i);if(a<=s)return i;let l=u(e,t,i);return l.$truncated&&(n[t]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${a}>16777216`),l}let g=[],h=[t];for(;h.length;){let e=h.shift();g.push(e),"children"in e&&h.push(...e.children)}let p=[],m=o-1024,b=2;for(let e=l;eNumber(e.protected)-Number(t.protected)||t.byteLength-e.byteLength),n)){if(r<=t)break;let n=u(e.id,i.name,e[i.sectionName][i.name]);e[i.sectionName][i.name]=n,n.$truncated&&(e.fieldErrors[i.name]=`DEVUP_FIELD_VALUE_UNSUPPORTED:${i.byteLength}>16777216`),r=c(e)}return e}(function(e){let t={},a={},l={};for(let i of(t.parentId=e.parent?e.parent.id:null,e.parent&&("PAGE"===e.parent.type||"SECTION"===e.parent.type||"COMPONENT_SET"===e.parent.type)&&(t.parentType=e.parent.type,"SECTION"===e.parent.type&&(t.parentName=e.parent.name)),t.childrenIds="children"in e?e.children.map(e=>e.id):[],!("overflowDirection"in e)&&["FRAME","COMPONENT","INSTANCE","COMPONENT_SET"].includes(e.type)&&(t.overflowDirection=null),function(e){let t=new Set,n=e;for(;n&&n!==Object.prototype;){for(let e of Object.getOwnPropertyNames(n))t.add(e);n=Object.getPrototypeOf(n)}for(let n of r)try{n in e&&t.add(n)}catch(e){}return[...t].sort()}(e)))if(!(d.has(i)||i.startsWith("_")))try{let r=e[i];if("function"==typeof r)continue;let o=y(e.id,i,r,l);(n.has(i)?t:a)[i]=o}catch(e){l[i]=String(e&&e.message?e.message:e)}if("TEXT"===e.type&&"function"==typeof e.getStyledTextSegments)try{t.styledTextSegments=y(e.id,"styledTextSegments",e.getStyledTextSegments(i),l)}catch(e){l.styledTextSegments=String(e&&e.message?e.message:e)}return{id:e.id,type:e.type,fields:t,extra:a,fieldErrors:l}}(g[e]),m),a=c(t)+ +!!p.length;if(p.length&&b+a>m)break;p.push(t),b+=a}let x=Math.min(g.length,l+p.length);return p.push({id:"__DEVUP_SNAPSHOT_CURSOR__",type:"DEVUP_INTERNAL",fields:{offset:l,nextOffset:x,complete:x>=g.length,totalNodes:g.length},extra:{},fieldErrors:{}}),{fileKey:figma.fileKey||"",version:null,rootIds:[t.id],nodes:p,diagnostics:[]}},usedResources:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),l=e;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!(l.startsWith("_")||["parent","children","consumers"].includes(l)))try{let a=r(e[l],t,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:"unavailable"}}return t.delete(e),i}let n=new Map,i="ok";try{for(let e of(await figma.variables.getLocalVariablesAsync()))n.set(e.id,e)}catch(e){i="unavailable"}let a=await Promise.all(t.variableIds.map(async e=>{let t=n.get(e);if(t)return{value:r(t),collectionId:t.variableCollectionId};try{let t=await figma.variables.getVariableByIdAsync(e);return t?{value:r(t),collectionId:t.variableCollectionId}:{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupEmpty"}}}catch(t){return{unresolved:{id:e,kind:"variable",reason:"notInFileAndLookupThrew"}}}})),l=[...new Set(a.flatMap(e=>e.collectionId?[e.collectionId]:[]))].sort(),o=await Promise.all(l.map(async e=>{try{let t=await figma.variables.getVariableCollectionByIdAsync(e);return t?[r(t)]:[]}catch(e){return[]}})),s=await Promise.all(t.styles.map(async e=>{try{let t=await figma.getStyleByIdAsync(e.id);if(!t)return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable"}};return{value:{...r(t),styleType:e.styleType,value:r("PAINT"===e.styleType?t.paints:"EFFECT"===e.styleType?t.effects:"GRID"===e.styleType?t.layoutGrids:t)}}}catch(t){return{unresolved:{id:e.id,kind:"style",reason:"notFoundOrUnavailable"}}}}));return{collections:o.flat(),variables:a.flatMap(e=>e.value?[e.value]:[]),styles:s.flatMap(e=>e.value?[e.value]:[]),usedVariableIds:t.variableIds,usedStyleIds:t.styles.map(e=>e.id),localVariableListing:i,localVariableCount:n.size,unresolved:[...a,...s].flatMap(e=>e.unresolved?[e.unresolved]:[])}},variableCatalog:async function(e){let[t,r,n,i,a]=await Promise.all([figma.variables.getLocalVariableCollectionsAsync(),figma.getLocalPaintStylesAsync(),figma.getLocalTextStylesAsync(),figma.getLocalEffectStylesAsync(),figma.getLocalGridStylesAsync()]),l=["PAINT","TEXT","EFFECT","GRID"];return{collections:t.map(e=>(function e(t,r=new WeakSet,n=0){if(null===t||["string","number","boolean"].includes(typeof t))return t;if(void 0===t)return{$undefined:!0};if("bigint"==typeof t)return{$bigint:t.toString()};if(["function","symbol"].includes(typeof t))return{$unsupported:typeof t};if(n>12)return{$truncated:"max-depth"};if(Array.isArray(t))return t.map(t=>e(t,r,n+1));if(r.has(t))return{$circular:!0};r.add(t);let i={},a=new Set(Object.keys(t)),l=t;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!l.startsWith("_"))try{let a=e(t[l],r,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:String(e&&e.message?e.message:e)}}return r.delete(t),i})(e)),variableIds:[...new Set(t.flatMap(e=>e.variableIds))].sort(),styles:[r,n,i,a].flatMap((e,t)=>e.map(e=>({id:e.id,styleType:l[t]}))).sort((e,t)=>e.id.localeCompare(t.id)),localComplete:!0,usedRemoteComplete:!1}},variables:async function(e){let t=e.resources;function r(e,t=new WeakSet,n=0){if(null===e||["string","number","boolean"].includes(typeof e))return e;if(void 0===e)return{$undefined:!0};if("bigint"==typeof e)return{$bigint:e.toString()};if(["function","symbol"].includes(typeof e))return{$unsupported:typeof e};if(n>12)return{$truncated:"max-depth"};if("object"==typeof e&&"parent"in e&&"string"==typeof e.id&&"string"==typeof e.type)return{$nodeId:e.id,$nodeType:e.type};if(Array.isArray(e))return e.map(e=>r(e,t,n+1));if(ArrayBuffer.isView(e))return{$binary:e.constructor.name,byteLength:e.byteLength};if(e instanceof ArrayBuffer)return{$binary:"ArrayBuffer",byteLength:e.byteLength};if(t.has(e))return{$circular:!0};t.add(e);let i={},a=new Set(Object.keys(e)),l=e;for(;l&&l!==Object.prototype;){for(let e of Object.getOwnPropertyNames(l))a.add(e);l=Object.getPrototypeOf(l)}for(let l of[...a].sort())if(!(l.startsWith("_")||["parent","children","consumers"].includes(l)))try{let a=r(e[l],t,n+1);a&&"function"===a.$unsupported||(i[l]=a)}catch(e){i[l]={$error:String(e&&e.message?e.message:e)}}return t.delete(e),i}let[n,i]=await Promise.all([Promise.all(t.variableIds.map(e=>figma.variables.getVariableByIdAsync(e))),Promise.all(t.styles.map(e=>figma.getStyleByIdAsync(e.id)))]),a=new Map(t.styles.map(e=>[e.id,e])),l=await Promise.all(i.filter(Boolean).map(async e=>{let t=a.get(e.id),n=t.styleType;if(Number.isInteger(t.consumerStart)&&Number.isInteger(t.consumerEnd)){let i=await e.getStyleConsumersAsync();return{id:e.id,styleType:n,$consumerStart:t.consumerStart,$consumerEntries:i.slice(t.consumerStart,t.consumerEnd).map(e=>[e.node.id,e.node.type,r(e.fields)])}}let i=await e.getStyleConsumersAsync();return{...r(e),styleType:n,$consumerCount:i.length,value:r("PAINT"===n?e.paints:"EFFECT"===n?e.effects:"GRID"===n?e.layoutGrids:e)}}));return{variables:n.filter(Boolean).map(e=>r(e)),styles:l}}};function t(){let e=figma.currentPage,t=e.selection;return{currentPage:{id:e.id,name:e.name},selection:t.slice(0,20).map(e=>({id:e.id,name:e.name,type:e.type})),selectionCount:t.length}}function r(){let e={kind:"devup-context",...t()};figma.ui.postMessage(e)}async function n(t){let r=e[t.script];if(!r)return{kind:"devup-result",requestId:t.requestId,error:`DEVUP_BRIDGE_UNKNOWN_SCRIPT: ${t.script} (아는 스크립트: ${Object.keys(e).join(", ")})`};try{var n;let e=await r({nodeId:(n=t.params).nodeId??"",rootIds:n.rootIds??[],snapshot:n.snapshot??{},search:n.search??{},explore:n.explore??{},resources:n.resources??{variableIds:[],styles:[]},largeValue:n.largeValue??{},asset:n.asset??{},theme:n.theme??{offset:0}});return{kind:"devup-result",requestId:t.requestId,data:e}}catch(e){return{kind:"devup-result",requestId:t.requestId,error:e instanceof Error?e.message:String(e)}}}let i=[],a=!1;async function l(){if(!a){a=!0;try{for(;i.length>0;){let e=i.shift(),t=await n(e).catch(t=>({kind:"devup-result",requestId:e.requestId,error:t instanceof Error?t.message:String(t)}));figma.ui.postMessage(t)}}finally{a=!1}}}figma.showUI(__html__,{width:320,height:220}),figma.on("selectionchange",r),figma.on("currentpagechange",r),figma.ui.onmessage=e=>{if("object"==typeof e&&null!==e){if("devup-ready"===e.kind){let e={kind:"devup-status",fileKey:figma.fileKey??null,fileName:figma.root.name,port:1993,...t()};figma.ui.postMessage(e);return}"devup-job"===e.kind&&(i.push(e),l())}}})(); \ No newline at end of file diff --git a/plugin/dist/ui.html b/plugin/dist/ui.html index 6251f6f7..9e98ecf5 100644 --- a/plugin/dist/ui.html +++ b/plugin/dist/ui.html @@ -45,4 +45,4 @@ padding-top: 12px; border-top: 1px solid #e6e6e6; color: #8c8c8c; - }

Devup Bridge

이 창을 열어 두면 devup-mcp 가 이 파일을 직접 읽습니다.

시작하는 중…

읽기 전용입니다. 문서를 바꾸지 않고, 데이터는 같은 기기의 devup-mcp 로만 나갑니다.

\ No newline at end of file + }

Devup Bridge

이 창을 열어 두면 devup-mcp 가 이 파일을 직접 읽습니다.

시작하는 중…

읽기 전용입니다. 문서를 바꾸지 않고, 데이터는 같은 기기의 devup-mcp 로만 나갑니다.

\ No newline at end of file diff --git a/plugin/src/code.ts b/plugin/src/code.ts index 580b2b9b..f33d8321 100644 --- a/plugin/src/code.ts +++ b/plugin/src/code.ts @@ -25,15 +25,62 @@ interface ResultMessage { error?: string } -interface StatusMessage { +/** 페이지나 선택한 노드 하나. */ +interface NodeRef { + id: string + name: string + type?: string +} + +/** + * 지금 보고 있는 페이지와 거기서 선택한 것. + * + * devup-mcp 는 url 없는 요청을 이 값으로 해석한다 — 연 파일의, 선택한 노드. + * 선택이 바뀔 때마다 보내므로 늦게 도착해도 최신이다. + */ +interface Context { + currentPage: NodeRef + selection: NodeRef[] + selectionCount: number +} + +interface StatusMessage extends Context { kind: 'devup-status' fileKey: string | null fileName: string port: number } +interface ContextMessage extends Context { + kind: 'devup-context' +} + const PORT = 1993 +/** + * 선택은 앞의 이만큼만 보낸다. 레이어 수천 개를 한꺼번에 고르면 메시지가 + * 그만큼 커지는데, 요청을 해석하는 데 필요한 것은 "정확히 하나인가"와 그 하나다. + * 전체 수는 selectionCount 로 따로 간다. + */ +const SELECTION_LIMIT = 20 + +function context(): Context { + const page = figma.currentPage + const selection = page.selection + return { + currentPage: { id: page.id, name: page.name }, + selection: selection + .slice(0, SELECTION_LIMIT) + .map((node) => ({ id: node.id, name: node.name, type: node.type })), + selectionCount: selection.length, + } +} + +function postContext() { + const message: ContextMessage = { kind: 'devup-context', ...context() } + figma.ui.postMessage(message) +} + /** * 스크립트가 읽는 자리를 모두 채운 파라미터. * @@ -118,6 +165,10 @@ async function drain() { figma.showUI(__html__, { width: 320, height: 220 }) +// 읽기 스크립트도 페이지를 옮기므로 사람이 옮긴 것과 함께 이 이벤트로 온다. +figma.on('selectionchange', postContext) +figma.on('currentpagechange', postContext) + figma.ui.onmessage = (message: unknown) => { if (typeof message !== 'object' || message === null) return const msg = message as { kind?: string } @@ -128,6 +179,7 @@ figma.ui.onmessage = (message: unknown) => { fileKey: figma.fileKey ?? null, fileName: figma.root.name, port: PORT, + ...context(), } figma.ui.postMessage(status) return diff --git a/plugin/src/ui.ts b/plugin/src/ui.ts index ef03f0c2..9e2a286c 100644 --- a/plugin/src/ui.ts +++ b/plugin/src/ui.ts @@ -6,13 +6,30 @@ // 연결은 끊어지는 것이 정상이다 — devup-mcp 는 MCP 클라이언트가 뜰 때마다 새로 // 시작한다. 그래서 실패를 예외가 아니라 상태로 다루고 계속 재시도한다. -interface StatusMessage { +interface NodeRef { + id: string + name: string + type?: string +} + +/** 보고 있는 페이지와 선택. 메인 스레드가 바뀔 때마다 보낸다. */ +interface Context { + currentPage: NodeRef + selection: NodeRef[] + selectionCount: number +} + +interface StatusMessage extends Context { kind: 'devup-status' fileKey: string | null fileName: string port: number } +interface ContextMessage extends Context { + kind: 'devup-context' +} + interface ResultMessage { kind: 'devup-result' requestId: string @@ -58,11 +75,15 @@ function connect() { ws.onopen = () => { render('on', 'devup-mcp 에 연결됨') // 어느 파일인지 먼저 알려야 devup-mcp 가 요청을 이 소켓으로 보낼 수 있다. + // 페이지와 선택도 함께 보낸다 — url 없는 요청은 그것으로 대상을 고른다. ws.send( JSON.stringify({ kind: 'hello', fileKey: bridgeStatus?.fileKey ?? null, fileName: bridgeStatus?.fileName ?? '', + currentPage: bridgeStatus?.currentPage ?? null, + selection: bridgeStatus?.selection ?? [], + selectionCount: bridgeStatus?.selectionCount ?? 0, }), ) } @@ -99,6 +120,30 @@ window.onmessage = (event: MessageEvent) => { return } + if (msg.kind === 'devup-context') { + const context = msg as ContextMessage + // 상태를 받기 전이면 버린다 — 상태가 그때의 값을 싣고 온다. 연결 전이면 + // 보관만 해서 hello 가 싣고 가게 한다. + if (!bridgeStatus) return + bridgeStatus = { + ...bridgeStatus, + currentPage: context.currentPage, + selection: context.selection, + selectionCount: context.selectionCount, + } + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send( + JSON.stringify({ + kind: 'context', + currentPage: context.currentPage, + selection: context.selection, + selectionCount: context.selectionCount, + }), + ) + } + return + } + if (msg.kind === 'devup-result') { const result = msg as ResultMessage if (socket && socket.readyState === WebSocket.OPEN) { From 540eeab7374fd00b5ecfc1671dcc5388c3789122 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 25 Sep 2026 22:07:58 +0900 Subject: [PATCH 03/10] feat(figma): track bridge plugins per connection and record which one answered Plugins were registered by file key, so two that could not report one shared the empty key: the second replaced the first, the first one's disconnect erased the second, and 'exactly one plugin attached' could not be counted. They are tracked per connection now, with the page and selection each reports. A plugin that could not report its key is reached through a connection-scoped key that never falls through to the direct path. Every bridge answer carries which plugin gave it, the collector keeps that with the acquisition, and paths.bridge.attachedFiles lists the file name, page and selection of each plugin. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp-figma/src/bridge.rs | 366 ++++++++++++---- crates/devup-mcp-figma/src/collector.rs | 9 + crates/devup-mcp-figma/src/lib.rs | 4 +- .../devup-mcp-figma/tests/bridge_transport.rs | 200 ++++++++- crates/devup-mcp/src/server/diagnostics.rs | 412 +++++++++++++++--- 5 files changed, 861 insertions(+), 130 deletions(-) diff --git a/crates/devup-mcp-figma/src/bridge.rs b/crates/devup-mcp-figma/src/bridge.rs index f1d63c81..a5d3c6ca 100644 --- a/crates/devup-mcp-figma/src/bridge.rs +++ b/crates/devup-mcp-figma/src/bridge.rs @@ -40,6 +40,7 @@ use tokio::{ use crate::{ errors::{DevupError, ErrorCode}, upstream::{BatchBudget, FigmaUpstream, ReadToolCall, UpstreamResult}, + url::{BRIDGE_KEY_PREFIX, is_bridge_only_key}, }; /// 플러그인 manifest 의 `allowedDomains` 와 같은 값이어야 한다. 바꾸려면 양쪽을 @@ -50,12 +51,83 @@ pub const DEFAULT_BRIDGE_PORT: u16 = 1993; /// 훨씬 짧아도 되지만, 아주 큰 페이지의 첫 스냅샷은 몇 초가 걸린다. const JOB_TIMEOUT: Duration = Duration::from_secs(90); -/// 플러그인이 붙을 때 보내는 첫 메시지. -#[derive(Debug, Deserialize)] +/// 브리지가 답한 결과의 `_meta` 에서 어느 플러그인이 답했는지를 싣는 자리. +const SERVED_META_KEY: &str = "devup/bridge"; + +/// 플러그인이 가리키는 노드 하나 — 보고 있는 페이지, 또는 거기서 선택한 것. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeRef { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub node_type: Option, +} + +/// Figma 를 쓰는 사람이 지금 있는 곳: 보고 있는 페이지와 거기서 선택한 것. +/// +/// 모두 선택 사항이다. 이것을 보고하기 전에 빌드된 플러그인은 아무것도 보내지 +/// 않고, "이 플러그인은 말하지 않는다"와 "아무것도 선택하지 않았다"는 계속 +/// 구분되어야 한다 — 앞의 것은 플러그인을 다시 띄울 일이고, 뒤의 것은 Figma 에서 +/// 고를 일이다. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct Hello { +pub struct PluginContext { + #[serde(default)] + pub current_page: Option, + /// 선택한 노드의 앞부분. 플러그인이 보내는 만큼만 있고, 전체 수는 + /// `selection_count` 다. #[serde(default)] - file_key: Option, + pub selection: Option>, + #[serde(default)] + pub selection_count: Option, +} + +impl PluginContext { + /// 정확히 하나가 선택돼 있으면 그 노드. + pub fn single_selection(&self) -> Option<&NodeRef> { + match self.selection.as_deref()? { + [node] if self.selection_count.is_none_or(|count| count == 1) => Some(node), + _ => None, + } + } +} + +/// 붙어 있는 플러그인 하나. `status` 가 보고하는 것이자, url 없는 요청이 +/// 가리키게 되는 것이다. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttachedFile { + /// 읽기가 이 플러그인에 닿으려면 부를 이름. 플러그인이 보고한 파일 키이고, + /// 보고하지 못했으면 이 연결만 가리키는 브리지 전용 키다. 호출자에게는 + /// 보이지 않는다 — 플러그인이 보고하지 않은 키는 그 파일의 키가 아니다. + pub target_key: String, + /// 플러그인이 보고한 파일 키. 보고하지 못했으면 `None`. + pub file_key: Option, + pub file_name: Option, + pub context: PluginContext, +} + +/// 수집의 읽기를 어느 플러그인이 답했는지. +/// +/// 수집 결과와 함께 보관되어, 새로 투영하든 재사용하든 같은 출처를 말한다. +/// 파일 키는 플러그인이 보고한 것뿐이다 — 보고하지 못했으면 키를 말하지 않으며, +/// 요청에 실려 온 키를 그 파일의 키로 내세우지 않는다. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgeServed { + /// 수집의 읽기 가운데 플러그인이 답한 수. + pub reads: usize, + pub port: Option, + pub file_key: Option, + pub file_name: Option, + pub page_name: Option, +} + +impl BridgeServed { + /// 브리지가 답한 결과면 그 출처, 아니면 `None`. + pub(crate) fn from_result(raw: &Value) -> Option { + serde_json::from_value(raw.get("_meta")?.get(SERVED_META_KEY)?.clone()).ok() + } } /// 플러그인이 작업을 마치고 보내는 메시지. @@ -90,13 +162,49 @@ pub struct BridgeJob { struct Connected { outbox: mpsc::UnboundedSender, + /// 플러그인이 보고한 파일 키. 보고하지 못했으면 빈 문자열이다. + file_key: String, + file_name: Option, + context: PluginContext, +} + +impl Connected { + fn reported_key(&self) -> Option { + (!self.file_key.is_empty()).then(|| self.file_key.clone()) + } + + fn attached(&self, id: u64) -> AttachedFile { + AttachedFile { + target_key: self.reported_key().unwrap_or_else(|| connection_key(id)), + file_key: self.reported_key(), + file_name: self.file_name.clone(), + context: self.context.clone(), + } + } + + fn served(&self) -> BridgeServed { + BridgeServed { + reads: 1, + port: None, + file_key: self.reported_key(), + file_name: self.file_name.clone(), + page_name: self + .context + .current_page + .as_ref() + .map(|page| page.name.clone()), + } + } } #[derive(Default)] struct Inner { - /// fileKey → 그 파일을 열어 둔 플러그인. 같은 파일을 두 창에서 열면 나중에 - /// 붙은 쪽이 이긴다. 둘 다 같은 문서를 보므로 어느 쪽이든 답은 같다. - plugins: HashMap, + /// 연결 번호 → 플러그인. + /// + /// 파일 키로 묶지 않는다. 키를 보고하지 못한 플러그인들은 모두 빈 키라서, + /// 그렇게 묶으면 둘째가 첫째를 덮어쓰고 첫째가 끊길 때 둘째의 등록까지 + /// 지운다. 그러면 "플러그인이 정확히 하나"를 셀 수도 없다. + plugins: HashMap, /// requestId → 결과를 기다리는 쪽. pending: HashMap>, } @@ -105,6 +213,7 @@ struct Inner { #[derive(Clone, Default)] pub struct BridgeState { inner: Arc>, + /// 요청 번호와 연결 번호를 함께 매긴다. 둘 다 유일하기만 하면 된다. counter: Arc, /// 붙어 있는 플러그인 수. 배치 크기를 정할 때는 잠금을 기다릴 수 없어 /// (그 자리가 async 가 아니다) 따로 센다. @@ -115,26 +224,54 @@ fn unavailable(message: impl Into) -> DevupError { DevupError::new(ErrorCode::DevupFigmaDirectUnavailable, message, false) } -/// 어느 플러그인이 이 파일을 맡을지 고른다. +/// 이 연결 하나만 가리키는 브리지 전용 키. +fn connection_key(id: u64) -> String { + format!("{BRIDGE_KEY_PREFIX}{id}") +} + +fn connection_id(file_key: &str) -> Option { + file_key.strip_prefix(BRIDGE_KEY_PREFIX)?.parse().ok() +} + +/// 오류 문구에 쓸 파일 이름. 브리지 전용 키는 파일의 키가 아니므로 드러내지 않는다. +fn describe_file(file_key: &str) -> String { + if is_bridge_only_key(file_key) { + "the file this request was addressed to".to_owned() + } else { + format!("file {file_key}") + } +} + +/// 어느 연결이 이 파일을 맡을지 고른다. /// -/// 보통은 파일 키가 그대로 맞는다. 다만 `figma.fileKey` 는 늘 있는 값이 아니어서 -/// (Dev Mode 에서 비어 오는 것을 실제로 봤다) 키 없이 붙는 플러그인이 생긴다. -/// 그때 등록을 건너뛰면 창은 "연결됨"이라고 하는데 어떤 읽기도 오지 않는 — -/// 원인을 찾기 가장 어려운 — 상태가 된다. +/// 보통은 파일 키가 그대로 맞는다. 같은 파일을 두 창에서 열었으면 나중에 붙은 +/// 쪽이 맡는다 — 둘 다 같은 문서를 보므로 어느 쪽이든 답은 같다. /// -/// 그래서 키 없는 플러그인은 **혼자 붙어 있을 때만** 맡는다. 여럿이면 어느 파일을 -/// 보고 있는지 알 수 없고, 엉뚱한 파일을 읽어 주는 것보다 원격으로 넘기는 편이 낫다. -fn resolve_key(plugins: &HashMap, file_key: &str) -> Option { - if plugins.contains_key(file_key) { - return Some(file_key.to_owned()); - } - if plugins.len() == 1 - && let Some(key) = plugins.keys().next() - && key.is_empty() - { - return Some(key.clone()); +/// 다만 `figma.fileKey` 는 늘 있는 값이 아니어서 (Dev Mode 에서 비어 오는 것을 +/// 실제로 봤다) 키 없이 붙는 플러그인이 생긴다. 그때 등록을 건너뛰면 창은 +/// "연결됨"이라고 하는데 어떤 읽기도 오지 않는 — 원인을 찾기 가장 어려운 — 상태가 +/// 된다. 그래서 키 없는 플러그인은 **혼자 붙어 있을 때만** 아무 키나 맡는다. +/// 여럿이면 어느 파일을 보고 있는지 알 수 없고, 엉뚱한 파일을 읽어 주는 것보다 +/// 원격으로 넘기는 편이 낫다. +/// +/// 연결 키는 그 연결 하나만 가리킨다. 몇 개가 붙어 있든 모호하지 않고, 그 연결이 +/// 끊기면 아무도 맡지 않는다 — 다른 파일의 플러그인이 대신 답하면 안 된다. +fn resolve(plugins: &HashMap, file_key: &str) -> Option { + if is_bridge_only_key(file_key) { + return connection_id(file_key).filter(|id| plugins.contains_key(id)); + } + let holding = plugins + .iter() + .filter(|(_, plugin)| plugin.file_key == file_key) + .map(|(id, _)| *id) + .max(); + if holding.is_some() { + return holding; + } + match plugins.iter().next() { + Some((id, plugin)) if plugins.len() == 1 && plugin.file_key.is_empty() => Some(*id), + _ => None, } - None } impl BridgeState { @@ -144,36 +281,51 @@ impl BridgeState { /// 이 파일을 열어 둔 플러그인이 있는지. pub async fn has_plugin(&self, file_key: &str) -> bool { - resolve_key(&self.inner.lock().await.plugins, file_key).is_some() + resolve(&self.inner.lock().await.plugins, file_key).is_some() } - /// 붙어 있는 파일 키 목록. 진단용. + /// 붙어 있는 플러그인이 보고한 파일 키. 보고하지 못한 플러그인은 빈 문자열이다. pub async fn connected_files(&self) -> Vec { - let mut keys: Vec = self.inner.lock().await.plugins.keys().cloned().collect(); + let mut keys: Vec = self + .inner + .lock() + .await + .plugins + .values() + .map(|plugin| plugin.file_key.clone()) + .collect(); keys.sort(); keys } + /// 붙어 있는 플러그인 전부를 붙은 순서대로. + pub async fn attached_files(&self) -> Vec { + let inner = self.inner.lock().await; + let mut plugins: Vec<_> = inner.plugins.iter().collect(); + plugins.sort_unstable_by_key(|(id, _)| **id); + plugins + .into_iter() + .map(|(id, plugin)| plugin.attached(*id)) + .collect() + } + async fn dispatch( &self, file_key: &str, script: &'static str, params: Value, - ) -> Result { + ) -> Result<(Value, BridgeServed), DevupError> { let request_id = self.next_request_id(); let (tx, rx) = oneshot::channel(); - { + let served = { let mut inner = self.inner.lock().await; - let Some(resolved) = resolve_key(&inner.plugins, file_key) else { + let Some((id, plugin)) = resolve(&inner.plugins, file_key) + .and_then(|id| inner.plugins.get(&id).map(|plugin| (id, plugin))) + else { return Err(unavailable(format!( - "no Devup Bridge plugin is open for file {file_key}" - ))); - }; - let file_key = resolved.as_str(); - let Some(plugin) = inner.plugins.get(file_key) else { - return Err(unavailable(format!( - "no Devup Bridge plugin is open for file {file_key}" + "no Devup Bridge plugin is open for {}", + describe_file(file_key) ))); }; let job = Job { @@ -184,15 +336,20 @@ impl BridgeState { }; let encoded = serde_json::to_string(&job) .map_err(|error| unavailable(format!("bridge job encode failed: {error}")))?; - if plugin.outbox.send(encoded).is_err() { + let sent = plugin.outbox.send(encoded).is_ok(); + let served = plugin.served(); + if !sent { // 소켓이 막 닫혔다. 등록을 지워 다음 호출이 곧장 폴백하도록 한다. - inner.plugins.remove(file_key); + inner.plugins.remove(&id); + self.connected.store(inner.plugins.len(), Ordering::Relaxed); return Err(unavailable(format!( - "the Devup Bridge plugin for file {file_key} disconnected" + "the Devup Bridge plugin for {} disconnected", + describe_file(file_key) ))); } inner.pending.insert(request_id.clone(), tx); - } + served + }; let received = timeout(JOB_TIMEOUT, rx).await; // 성공이든 실패든 대기표는 반드시 걷는다. 남겨 두면 연결이 오래 살아 있는 @@ -208,7 +365,7 @@ impl BridgeState { message, false, )), - (Some(data), None) => Ok(data), + (Some(data), None) => Ok((data, served)), (None, None) => Err(unavailable("bridge returned neither data nor error")), }, // 플러그인 창이 닫혔다. @@ -252,7 +409,9 @@ fn widen_budgets(params: &mut Value) { /// 그대로 두면 노드를 다 받고도 "스냅샷을 찾지 못했다"며 버린다. /// /// 지어내는 값이 아니다. 이 읽기가 어느 파일을 향했는지는 호출자가 알고 있고, -/// 그 파일을 이 플러그인이 맡는다는 판단은 이미 `resolve_key` 가 내렸다. +/// 그 파일을 이 플러그인이 맡는다는 판단은 이미 `resolve` 가 내렸다. 이 값은 +/// 디코더의 대조에만 쓰이고, 파일 키로 보고되는 것은 [`BridgeServed`] 가 싣는 +/// 플러그인 자신의 보고뿐이다. fn stamp_file_key(data: &mut Value, file_key: &str) { let Some(object) = data.as_object_mut() else { return; @@ -270,10 +429,20 @@ fn stamp_file_key(data: &mut Value, file_key: &str) { /// /// 디코더들은 `content[].text` 안의 JSON 문자열을 찾도록 쓰여 있다. 값을 그대로 /// 올리면 일부 디코더는 통과하고 일부는 실패해, 두 경로가 화면 단위로 갈라진다. -fn wrap_as_tool_result(data: &Value) -> Result { - let text = serde_json::to_string(data) - .map_err(|error| unavailable(format!("bridge result encode failed: {error}")))?; - Ok(json!({ "content": [{ "type": "text", "text": text }], "isError": false })) +/// +/// 어느 플러그인이 답했는지는 `_meta` 에 싣는다. 디코더는 그 자리를 읽지 않으므로 +/// 두 경로의 모양은 그대로이고, 수집기는 이것으로 응답의 출처를 브리지로 적는다. +fn wrap_as_tool_result(data: &Value, served: &BridgeServed) -> Result { + let encode = + |error: serde_json::Error| unavailable(format!("bridge result encode failed: {error}")); + let text = serde_json::to_string(data).map_err(encode)?; + let mut result = json!({ + "content": [{ "type": "text", "text": text }], + "isError": false, + "_meta": {}, + }); + result["_meta"][SERVED_META_KEY] = serde_json::to_value(served).map_err(encode)?; + Ok(result) } async fn plugin_socket(State(state): State, upgrade: WebSocketUpgrade) -> Response { @@ -282,7 +451,7 @@ async fn plugin_socket(State(state): State, upgrade: WebSocketUpgra async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { let (outbox, mut outbox_rx) = mpsc::unbounded_channel::(); - let mut registered: Option = None; + let id = state.counter.fetch_add(1, Ordering::Relaxed); // 보내기와 받기를 한 루프에서 번갈아 본다. 소켓을 쪼개려면 Stream/Sink 트레이트 // 의존성이 필요한데, 그것을 들이는 것보다 select 가 싸다. @@ -303,20 +472,37 @@ async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { Some("hello") => { // 키가 없어도 등록한다. 건너뛰면 창은 "연결됨"이라고 하는데 // 어떤 읽기도 오지 않아 원인을 찾을 수 없다. 키 없는 연결을 - // 어디까지 믿을지는 resolve_key 가 정한다. - let key = serde_json::from_value::(value) - .ok() - .and_then(|hello| hello.file_key) - .unwrap_or_default(); - state.inner.lock().await.plugins.insert( - key.clone(), - Connected { outbox: outbox.clone() }, - ); - state.connected.store( - state.inner.lock().await.plugins.len(), - Ordering::Relaxed, - ); - registered = Some(key); + // 어디까지 믿을지는 resolve 가 정한다. + // + // 필드마다 따로 읽는다. 페이지나 선택이 어긋난 모양으로 와도 + // 파일 키까지 잃으면 안 된다. + let plugin = Connected { + outbox: outbox.clone(), + file_key: value + .get("fileKey") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + file_name: value + .get("fileName") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + .map(str::to_owned), + context: serde_json::from_value(value).unwrap_or_default(), + }; + let mut inner = state.inner.lock().await; + inner.plugins.insert(id, plugin); + state.connected.store(inner.plugins.len(), Ordering::Relaxed); + } + // 페이지를 옮기거나 선택을 바꿀 때마다 온다. url 없는 요청은 + // 이 값으로 대상을 고르므로, 늦게라도 최신이어야 한다. + Some("context") => { + let Ok(context) = serde_json::from_value::(value) else { + continue; + }; + if let Some(plugin) = state.inner.lock().await.plugins.get_mut(&id) { + plugin.context = context; + } } Some("devup-result") => { let Ok(result) = serde_json::from_value::(value) else { @@ -334,12 +520,11 @@ async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { } } - if let Some(key) = registered { - state.inner.lock().await.plugins.remove(&key); - } + let mut inner = state.inner.lock().await; + inner.plugins.remove(&id); state .connected - .store(state.inner.lock().await.plugins.len(), Ordering::Relaxed); + .store(inner.plugins.len(), Ordering::Relaxed); } /// 브리지 서버. 포트를 잡지 못하면 열지 않으며, 그 경우 호출자는 원격 경로만 쓴다. @@ -427,9 +612,9 @@ pub trait PreferredUpstream: FigmaUpstream { pub struct BridgePathSnapshot { /// 실제로 잡은 포트. 플러그인 manifest 의 `allowedDomains` 와 같아야 붙는다. pub port: Option, - /// 지금 붙어 있는 플러그인이 열어 둔 파일 키. 빈 문자열은 자기 파일 키를 - /// 보고하지 못한 플러그인이며, 혼자 붙어 있을 때만 읽기를 받는다. - pub attached_files: Vec, + /// 지금 붙어 있는 플러그인, 붙은 순서대로. 파일 키를 보고하지 못한 + /// 플러그인은 혼자 붙어 있을 때만 다른 키의 읽기를 받는다. + pub attached_files: Vec, } /// 플러그인을 통해 Figma 를 읽는 `FigmaUpstream`. @@ -467,13 +652,17 @@ impl FigmaUpstream for BridgeFigmaClient { }; let mut params = job.params; widen_budgets(&mut params); - let mut data = self + let (mut data, served) = self .state .dispatch(call.file_key(), job.script, params) .await?; stamp_file_key(&mut data, call.file_key()); + let served = BridgeServed { + port: self.port, + ..served + }; Ok(UpstreamResult { - raw: wrap_as_tool_result(&data)?, + raw: wrap_as_tool_result(&data, &served)?, }) } @@ -488,7 +677,7 @@ impl FigmaUpstream for BridgeFigmaClient { async fn bridge_path_snapshot(&self) -> Option { Some(BridgePathSnapshot { port: self.port, - attached_files: self.state.connected_files().await, + attached_files: self.state.attached_files().await, }) } } @@ -565,6 +754,11 @@ where if self.preferred.can_serve(&call).await { return self.preferred.call_read_tool(call).await; } + // 브리지만 읽을 수 있는 키다. 원격으로 넘기면 Figma 에 없는 키를 묻게 되고, + // 돌아오는 것은 원인과 무관한 거절 — 로그인하라거나 파일이 없다는 — 뿐이다. + if is_bridge_only_key(call.file_key()) { + return Err(bridge_only_refusal(&call)); + } self.secondary.call_read_tool(call).await } @@ -585,11 +779,37 @@ where /// 이 파일을 맡은 플러그인이 있으면 원격 자격증명 없이도 수집이 성립한다. /// 뒤엣것은 원격이므로 물을 것이 없다. + /// + /// 브리지만 읽을 수 있는 키는 로그인을 기다리지 않는다. direct 경로는 그 키로는 + /// 무엇을 치러도 읽을 수 없으므로, 로그인을 요구하는 것은 엉뚱한 처방이다. + /// 플러그인이 그새 끊겼다면 그 거절은 첫 읽기가 제 이유와 함께 한다. async fn serves_without_credentials(&self, file_key: &str) -> bool { - self.preferred.serves_without_credentials(file_key).await + is_bridge_only_key(file_key) || self.preferred.serves_without_credentials(file_key).await } async fn bridge_path_snapshot(&self) -> Option { self.preferred.bridge_path_snapshot().await } } + +/// 브리지만 읽을 수 있는 키의 읽기를 브리지가 맡지 못할 때의 거절. +/// +/// 두 경우는 고치는 방법이 다르다. 스크립트 읽기면 플러그인이 끊긴 것이니 다시 +/// 띄우면 되고, 그 밖의 읽기(`get_screenshot` 등)는 브리지가 애초에 못 하는 일이라 +/// 그 파일의 진짜 Figma 링크가 있어야 direct 경로로 읽을 수 있다. +fn bridge_only_refusal(call: &ReadToolCall) -> DevupError { + let message = if call.bridge_job().is_some() { + "The Devup Bridge plugin this request was addressed to is no longer attached. Run it \ + again on the file in the Figma desktop app, then repeat the call." + } else { + "This read cannot go through the Devup Bridge plugin, and the plugin could not report \ + the file's Figma key, so the direct path cannot make it either. Repeat the call with the \ + file's own Figma link (Share -> Copy link) to read it through the direct path." + }; + DevupError::with_details( + ErrorCode::DevupFigmaDirectUnavailable, + message, + false, + json!({ "source": "bridge", "stage": "bridge-routing", "tool": call.tool_name() }), + ) +} diff --git a/crates/devup-mcp-figma/src/collector.rs b/crates/devup-mcp-figma/src/collector.rs index 1686fe6a..544d25f0 100644 --- a/crates/devup-mcp-figma/src/collector.rs +++ b/crates/devup-mcp-figma/src/collector.rs @@ -161,6 +161,10 @@ pub struct CollectionStats { pub raw_bytes: usize, pub wire_bytes: usize, pub envelope_chunks: usize, + /// Set when the Devup Bridge plugin answered any of `figma_tool_calls`, + /// naming the plugin; the rest went to Figma's metered MCP. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bridge: Option, } impl Default for CollectionStats { @@ -179,6 +183,7 @@ impl Default for CollectionStats { raw_bytes: 0, wire_bytes: 0, envelope_chunks: 0, + bridge: None, } } } @@ -604,6 +609,10 @@ impl CollectorSession { .remove(call_id) .ok_or_else(|| invalid_call("Unknown or already-handled Figma call ID."))?; self.consumed.insert(call_id.to_owned()); + if let Some(served) = crate::BridgeServed::from_result(&result.raw) { + let reads = self.stats.bridge.as_ref().map_or(0, |bridge| bridge.reads) + served.reads; + self.stats.bridge = Some(crate::BridgeServed { reads, ..served }); + } match pending.kind { CallKind::FastSnapshot => { self.accept_fast_snapshot(&pending.planned, pending.order, result) diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index dba42ba1..b5a89a46 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -23,8 +23,8 @@ pub use collector::{ }; pub use bridge::{ - BridgeFigmaClient, BridgeJob, BridgePathSnapshot, BridgeServer, BridgeState, - DEFAULT_BRIDGE_PORT, FallbackUpstream, PreferredUpstream, + AttachedFile, BridgeFigmaClient, BridgeJob, BridgePathSnapshot, BridgeServed, BridgeServer, + BridgeState, DEFAULT_BRIDGE_PORT, FallbackUpstream, NodeRef, PluginContext, PreferredUpstream, }; pub use credentials::{ ClientCredentialStore, ClientCredentials, CredentialStore, KeyringClientCredentialStore, diff --git a/crates/devup-mcp-figma/tests/bridge_transport.rs b/crates/devup-mcp-figma/tests/bridge_transport.rs index 1c2cc41d..b7c892c9 100644 --- a/crates/devup-mcp-figma/tests/bridge_transport.rs +++ b/crates/devup-mcp-figma/tests/bridge_transport.rs @@ -5,7 +5,8 @@ //! 모든 것 — 등록, 이름·값 매핑, 상관, 봉투 모양, 라우팅 판정 — 을 확인한다. use devup_mcp_figma::{ - BridgeFigmaClient, BridgeServer, FigmaUpstream, PreferredUpstream, ReadToolCall, + BridgeFigmaClient, BridgeServed, BridgeServer, DevupError, FallbackUpstream, FigmaUpstream, + PreferredUpstream, ReadToolCall, UpstreamResult, }; use futures_util::{SinkExt, StreamExt}; use serde_json::{Value, json}; @@ -289,8 +290,8 @@ async fn two_keyless_plugins_are_ambiguous_and_neither_serves() { let client = BridgeFigmaClient::new(server.state()); let _first = connect_plugin_as(&server, None).await; - // 둘 다 키가 없으면 같은 자리에 들어가므로, 서로 다른 파일의 플러그인을 하나 - // 더 붙여 "여럿"을 만든다. + // 다른 파일을 연 플러그인이 하나 더 붙으면 키 없는 쪽은 더는 혼자가 아니다. + // 둘 다 키가 없는 경우는 `two_keyless_plugins_are_counted_apart` 가 본다. let _second = connect_plugin_as(&server, Some("AnotherFile")).await; assert!( @@ -300,6 +301,199 @@ async fn two_keyless_plugins_are_ambiguous_and_neither_serves() { ); } +/// 플러그인 흉내: `hello` 를 그대로 보내고, 붙은 플러그인이 `count` 개가 될 +/// 때까지 기다린다. 키 없는 플러그인이 둘이면 `connected_files` 로는 둘째의 +/// 등록을 가릴 수 없어서 수로 기다린다. +async fn attach(server: &BridgeServer, hello: Value, count: usize) -> Plugin { + let (mut socket, _) = connect_async(format!("ws://127.0.0.1:{}/plugin", server.port())) + .await + .expect("bridge accepts a plugin"); + socket + .send(Message::Text(hello.to_string().into())) + .await + .expect("hello is sent"); + let state = server.state(); + for _ in 0..200 { + if state.attached_files().await.len() == count { + return socket; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("plugin never registered"); +} + +async fn answer(plugin: &mut Plugin, job: &Value, data: Value) { + plugin + .send(Message::Text( + json!({ "kind": "devup-result", "requestId": job["requestId"], "data": data }) + .to_string() + .into(), + )) + .await + .expect("the result is sent"); +} + +/// 플러그인이 보고하는 페이지와 선택은 `status` 가 그대로 보여 주고, url 없는 +/// 요청은 그것으로 대상을 고른다. 키를 보고하지 못한 플러그인에는 그 연결만 +/// 가리키는 키가 주어지고, 그 키로 보낸 읽기는 그 플러그인에 닿으며, 답에는 +/// 파일 키를 지어내지 않은 출처가 실린다. +#[tokio::test] +async fn a_keyless_plugin_reports_where_it_is_and_answers_its_own_connection() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = attach( + &server, + json!({ + "kind": "hello", "fileKey": null, "fileName": "Landing", + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [{ "id": "1:2", "name": "Hero", "type": "FRAME" }], + "selectionCount": 1, + }), + 1, + ) + .await; + let state = server.state(); + let attached = state.attached_files().await; + let file = &attached[0]; + assert_eq!(file.file_key, None); + assert_eq!(file.file_name.as_deref(), Some("Landing")); + assert_eq!( + file.context.single_selection().map(|node| node.id.as_str()), + Some("1:2") + ); + assert!(devup_mcp_figma::is_bridge_only_key(&file.target_key)); + + let client = BridgeFigmaClient::new(state.clone()).with_port(server.port()); + let call = ReadToolCall::fast_snapshot(file.target_key.clone(), "1:2"); + let reading = tokio::spawn(async move { client.call_read_tool(call).await }); + let job = next_job(&mut plugin).await; + assert_eq!(job["params"]["nodeId"], "1:2"); + answer(&mut plugin, &job, json!({ "fileKey": "", "nodes": [] })).await; + let result = reading.await.unwrap().expect("the plugin answers"); + let served = + serde_json::from_value::(result.raw["_meta"]["devup/bridge"].clone()) + .expect("a bridge answer names the plugin that gave it"); + assert_eq!(served.reads, 1); + assert_eq!(served.port, Some(server.port())); + assert_eq!( + served.file_key, None, + "no key is claimed for a keyless plugin" + ); + assert_eq!(served.file_name.as_deref(), Some("Landing")); + assert_eq!(served.page_name.as_deref(), Some("Page 1")); + + // A new selection reaches the server without a reconnect. + plugin + .send(Message::Text( + json!({ + "kind": "context", + "currentPage": { "id": "0:2", "name": "Page 2" }, + "selection": [], + "selectionCount": 0, + }) + .to_string() + .into(), + )) + .await + .expect("the context is sent"); + for _ in 0..200 { + let context = state.attached_files().await[0].context.clone(); + if context.current_page.map(|page| page.name).as_deref() == Some("Page 2") { + assert_eq!(context.selection, Some(vec![])); + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("the context update never arrived"); +} + +/// 키 없는 플러그인 둘은 두 개로 센다. 한 자리에 묶이면 "정확히 하나"를 셀 수 +/// 없고, 먼저 붙은 쪽이 끊길 때 나중 쪽의 등록까지 지워진다. 둘이면 아무 키도 +/// 맡지 않지만, 각 연결의 키는 여전히 제 플러그인에만 닿는다. +#[tokio::test] +async fn two_keyless_plugins_are_counted_apart() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let hello = json!({ "kind": "hello", "fileKey": null }); + let first = attach(&server, hello.clone(), 1).await; + let _second = attach(&server, hello, 2).await; + let state = server.state(); + let client = BridgeFigmaClient::new(state.clone()); + assert!( + !client + .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + ); + let attached = state.attached_files().await; + assert_eq!(attached.len(), 2); + for file in &attached { + assert!( + client + .can_serve(&ReadToolCall::fast_snapshot(file.target_key.clone(), "1:2")) + .await + ); + } + + drop(first); + for _ in 0..200 { + if state.attached_files().await.len() == 1 { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("the first plugin's disconnect must leave the second attached"); +} + +/// 원격만 아는 상류. 몇 번 불렸는지만 센다. +struct CountingRemote { + calls: std::sync::Arc, +} + +#[async_trait::async_trait] +impl FigmaUpstream for CountingRemote { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(UpstreamResult { raw: json!({}) }) + } +} + +/// 브리지만 읽을 수 있는 키는 원격으로 넘어가지 않는다. Figma 에 없는 키를 +/// 물어 봐야 원인과 무관한 거절만 돌아오고, 로그인이 필요 없는 요청에 로그인이 +/// 요구된다. +#[tokio::test] +async fn a_bridge_only_key_never_reaches_the_remote_path() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let remote_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let upstream = FallbackUpstream::new( + BridgeFigmaClient::new(server.state()), + CountingRemote { + calls: remote_calls.clone(), + }, + ); + assert!(upstream.serves_without_credentials("bridge:41").await); + let error = upstream + .call_read_tool(ReadToolCall::fast_snapshot("bridge:41", "1:2")) + .await + .expect_err("no plugin holds this connection"); + assert_eq!(error.details["stage"], "bridge-routing"); + let error = upstream + .call_read_tool(ReadToolCall::screenshot("bridge:41", "1:2")) + .await + .expect_err("the bridge cannot take a screenshot, and direct has no key"); + assert_eq!(error.details["tool"], "get_screenshot"); + + assert_eq!(remote_calls.load(std::sync::atomic::Ordering::SeqCst), 0); + + // An ordinary key still falls through when no plugin holds it. + upstream + .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + .expect("the remote path answers"); + assert_eq!(remote_calls.load(std::sync::atomic::Ordering::SeqCst), 1); +} + #[tokio::test] async fn original_upload_crosses_the_socket_without_remote_text_truncation() { use base64::{Engine as _, engine::general_purpose::STANDARD}; diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index 1de4454f..7977911f 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -12,11 +12,14 @@ //! plugin" or "the listener never bound". Naming one path made it the only //! path, and the metered one at that. [`bridge_path`] is the other half. //! -//! - [`doctor_report`] backs the `devup_figma_auth {"action":"doctor"}` -//! action and reports whether each path is usable right now, which one to -//! prefer, plus client-specific setup data for the constraints that were -//! verified by hand (client_name allowlist, redirect_uri shape, the silent -//! callback port collision, PAT rejection). +//! - [`connection_report`] backs `devup_figma_auth`'s `status` (and the +//! answers to `login` and `logout`): whether each path is usable right now, +//! which one a read would take, what the attached plugins have open, and +//! the next call to make. +//! - [`doctor_report`] backs `{"action":"doctor"}`: the same report plus +//! client-specific setup data for the constraints that were verified by +//! hand (client_name allowlist, redirect_uri shape, the silent callback port +//! collision, PAT rejection). //! //! All facts embedded here (allowlist behavior, redirect_uri constraints, //! the callback-port trap) were measured against the real Figma Remote MCP @@ -33,11 +36,176 @@ //! Naming it as a path sent agents to a dead end, so it is named nowhere. use devup_mcp_figma::{ - AuthStatus, BridgePathSnapshot, ClientCredentialSource, DEFAULT_CLIENT_NAME, + AttachedFile, AuthStatus, BridgePathSnapshot, ClientCredentialSource, DEFAULT_CLIENT_NAME, DirectPathSnapshot, TokenState, }; +use serde::Serialize; use serde_json::{Value, json}; +/// The path a read would take right now. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +enum ActivePath { + Bridge, + Direct, +} + +/// Stands in for the link an agent has to supply itself. Nothing reads it as a +/// link; `requiredArguments` says it must be replaced. +const FIGMA_LINK_PLACEHOLDER: &str = ""; + +/// What `fileKey: null` on an attached file means, said where it is read. +const KEYLESS_NOTE: &str = "This plugin could not report its file key (figma.fileKey is empty, as in Dev Mode), so no key is claimed for its file. It serves reads only while it is the only plugin attached; address it by omitting url, or with figma-bridge://current."; + +/// Backs `devup_figma_auth`'s `status`, `login` and `logout` answers, and is +/// the first half of `doctor`. +/// +/// `status` used to be the direct path's word alone. With a plugin attached +/// and serving it still answered `disconnected`, and an agent asked for one +/// screen concluded Figma was unreachable and asked its user to log in. Both +/// paths are reported now, and the verdict is `disconnected` only when neither +/// can serve. +pub fn connection_report( + status: AuthStatus, + direct: &DirectPathSnapshot, + bridge: Option<&BridgePathSnapshot>, +) -> Value { + let attached = bridge.map_or(&[][..], |bridge| bridge.attached_files.as_slice()); + let direct_available = status == AuthStatus::Connected; + let active_path = if !attached.is_empty() { + Some(ActivePath::Bridge) + } else if direct_available { + Some(ActivePath::Direct) + } else { + None + }; + json!({ + "connected": active_path.is_some(), + "status": if active_path.is_some() { "connected" } else { "disconnected" }, + "activePath": active_path, + "preferredPath": "bridge", + "paths": { + "bridge": bridge_path(bridge), + "direct": direct_path(status, direct, !attached.is_empty()), + }, + "nextAction": match active_path { + Some(ActivePath::Bridge) => bridge_next_action(attached), + Some(ActivePath::Direct) => json!({ + "tool": "devup_figma_export", + "arguments": { "url": FIGMA_LINK_PLACEHOLDER, "outputs": ["tsx"] }, + "requiredArguments": ["url"], + "note": "Only the metered direct path is open, so an export needs the frame's Figma link. Running the Devup Bridge plugin on the file instead spends no allowance and makes url optional.", + }), + None => ways_to_open_a_path( + bridge.is_some(), + false, + &json!({ "tool": "devup_figma_export", "arguments": { "outputs": ["tsx"] } }), + ), + }, + }) +} + +/// With plugins attached: the call to make, and whether it needs a url. +fn bridge_next_action(attached: &[AttachedFile]) -> Value { + let [file] = attached else { + return several_plugins( + attached, + &json!({ "tool": "devup_figma_export", "arguments": { "outputs": ["tsx"] } }), + ); + }; + let selection = match (&file.context.selection, file.context.single_selection()) { + (_, Some(node)) => format!( + "the node selected in Figma ({:?}, {})", + node.name, + node.node_type.as_deref().unwrap_or("node") + ), + (Some(_), None) => format!( + "the node selected in Figma - select exactly one frame first ({} selected now), or pass frameIds with that frame's node id", + file.context.selection_count.unwrap_or_default() + ), + (None, None) => { + "the node you name in frameIds - this plugin build does not report the Figma selection" + .to_owned() + } + }; + json!({ + "tool": "devup_figma_export", + "arguments": { "outputs": ["tsx"] }, + "note": format!( + "One Devup Bridge plugin is attached, so no url is needed: the export reads the file it has open and {selection}. devup_figma_search and devup_figma_explore take no url either." + ), + }) +} + +/// Several plugins attached: a call without url cannot say which file it +/// means, so each file is offered as `retry` with the link that names it. +pub(super) fn several_plugins(attached: &[AttachedFile], retry: &Value) -> Value { + let files = attached + .iter() + .map(|file| match &file.file_key { + Some(key) => { + let target = devup_mcp_figma::FigmaTarget { + file_key: key.clone(), + node_id: None, + branch_key: None, + }; + let node = file.context.single_selection().map(|node| node.id.as_str()); + let mut call = retry.clone(); + call["arguments"]["url"] = json!(target.link(node)); + call["fileName"] = json!(file.file_name); + call + } + None => json!({ "fileName": file.file_name, "note": KEYLESS_NOTE }), + }) + .collect::>(); + json!({ + "how": format!( + "{} Devup Bridge plugins are attached, so a call without url cannot say which file it means. Pass the url of the file you mean, or close the other plugin windows so one remains.", + attached.len() + ), + "options": files, + }) +} + +/// The ways to open a path to Figma, preferred first. +/// +/// Shared by `status` and by the refusal of a call made without a url, so the +/// two cannot recommend different things. `retry` is the call to make once a +/// path is open, without url; the direct option adds the url it then needs. +pub(super) fn ways_to_open_a_path(listening: bool, direct_available: bool, retry: &Value) -> Value { + let mut with_url = retry.clone(); + with_url["arguments"]["url"] = json!(FIGMA_LINK_PLACEHOLDER); + with_url["requiredArguments"] = json!(["url"]); + let mut options = Vec::new(); + if listening { + options.push(json!({ + "path": "bridge", + "action": "In the Figma desktop app, open the file and run Plugins -> Development -> Devup Bridge (imported once from plugin/manifest.json), keeping its window open. No login is needed and no Figma allowance is spent.", + "then": retry, + })); + } + options.push(if direct_available { + let mut call = with_url; + call["path"] = json!("direct"); + call + } else { + json!({ + "path": "direct", + "tool": "devup_figma_auth", + "arguments": { "action": "login" }, + "then": with_url, + }) + }); + json!({ + "how": if listening { + "Open a path to Figma: run the Devup Bridge plugin (preferred - no login), or use the metered direct path with the frame's Figma link." + } else { + "This devup-mcp is not listening for the Devup Bridge plugin (see paths.bridge.reason), so only the metered direct path can open here: it needs the frame's Figma link." + }, + "options": options, + }) +} + /// What `credentialSource` counts, said in the response rather than only in /// the README. /// @@ -56,51 +224,59 @@ use serde_json::{Value, json}; /// anything, so the field now carries what it counts next to the value. const CREDENTIAL_SOURCE_NOTE: &str = "Where the OAuth *client registration* credential (client_id/client_secret) came from: cli-arg, env, credential-store, or none. This is not the user's access token — that is tokenState, and whether the direct path is usable right now is available. \"none\" only means no pre-registered client is injected, so login registers dynamically under registrationClientName; a signed-in session that registered that way reads credentialSource \"none\" with tokenState \"valid\", which is normal."; -/// Builds the response for `devup_figma_auth {"action":"doctor"}`. -/// -/// `status` mirrors the existing `status` action's value so a caller that -/// only reads `status` sees no behavior change. Everything under `paths` -/// and `clientSetup` is new: `paths` reports what was actually measured -/// (stored-credential presence, a live local-TCP probe, and the structural -/// process), and `clientSetup` is static, verified reference data — never -/// an instruction to register under a specific product name. Registration -/// is allowlisted by Figma outside devup-mcp's control; this only reports -/// the constraint and points at the public waitlist. +/// Builds the response for `devup_figma_auth {"action":"doctor"}`: the same +/// path-aware report `status` gives, plus the reference data behind it. /// -/// `direct` supplies the richer, measured detail behind `paths.direct`: -/// which credential source is in play (never the secret itself), whether -/// the stored token is fresh, and — when a fixed callback port is -/// configured — whether it is actually free right now. +/// `paths` reports what was actually measured (stored-credential presence, a +/// live local-TCP probe, the attached plugins), and `clientSetup` is static, +/// verified reference data — never an instruction to register under a +/// specific product name. Registration is allowlisted by Figma outside +/// devup-mcp's control; this only reports the constraint and points at the +/// public waitlist. pub async fn doctor_report( status: AuthStatus, direct: DirectPathSnapshot, bridge: Option, ) -> Value { + let mut report = connection_report(status, &direct, bridge.as_ref()); + report["preferredPathNote"] = json!( + "Two paths reach Figma and they are not equals. The bridge plugin reads through the Figma desktop app: no login, no OAuth, and it spends none of the Figma allowance the direct path is metered against — a single screen costs several reads, so the allowance goes quickly. Reach for the bridge first and keep direct as the fallback for what the bridge cannot serve (currently a file-scope metadata read and referencePng's get_screenshot)." + ); + report["clientSetup"] = client_setup(); + report +} + +/// The measured detail behind `paths.direct`: which credential source is in +/// play (never the secret itself), whether the stored token is fresh, and — +/// when a fixed callback port is configured — whether it is free right now. +fn direct_path(status: AuthStatus, direct: &DirectPathSnapshot, bridge_available: bool) -> Value { let direct_available = status == AuthStatus::Connected; + let mut reason = direct_reason( + direct_available, + direct.token_state, + direct.credential_source, + ); + if bridge_available { + reason.push_str( + " While a bridge plugin is attached this path is needed only for what the bridge \ + cannot serve: a file-scope metadata read and referencePng.", + ); + } json!({ - "status": status, - "preferredPath": "bridge", - "preferredPathNote": "Two paths reach Figma and they are not equals. The bridge plugin reads through the Figma desktop app: no login, no OAuth, and it spends none of the Figma allowance the direct path is metered against — a single screen costs several reads, so the allowance goes quickly. Reach for the bridge first and keep direct as the fallback for what the bridge cannot serve (currently a file-scope metadata read and referencePng's get_screenshot).", - "paths": { - "bridge": bridge_path(bridge.as_ref()), - "direct": { - "available": direct_available, - "credentialSource": direct.credential_source, - "credentialSourceNote": CREDENTIAL_SOURCE_NOTE, - "tokenState": direct.token_state, - "callbackPort": { - "port": direct.callback_port, - "free": direct.callback_port_free - }, - "registrationClientName": { - "value": direct.client_name, - "isDefault": direct.client_name == DEFAULT_CLIENT_NAME, - "note": "client_name Dynamic Client Registration will send. Figma matches it against its catalog allowlist exactly. The default is Codex, which the allowlist admits, so login works from a Codex install with no extra flags; Figma attributes that registration to Codex, not to devup-mcp. Once your own client is admitted through https://www.figma.com/mcp-catalog/, pass its name via --figma-client-name or DEVUP_FIGMA_CLIENT_NAME." - }, - "reason": direct_reason(direct_available, direct.token_state, direct.credential_source) - } + "available": direct_available, + "credentialSource": direct.credential_source, + "credentialSourceNote": CREDENTIAL_SOURCE_NOTE, + "tokenState": direct.token_state, + "callbackPort": { + "port": direct.callback_port, + "free": direct.callback_port_free + }, + "registrationClientName": { + "value": direct.client_name, + "isDefault": direct.client_name == DEFAULT_CLIENT_NAME, + "note": "client_name Dynamic Client Registration will send. Figma matches it against its catalog allowlist exactly. The default is Codex, which the allowlist admits, so login works from a Codex install with no extra flags; Figma attributes that registration to Codex, not to devup-mcp. Once your own client is admitted through https://www.figma.com/mcp-catalog/, pass its name via --figma-client-name or DEVUP_FIGMA_CLIENT_NAME." }, - "clientSetup": client_setup() + "reason": reason }) } @@ -137,10 +313,10 @@ fn bridge_path(bridge: Option<&BridgePathSnapshot>) -> Value { "available": attached, "listening": true, "port": bridge.port, - "attachedFiles": bridge.attached_files, - "attachedFilesNote": "File keys the attached plugins have open. An empty string is a plugin that could not report its own file key (seen in Dev Mode); it serves reads only while it is the only one attached, because with two there is no way to tell which file is meant.", + "attachedFiles": bridge.attached_files.iter().map(attached_file).collect::>(), + "attachedFilesNote": "The files the attached plugins have open, with the page in view and what is selected on it. fileKey is null for a plugin that could not report it (seen in Dev Mode); such a plugin serves reads only while it is the only one attached, because with two there is no way to tell which file is meant.", "reason": if attached { - "A plugin is attached. Reads for the files listed in attachedFiles are served through it, spending no Figma allowance and needing no login.".to_owned() + "A plugin is attached. Reads for the files listed in attachedFiles are served through it, spending no Figma allowance and needing no login. With exactly one attached, devup_figma_export, devup_figma_search and devup_figma_explore take no url: they read the file it has open, and export and explore start from the node selected in Figma.".to_owned() } else { format!( "The bridge is listening on 127.0.0.1:{} but no plugin is attached, so every read falls through to the metered direct path. Open the target file in the Figma desktop app and run the Devup Bridge plugin (Plugins -> Development -> Import plugin from manifest... once, using plugin/manifest.json). The bridge works only while that plugin window stays open. If the indicator stays grey, the port in the plugin's manifest allowedDomains and the port here must match.", @@ -150,6 +326,28 @@ fn bridge_path(bridge: Option<&BridgePathSnapshot>) -> Value { }) } +/// One attached plugin as the caller reads it. The key a read is routed by +/// stays internal: a plugin that could not report its file key has no key to +/// show, and the one standing in for it is not the file's. +fn attached_file(file: &AttachedFile) -> Value { + let mut entry = json!({ + "fileKey": file.file_key, + "fileName": file.file_name, + "currentPage": file.context.current_page, + "selection": file.context.selection, + "selectionCount": file.context.selection_count, + }); + if file.file_key.is_none() { + entry["fileKeyNote"] = json!(KEYLESS_NOTE); + } + if file.context.selection.is_none() { + entry["selectionNote"] = json!( + "This plugin build does not report its page or selection. Re-run Devup Bridge from this devup-mcp's plugin/manifest.json to have them reported, or name the node with frameIds." + ); + } + entry +} + /// Says which of the two credentials is present, and never lets one of them /// stand in for the other. /// @@ -323,22 +521,132 @@ mod tests { ); // Attached is the whole test: the bridge needs no credential, so a - // disconnected direct path takes nothing away from it. + // disconnected direct path takes nothing away from it - and the + // verdict follows the bridge rather than the direct path. let attached = doctor_report( AuthStatus::Disconnected, absent_direct_snapshot(), - Some(BridgePathSnapshot { - port: Some(1993), - attached_files: vec!["FileKey123".to_owned()], - }), + Some(bridge_with(vec![attached(Some("FileKey123"), Some(1))])), ) .await; assert_eq!(attached["paths"]["bridge"]["available"], true); assert_eq!( - attached["paths"]["bridge"]["attachedFiles"][0], + attached["paths"]["bridge"]["attachedFiles"][0]["fileKey"], "FileKey123" ); - assert_eq!(attached["status"], "disconnected"); + assert_eq!(attached["status"], "connected"); + assert_eq!(attached["activePath"], "bridge"); + } + + fn attached(file_key: Option<&str>, selected: Option) -> AttachedFile { + let node = |index: usize| devup_mcp_figma::NodeRef { + id: format!("1:{index}"), + name: format!("Frame {index}"), + node_type: Some("FRAME".to_owned()), + }; + AttachedFile { + target_key: file_key.map_or_else(|| "bridge:7".to_owned(), str::to_owned), + file_key: file_key.map(str::to_owned), + file_name: Some("Landing".to_owned()), + context: devup_mcp_figma::PluginContext { + current_page: Some(devup_mcp_figma::NodeRef { + id: "0:1".to_owned(), + name: "Page 1".to_owned(), + node_type: None, + }), + selection: selected.map(|count| (1..=count).map(node).collect()), + selection_count: selected, + }, + } + } + + fn bridge_with(attached_files: Vec) -> BridgePathSnapshot { + BridgePathSnapshot { + port: Some(1993), + attached_files, + } + } + + /// The three states `status` has to tell apart. `disconnected` is the + /// verdict only when neither path can serve, and each state names the + /// call that moves it forward. + #[test] + fn the_verdict_follows_whichever_path_can_serve() { + let valid = DirectPathSnapshot { + token_state: devup_mcp_figma::TokenState::Valid, + ..absent_direct_snapshot() + }; + + let bridge_only = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&bridge_with(vec![attached(None, Some(1))])), + ); + assert_eq!(bridge_only["connected"], true); + assert_eq!(bridge_only["status"], "connected"); + assert_eq!(bridge_only["activePath"], "bridge"); + assert_eq!(bridge_only["paths"]["bridge"]["available"], true); + assert_eq!(bridge_only["paths"]["direct"]["available"], false); + assert!( + !bridge_only.to_string().contains("disconnected"), + "nothing may say disconnected while the bridge serves: {bridge_only}" + ); + let file = &bridge_only["paths"]["bridge"]["attachedFiles"][0]; + assert!(file["fileKey"].is_null(), "no key is claimed: {file}"); + assert!( + file.get("targetKey").is_none(), + "the routing key stays internal" + ); + assert_eq!(file["currentPage"]["name"], "Page 1"); + assert_eq!(file["selection"][0]["id"], "1:1"); + assert_eq!(file["selection"][0]["type"], "FRAME"); + // One plugin, one selected frame: the next call needs no url. + assert_eq!(bridge_only["nextAction"]["tool"], "devup_figma_export"); + assert!(bridge_only["nextAction"]["arguments"].get("url").is_none()); + + let direct_only = + connection_report(AuthStatus::Connected, &valid, Some(&bridge_with(vec![]))); + assert_eq!(direct_only["connected"], true); + assert_eq!(direct_only["activePath"], "direct"); + assert_eq!(direct_only["paths"]["bridge"]["available"], false); + assert_eq!( + direct_only["nextAction"]["requiredArguments"], + json!(["url"]) + ); + + let neither = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&bridge_with(vec![])), + ); + assert_eq!(neither["connected"], false); + assert_eq!(neither["status"], "disconnected"); + assert!(neither["activePath"].is_null()); + let options = neither["nextAction"]["options"].as_array().unwrap(); + assert_eq!(options[0]["path"], "bridge"); + assert_eq!(options[1]["path"], "direct"); + assert_eq!(options[1]["arguments"]["action"], "login"); + } + + /// With two plugins attached a call without url could mean either file, + /// so the next action names each one - with its own link where the plugin + /// reported a key, and without inventing one where it did not. + #[test] + fn several_plugins_are_offered_one_by_one() { + let report = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&bridge_with(vec![ + attached(Some("FileKey123"), Some(1)), + attached(None, Some(0)), + ])), + ); + let options = report["nextAction"]["options"].as_array().unwrap(); + assert_eq!( + options[0]["arguments"]["url"], + "https://www.figma.com/design/FileKey123/devup?node-id=1-1" + ); + assert!(options[1].get("arguments").is_none(), "{}", options[1]); } #[tokio::test] From b2355dd637e8f33e155bc88752fe57277b87b112 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 25 Sep 2026 22:08:41 +0900 Subject: [PATCH 04/10] feat(server): answer status for both paths and warn instead of refusing an unneeded login status used to be the direct path's word alone, so with a plugin attached and serving it still answered disconnected, and an agent asked its user to log in. status, login and logout now return the path-aware report - connected, activePath, both paths, and the next call to make - and keep the status key, which reads disconnected only when neither path can serve. login still runs while a plugin is attached, and says it was probably not needed. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp/src/server/mod.rs | 108 +++++++++++++++++-------- crates/devup-mcp/tests/figma_doctor.rs | 24 +++--- 2 files changed, 87 insertions(+), 45 deletions(-) diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 1434916e..86defb69 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -707,6 +707,18 @@ impl DevupServer { } } } + + /// Both paths to Figma as they stand right now. + async fn connection_report(&self) -> Result { + let status = self.services.auth.status().await?; + let direct = self.services.auth.direct_path_snapshot().await?; + let bridge = self.services.upstream.bridge_path_snapshot().await; + Ok(diagnostics::connection_report( + status, + &direct, + bridge.as_ref(), + )) + } } /// Every `devup_figma_*` tool response is a JSON object whose exact shape @@ -966,45 +978,70 @@ impl DevupServer { } #[tool( - description = "Check, start, or clear Figma Remote MCP OAuth, or inject a pre-registered client credential to skip Dynamic Client Registration (action: status | login | logout | configure | doctor)", + description = "Report how devup-mcp reaches Figma, and manage the direct path's OAuth (action: status | login | logout | configure | doctor). \ + Two paths reach Figma: the Devup Bridge plugin running in the Figma desktop app is preferred and needs no login, and OAuth - the metered direct path - is the fallback. \ + Call status before asking anyone to log in: it reports both paths, the files attached plugins have open with their current page and selection, and the next call to make. `connected` is false, and `status` reads disconnected, only when neither path can serve. \ + With exactly one plugin attached, devup_figma_export, devup_figma_search and devup_figma_explore take no url. \ + login is needed only when no plugin is attached, or for what the bridge cannot serve (file-scope metadata, referencePng). configure injects a pre-registered client credential to skip Dynamic Client Registration; doctor adds client setup reference data to status.", output_schema = permissive_object_output_schema() )] async fn devup_figma_auth( &self, Parameters(input): Parameters, ) -> Result { - if input.action == "doctor" { - let status = self.services.auth.status().await.map_err(to_mcp_error)?; - let direct = self - .services - .auth - .direct_path_snapshot() - .await - .map_err(to_mcp_error)?; - let bridge = self.services.upstream.bridge_path_snapshot().await; - return Ok(tool_result( - diagnostics::doctor_report(status, direct, bridge).await, - )); - } - if input.action == "configure" { - let client_id = input.client_id.ok_or_else(|| { - to_mcp_error(DevupError::new( - ErrorCode::DevupInvalidInput, - "configure requires clientId.", - false, - )) - })?; - self.services - .auth - .configure_client_credentials(client_id, input.client_secret) - .await - .map_err(to_mcp_error)?; - return Ok(tool_result(json!({ "status": "configured" }))); - } - let status = match input.action.as_str() { - "status" => self.services.auth.status().await, - "login" => self.services.auth.login().await, - "logout" => self.services.auth.logout().await, + match input.action.as_str() { + "status" => {} + "doctor" => { + let status = self.services.auth.status().await.map_err(to_mcp_error)?; + let direct = self + .services + .auth + .direct_path_snapshot() + .await + .map_err(to_mcp_error)?; + let bridge = self.services.upstream.bridge_path_snapshot().await; + return Ok(tool_result( + diagnostics::doctor_report(status, direct, bridge).await, + )); + } + "configure" => { + let client_id = input.client_id.ok_or_else(|| { + to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + "configure requires clientId.", + false, + )) + })?; + self.services + .auth + .configure_client_credentials(client_id, input.client_secret) + .await + .map_err(to_mcp_error)?; + return Ok(tool_result(json!({ "status": "configured" }))); + } + "login" => { + // Warned rather than refused: the bridge cannot serve every + // read, and whoever asks may need one of those. But reaching + // for the metered path while the free one is attached is the + // mistake this answer exists to catch, so it says so. + let bridge_attached = self + .services + .upstream + .bridge_path_snapshot() + .await + .is_some_and(|bridge| !bridge.attached_files.is_empty()); + self.services.auth.login().await.map_err(to_mcp_error)?; + let mut report = self.connection_report().await.map_err(to_mcp_error)?; + if bridge_attached { + report["warning"] = json!( + "A bridge is attached; login is only needed for file-scope metadata or referencePng." + ); + } + return Ok(tool_result(report)); + } + "logout" => { + self.services.auth.logout().await.map_err(to_mcp_error)?; + } _ => { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupAuthRequired, @@ -1013,8 +1050,9 @@ impl DevupServer { ))); } } - .map_err(to_mcp_error)?; - Ok(tool_result(json!({ "status": status }))) + Ok(tool_result( + self.connection_report().await.map_err(to_mcp_error)?, + )) } #[tool( diff --git a/crates/devup-mcp/tests/figma_doctor.rs b/crates/devup-mcp/tests/figma_doctor.rs index 90c3249e..90dde89f 100644 --- a/crates/devup-mcp/tests/figma_doctor.rs +++ b/crates/devup-mcp/tests/figma_doctor.rs @@ -186,8 +186,7 @@ async fn doctor_action_reports_measured_paths_and_client_setup_data() -> anyhow: } #[tokio::test] -async fn doctor_action_reflects_connected_status_without_changing_the_status_action_shape() --> anyhow::Result<()> { +async fn doctor_and_status_agree_on_a_connected_direct_path() -> anyhow::Result<()> { let doctor = call_named_tool( Arc::new(AuthProbe { status: AuthStatus::Connected, @@ -202,8 +201,9 @@ async fn doctor_action_reflects_connected_status_without_changing_the_status_act assert_eq!(doctor["status"], "connected"); assert_eq!(doctor["paths"]["direct"]["available"], true); - // The pre-existing `status` action must keep returning exactly - // `{"status": ...}` so existing callers stay compatible. + // `status` keeps its `status` field, so a caller that reads only that + // still gets the verdict - which now covers both paths - and gains the + // path-aware report beside it. let status = call_named_tool( Arc::new(AuthProbe { status: AuthStatus::Connected, @@ -215,18 +215,22 @@ async fn doctor_action_reflects_connected_status_without_changing_the_status_act .await? .structured_content .unwrap(); - // The tool's own payload is still pinned exactly - that is what keeps - // existing callers compatible. The identity block is asserted field by - // field instead of as one literal: it is shared by every tool and grows - // (it just gained `updateAvailable`), and spelling it out in full made - // unrelated tests fail for a change that broke nothing. + // The identity block is asserted field by field instead of as one + // literal: it is shared by every tool and grows (it just gained + // `updateAvailable`), and spelling it out in full made unrelated tests + // fail for a change that broke nothing. let mut status = status; let server = status .as_object_mut() .expect("structured content is an object") .remove("server") .expect("every response carries build identity"); - assert_eq!(status, json!({ "status": "connected" })); + assert_eq!(status["status"], "connected"); + assert_eq!(status["connected"], true); + assert_eq!(status["activePath"], "direct"); + assert_eq!(status["paths"], doctor["paths"]); + // Reference data stays on doctor, where it was asked for. + assert!(status.get("clientSetup").is_none()); assert_identity(&server); Ok(()) } From 9fd4ee802ea049b82553fba69aae3cba47015baa Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 25 Sep 2026 22:08:52 +0900 Subject: [PATCH 05/10] feat(server): name the bridge as the source and never claim a file key the plugin did not report Every answer said source.kind direct whatever carried it, and repeated the request's key as the file's - including a key invented to get past the url field, which a keyless plugin served because it was the only one attached. An answer the plugin served now says kind bridge with bridgePort, fileName, pageName and bridgeReads; fileKey is the key the plugin reported, or null with fileKeyReason, and a key the request carried is reported apart as requestedFileKey. A reprojected acquisition keeps kind artifact and says origin bridge. sourceMap and the links in recovery and resume arguments follow the same rule. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp/src/server/artifacts.rs | 12 ++- crates/devup-mcp/src/server/projection.rs | 92 ++++++++++++++++++----- 2 files changed, 78 insertions(+), 26 deletions(-) diff --git a/crates/devup-mcp/src/server/artifacts.rs b/crates/devup-mcp/src/server/artifacts.rs index d98c7a4f..cda8360d 100644 --- a/crates/devup-mcp/src/server/artifacts.rs +++ b/crates/devup-mcp/src/server/artifacts.rs @@ -1160,14 +1160,12 @@ fn remove_entry(state: &mut StoreState, artifact_id: &str) { // Bounded tombstones retain no payload, credentials or original private URL label. // Canonical URL and the last selection are enough to reacquire the same capture. let key = &entry.request_key; - let mut url = if let Some(branch) = &key.branch_key { - format!("https://www.figma.com/branch/{}/{branch}", key.file_key) - } else { - format!("https://www.figma.com/design/{}", key.file_key) - }; - if let Some(node) = &key.node_id { - url.push_str(&format!("?node-id={}", node.replace(':', "-"))); + let url = devup_mcp_figma::FigmaTarget { + file_key: key.file_key.clone(), + node_id: None, + branch_key: key.branch_key.clone(), } + .link(key.node_id.as_deref()); let mut args = json!({"url":url,"refresh":true}); if let Some(selection) = &entry.capabilities.section_selection { args["frameIds"] = json!(selection.frame_ids); diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 72686f15..89021105 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -1063,6 +1063,75 @@ fn generate_collected_component( } pub(super) async fn complete_operation( + operation: PendingOperation, + payload: &CollectedPayload, + source_kind: &str, + artifact: &ArtifactLookup, + output_policy: &OutputPolicy, + artifact_store: &ArtifactStore, +) -> Result { + let mut response = project_operation( + operation, + payload, + source_kind, + artifact, + output_policy, + artifact_store, + ) + .await?; + if let Some(source) = response + .get_mut("source") + .filter(|source| source.is_object()) + { + attach_bridge_source(source, payload); + } + Ok(response) +} + +/// Names the Devup Bridge plugin when it carried the design, and never claims +/// a file key the plugin did not report. +/// +/// Every answer used to say `kind: "direct"` whatever carried it, and repeated +/// the request's key as the file's - including a key an agent had invented to +/// get past the url field, which the plugin served only because it was the one +/// attached. So the agent could not tell which path had served it, and was +/// told its own invention was the file. +fn attach_bridge_source(source: &mut Value, payload: &CollectedPayload) { + let Some(served) = &payload.stats.bridge else { + return; + }; + // A reused acquisition stays `artifact` - this call read nothing - and + // says where the acquisition came from. + if source["kind"] == "direct" { + source["kind"] = json!("bridge"); + } else { + source["origin"] = json!("bridge"); + } + source["bridgePort"] = json!(served.port); + source["fileKey"] = json!(served.file_key); + if served.file_key.is_none() { + source["fileKeyReason"] = json!( + "The Devup Bridge plugin that served this could not report its file key (figma.fileKey is empty, as in Dev Mode), so no file key is claimed." + ); + if !payload.target.is_bridge_only() { + source["requestedFileKey"] = json!(payload.target.file_key); + } + } + source["fileName"] = json!(served.file_name); + source["pageName"] = json!(served.page_name); + source["bridgeReads"] = json!(served.reads); +} + +/// The file key an answer may state: the plugin's own report when the bridge +/// read the file, and the requested key when Figma itself served it. +fn vouched_file_key(payload: &CollectedPayload) -> Option<&str> { + match &payload.stats.bridge { + Some(served) => served.file_key.as_deref(), + None => Some(payload.target.file_key.as_str()), + } +} + +async fn project_operation( mut operation: PendingOperation, payload: &CollectedPayload, source_kind: &str, @@ -1453,8 +1522,7 @@ pub(super) async fn complete_operation( } else { result.get_mut("nextAction").expect("nextAction")["example"] = json!({ "tool":"devup_figma_explore", "arguments":{ - "url":format!("https://www.figma.com/design/{}?node-id={}", payload.target.file_key, - payload.target.node_id.as_deref().unwrap_or_default().replace(':', "-")), + "url":payload.target.link(payload.target.node_id.as_deref()), "limit":100 } }); @@ -1666,7 +1734,7 @@ pub(super) async fn complete_operation( frame["sourceMap"] = json!({"version":output.source_map.version, "designFingerprints":design_fingerprints, "resolutionSemantics":{"axis":"mapping-method","dictionary":"/resolutionSemantics"}, - "entries":output.source_map.property_entries(),"source":{"fileKey":payload.target.file_key, + "entries":output.source_map.property_entries(),"source":{"fileKey":vouched_file_key(payload), "rootNodeId":candidate.node.node_id,"sourceVersion":payload.source_version, "generatedOutput":field,"mappingKind":"node-field-property"}}); } @@ -2107,7 +2175,7 @@ pub(super) async fn complete_operation( .map(|source_map| source_map.entries) .unwrap_or_default(), "source": { - "fileKey": payload.target.file_key, + "fileKey": vouched_file_key(payload), "rootNodeId": payload.target.node_id, "sourceVersion": payload.source_version, "generatedOutput":generated_output_target,"mappingKind":"node-field-property" @@ -2451,24 +2519,10 @@ pub(super) async fn complete_operation( .filter_map(|failure| failure["nodeId"].as_str()) .collect::>(); if !failed_frames.is_empty() { - let mut arguments = json!({"url":format!("https://www.figma.com/design/{}/?node-id={}", - payload.target.file_key, payload.target.node_id.as_deref().unwrap_or_default().replace(':', "-")), + let mut arguments = json!({"url":payload.target.link(payload.target.node_id.as_deref()), "frameIds":failed_frames,"outputs":outputs,"scope":scope,"delivery":delivery, "rootLayout":match root_layout { devup_mcp_devup_ui::codegen::RootLayout::Standalone => "standalone", devup_mcp_devup_ui::codegen::RootLayout::Embedded => "embedded" }, "assetNamesPerNode":asset_names_per_node,"strict":strict,"includeDiagnostics":include_diagnostics}); - if let Some(branch) = &payload.target.branch_key { - arguments["url"] = json!(format!( - "https://www.figma.com/branch/{}/{}/?node-id={}", - payload.target.file_key, - branch, - payload - .target - .node_id - .as_deref() - .unwrap_or_default() - .replace(':', "-") - )); - } if let Some(name) = &component_name_for_components { arguments["componentName"] = json!(name); } From ef0f6399825ea4bc676bcce93284cc1ff35ba8b5 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 25 Sep 2026 22:08:52 +0900 Subject: [PATCH 06/10] fix(server): merge bridge asset batches that honestly carry no file key --merge-asset-batches established a batch's file from source.fileKey alone, so a batch the bridge served from a plugin that could not report its key - fileKey null rather than a stand-in - would be refused. Such batches are now compared by the file name the plugin gave, kept under source.fileName in the merged summary, and a different name is still a different file. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp/src/asset_batches.rs | 38 ++++++++++++++++++++++----- crates/devup-mcp/tests/cli.rs | 18 +++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/devup-mcp/src/asset_batches.rs b/crates/devup-mcp/src/asset_batches.rs index ce0e3daa..562de3bc 100644 --- a/crates/devup-mcp/src/asset_batches.rs +++ b/crates/devup-mcp/src/asset_batches.rs @@ -41,8 +41,8 @@ fn unwrap_response(value: &Value) -> anyhow::Result { anyhow::bail!("Response content contains no export JSON"); } anyhow::ensure!( - value["source"]["fileKey"].as_str().is_some(), - "Each batch must contain its export source.fileKey; pending jobs and bare manifests cannot establish source identity" + batch_file(value).is_some(), + "Each batch must contain its export source.fileKey (or, from a Devup Bridge plugin that could not report one, source.fileName); pending jobs and bare manifests cannot establish source identity" ); anyhow::ensure!( value["assetSummary"].is_object(), @@ -51,6 +51,25 @@ fn unwrap_response(value: &Value) -> anyhow::Result { Ok(value.clone()) } +/// Which file a batch came from, as its source can say it: the field that +/// said it, and what it said. +/// +/// A key when there is one. A batch the Devup Bridge served from a plugin that +/// could not report its key carries `fileKey: null` rather than a stand-in, so +/// the file name that plugin gave is compared instead - a weaker identity, +/// which the merged source names by keeping it under `fileName`. +fn batch_file(value: &Value) -> Option<(&'static str, String)> { + let source = &value["source"]; + if let Some(key) = source["fileKey"].as_str() { + return Some(("fileKey", key.to_owned())); + } + let bridge = source["kind"] == "bridge" || source["origin"] == "bridge"; + source["fileName"] + .as_str() + .filter(|_| bridge) + .map(|name| ("fileName", name.to_owned())) +} + #[derive(Default)] struct Asset { captures: BTreeMap<(String, u64), Value>, @@ -63,7 +82,7 @@ struct Asset { pub fn merge(values: &[Value]) -> anyhow::Result { anyhow::ensure!(!values.is_empty(), "No batch responses supplied"); let mut assets: BTreeMap = BTreeMap::new(); - let mut file_key = None; + let mut identity = None; let mut versions = BTreeSet::new(); let mut unknown_version = false; let mut roots = BTreeSet::new(); @@ -71,12 +90,13 @@ pub fn merge(values: &[Value]) -> anyhow::Result { let mut incomplete_inventory_batches = 0; for value in values { let value = unwrap_response(value)?; - let file = value["source"]["fileKey"].as_str().unwrap().to_owned(); + let file = batch_file(&value) + .ok_or_else(|| anyhow::anyhow!("A batch lost its source identity"))?; anyhow::ensure!( - file_key.as_ref().is_none_or(|key| key == &file), + identity.as_ref().is_none_or(|known| known == &file), "Cannot merge different Figma files" ); - file_key = Some(file); + identity = Some(file); if let Some(version) = value["source"]["version"].as_str() { versions.insert(version.to_owned()); } else { @@ -175,10 +195,14 @@ pub fn merge(values: &[Value]) -> anyhow::Result { let complete = !incomplete && incomplete_inventory_batches == 0 && failed + unrequested + pending + conflicts == 0; + let mut source = json!({"fileKey":null,"versions":versions,"versionVerified":!unknown_version}); + if let Some((field, file)) = identity { + source[field] = json!(file); + } Ok(json!({"status":if complete {"complete"} else {"partial"}, "description":"Cumulative binary collection across the supplied batches only. Per-batch partial can mean assets were not requested in that batch; it is not an export failure. Projection quality is not merged. File writes are historical reports, not reverified files.", "scope":"union-of-supplied-batches", "inventoryComplete":incomplete_inventory_batches == 0, - "incompleteInventoryBatchCount":incomplete_inventory_batches, "source":{"fileKey":file_key,"versions":versions,"versionVerified":!unknown_version}, + "incompleteInventoryBatchCount":incomplete_inventory_batches, "source":source, "scopeRootIds":roots,"batchCount":values.len(),"discovery":if incomplete {"incomplete"} else {"complete"}, "discoveredCount":manifest.len(),"collectedCount":collected,"failedCount":failed, "unrequestedCount":unrequested,"pendingCount":pending,"excludedCount":excluded,"conflictCount":conflicts, diff --git a/crates/devup-mcp/tests/cli.rs b/crates/devup-mcp/tests/cli.rs index d86086d7..13e06cb1 100644 --- a/crates/devup-mcp/tests/cli.rs +++ b/crates/devup-mcp/tests/cli.rs @@ -544,6 +544,24 @@ fn r5_batch_summary_without_asset_identities_cannot_claim_complete() { assert_eq!(result["inventoryComplete"], false); } +/// A batch the Devup Bridge served from a plugin that could not report its +/// file key says `fileKey: null` rather than a stand-in. Such batches still +/// merge, compared by the file name the plugin gave - and a different name is +/// still a different file. +#[test] +fn bridge_batches_without_a_file_key_merge_by_the_plugins_file_name() { + let batch = |name: &str| { + serde_json::json!({ + "source":{"kind":"bridge","fileKey":null,"fileName":name,"version":null}, + "assetSummary":{"discovery":"complete","discoveredCount":0,"collectedCount":0,"unavailable":[]} + }) + }; + let result = devup_mcp::asset_batches::merge(&[batch("Landing"), batch("Landing")]).unwrap(); + assert!(result["source"]["fileKey"].is_null()); + assert_eq!(result["source"]["fileName"], "Landing"); + assert!(devup_mcp::asset_batches::merge(&[batch("Landing"), batch("Other")]).is_err()); +} + #[test] fn r5_batch_summary_without_discovery_counts_is_incomplete() { let result = devup_mcp::asset_batches::merge(&[serde_json::json!({ From bf630d53ec258546ce58ed479f77b7a13f1b3a46 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 25 Sep 2026 22:09:01 +0900 Subject: [PATCH 07/10] feat(server): take the file and node from the attached bridge plugin when a call gives no url A call without url was refused as 'url or artifactId is required' while a plugin sat attached and ready, and the agent that met it invented /design/bridge/bridge. With exactly one plugin attached, devup_figma_export, devup_figma_search and devup_figma_explore now take no url: they read that plugin's file, export and explore start from the node selected in Figma, and frameIds without url name frames in it. figma-bridge://current names the same file. With no plugin or several, the refusal carries a nextAction listing the ways forward; a selection that names no single node gets the call that would. The contract tests drive the tool surface with a real bridge socket across bridge-only, direct-only and neither. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- Cargo.lock | 2 + crates/devup-mcp/Cargo.toml | 4 + crates/devup-mcp/src/server/mod.rs | 307 +++++++++++-- crates/devup-mcp/src/server/tools.rs | 13 +- crates/devup-mcp/tests/bridge_first.rs | 567 +++++++++++++++++++++++++ 5 files changed, 846 insertions(+), 47 deletions(-) create mode 100644 crates/devup-mcp/tests/bridge_first.rs diff --git a/Cargo.lock b/Cargo.lock index 77c19595..ec6762c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -704,6 +704,7 @@ dependencies = [ "devup-mcp-figma", "devup-mcp-visual", "dunce", + "futures-util", "oxc_allocator", "oxc_ast", "oxc_parser", @@ -717,6 +718,7 @@ dependencies = [ "sha2 0.11.0", "syn 2.0.119", "tokio", + "tokio-tungstenite 0.28.0", "tracing-subscriber", ] diff --git a/crates/devup-mcp/Cargo.toml b/crates/devup-mcp/Cargo.toml index 1b650374..1a419373 100644 --- a/crates/devup-mcp/Cargo.toml +++ b/crates/devup-mcp/Cargo.toml @@ -43,3 +43,7 @@ dunce.workspace = true # `start_paused` lets a test watch the retry waits elapse without spending the # minute they describe. tokio = { workspace = true, features = ["test-util"] } +# A real socket standing in for the Devup Bridge plugin, the same client the +# figma crate's transport tests use, so the tool surface is driven end to end. +tokio-tungstenite = "0.28" +futures-util = "0.3" diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 86defb69..d47d9ba3 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -42,14 +42,14 @@ use serde_json::{Value, json}; use devup_mcp_devup_ui::theme::ThemeScope; use devup_mcp_figma::{ - AuthStatus, BridgeFigmaClient, BridgeServer, ClientCredentialSource, ClientCredentials, - CollectedParts, CollectedPayload, CollectionRequest, CollectionScope, CollectorSession, - CollectorStep, CredentialStore, DEFAULT_CLIENT_NAME, DevupError, DirectPathSnapshot, ErrorCode, - ExploreCandidate, ExploreKind, ExploreNode, ExploreReadOptions, FallbackUpstream, FigmaTarget, - FigmaUpstream, KeyringClientCredentialStore, KeyringCredentialStore, OAuthManager, - ReadToolCall, RemoteFigmaClient, ResourceScope, SearchReadOptions, SecretString, - SectionCandidate, SectionIndex, SectionReadOptions, Snapshot, SystemBrowser, TokenState, - UpstreamResult, + AuthStatus, BRIDGE_CURRENT_KEY, BridgeFigmaClient, BridgeServer, ClientCredentialSource, + ClientCredentials, CollectedParts, CollectedPayload, CollectionRequest, CollectionScope, + CollectorSession, CollectorStep, CredentialStore, DEFAULT_CLIENT_NAME, DevupError, + DirectPathSnapshot, ErrorCode, ExploreCandidate, ExploreKind, ExploreNode, ExploreReadOptions, + FallbackUpstream, FigmaTarget, FigmaUpstream, KeyringClientCredentialStore, + KeyringCredentialStore, OAuthManager, ReadToolCall, RemoteFigmaClient, ResourceScope, + SearchReadOptions, SecretString, SectionCandidate, SectionIndex, SectionReadOptions, Snapshot, + SystemBrowser, TokenState, UpstreamResult, }; use artifacts::{ArtifactKind, ArtifactRequestKey, ArtifactStore}; @@ -670,7 +670,13 @@ impl DevupServer { if !error.details.is_object() { error.details = json!({"upstreamDetails":error.details}); } - error.details["fileKey"] = json!(target.file_key); + // A key only the bridge routes by is not the + // file's, so it is not reported as one. + error.details["fileKey"] = if target.is_bridge_only() { + Value::Null + } else { + json!(target.file_key) + }; if error.details.get("nodeId").is_none() { error.details["nodeId"] = json!(planned.expected_node_id); } @@ -719,6 +725,189 @@ impl DevupServer { bridge.as_ref(), )) } + + /// Binds a call made without url, or with `figma-bridge://current`, to the + /// one attached Devup Bridge plugin: its file, and the node selected in + /// Figma when the call names none. + /// + /// A call without url used to be refused outright as "url or artifactId + /// is required" while a plugin sat attached and ready, and the one agent + /// that met it invented `/design/bridge/bridge` to get past the field. + /// With no plugin, or several, there is no one file the call can mean, + /// and the refusal says what would make one - `retry` is the call to make + /// then. + async fn bind_to_bridge( + &self, + mut target: FigmaTarget, + unnamed: Unnamed, + retry: &Value, + ) -> Result { + if target.file_key != BRIDGE_CURRENT_KEY { + return Ok(target); + } + let bridge = self.services.upstream.bridge_path_snapshot().await; + let attached = bridge + .as_ref() + .map_or(&[][..], |bridge| bridge.attached_files.as_slice()); + let file = match attached { + [file] => file, + [] => { + let direct_available = self.services.auth.status().await? == AuthStatus::Connected; + return Err(DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + "No url was given and no Devup Bridge plugin is attached, so there is no open \ + file to read. Run the Devup Bridge plugin on the file in the Figma desktop \ + app (preferred: no login), or pass the frame's Figma link as url.", + false, + json!({ + "stage": "target-resolution", + "bridge": { + "listening": bridge.is_some(), + "port": bridge.as_ref().and_then(|bridge| bridge.port), + "attachedFiles": [], + }, + "directAvailable": direct_available, + "nextAction": diagnostics::ways_to_open_a_path( + bridge.is_some(), + direct_available, + retry, + ), + }), + )); + } + several => { + return Err(DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + "No url was given and several Devup Bridge plugins are attached, so which file \ + is meant is ambiguous.", + false, + json!({ + "stage": "target-resolution", + "nextAction": diagnostics::several_plugins(several, retry), + }), + )); + } + }; + target.file_key.clone_from(&file.target_key); + if target.node_id.is_some() || unnamed == Unnamed::File { + return Ok(target); + } + let context = &file.context; + match (context.single_selection(), unnamed) { + (Some(node), Unnamed::SelectedSection) + if node + .node_type + .as_deref() + .is_some_and(|node_type| node_type != "SECTION") => + { + let mut call = retry.clone(); + if let Some(first) = retry["arguments"]["frameIds"].get(0) { + call["arguments"]["frameIds"] = json!([first]); + } + call["how"] = json!( + "Select the Section in Figma and repeat the call, or export one frame per call: a single frameId and no url, once for each frame." + ); + Err(selection_refusal( + file, + format!( + "frameIds and allScreens without url choose screens inside the Section \ + selected in Figma, and the selection is a {}.", + node.node_type.as_deref().unwrap_or("node") + ), + call, + )) + } + (Some(node), _) => { + target.node_id = Some(node.id.clone()); + Ok(target) + } + (None, Unnamed::SelectionOrFile) + if context.selection.as_ref().is_some_and(Vec::is_empty) => + { + Ok(target) + } + (None, _) => Err(selection_refusal( + file, + match &context.selection { + None => "No url or node was given, and the attached Devup Bridge plugin does \ + not report the Figma selection - it predates this devup-mcp. Re-run \ + Devup Bridge from this devup-mcp's plugin/manifest.json, or name the \ + node." + .to_owned(), + Some(selection) if selection.is_empty() => { + "No url or node was given and nothing is selected in Figma. Select the \ + node in Figma, or name it." + .to_owned() + } + Some(selection) => format!( + "No url or node was given and {} nodes are selected in Figma. Select \ + exactly one, or name the one you mean.", + context.selection_count.unwrap_or(selection.len()) + ), + }, + name_the_node(file, retry), + )), + } + } +} + +/// The call that names the node a selection could not: the first selected +/// one when anything is selected, a placeholder to replace otherwise. +fn name_the_node(file: &devup_mcp_figma::AttachedFile, retry: &Value) -> Value { + let node = file + .context + .selection + .as_deref() + .and_then(<[_]>::first) + .map(|node| node.id.as_str()); + let mut call = retry.clone(); + call["arguments"]["url"] = + json!(FigmaTarget::bridge_current().link(Some(node.unwrap_or("")))); + if node.is_none() { + call["requiredArguments"] = json!(["url"]); + } + call["how"] = json!( + "Select one node in Figma and repeat the call, or name the node with figma-bridge://current?node-id= as url." + ); + call +} + +/// Which node a call bound to the bridge means when it names none. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Unnamed { + /// No node: the whole file, as a search reads it. + File, + /// The one node selected in Figma: an export's target, explore's anchor. + Selection, + /// The selected node when exactly one is selected, else the whole file: + /// a file-scope export that needs no node. + SelectionOrFile, + /// The selected Section, whose screens frameIds or allScreens choose. + SelectedSection, +} + +/// The refusal for a selection that does not name the node a call needs, +/// carrying the call that would. +fn selection_refusal( + file: &devup_mcp_figma::AttachedFile, + message: String, + call: Value, +) -> DevupError { + DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + message, + false, + json!({ + "stage": "target-resolution", + "file": { + "fileName": file.file_name, + "currentPage": file.context.current_page, + "selection": file.context.selection, + "selectionCount": file.context.selection_count, + }, + "nextAction": call, + }), + ) } /// Every `devup_figma_*` tool response is a JSON object whose exact shape @@ -926,17 +1115,6 @@ fn apply_explore_limit(mut response: Value, limit: usize) -> Value { response } -/// The same Figma file with no node linked. -fn file_scope_url(target: &FigmaTarget) -> String { - match &target.branch_key { - Some(branch_key) => format!( - "https://www.figma.com/branch/{}/{branch_key}/devup", - target.file_key - ), - None => format!("https://www.figma.com/design/{}/devup", target.file_key), - } -} - #[tool_router] impl DevupServer { #[tool( @@ -1058,6 +1236,7 @@ impl DevupServer { #[tool( description = "Search Figma pages, sections, frames, and components by name to locate the target before devup_figma_export. \ A node-id in the URL scopes the search to that node and everything under it; a URL without one searches the whole file. \ + url is optional while exactly one Devup Bridge plugin is attached: omitted, the whole file that plugin has open is searched, and figma-bridge://current?node-id= scopes it. \ The answer says which of the two it did under `scope`, and `limit` is the number of matches returned.", output_schema = permissive_object_output_schema() )] @@ -1076,11 +1255,18 @@ impl DevupServer { false, ))); } - let target = FigmaTarget::parse(&input.url).map_err(to_mcp_error)?; + let target = self + .bind_to_bridge( + parse_link(input.url.as_deref()).map_err(to_mcp_error)?, + Unnamed::File, + &json!({ "tool": "devup_figma_search", "arguments": { "query": input.query } }), + ) + .await + .map_err(to_mcp_error)?; let scope = SearchScope { node_id: target.node_id.clone(), limit: input.limit, - file_url: file_scope_url(&target), + file_url: target.link(None), }; // Scope the upstream read itself to the linked subtree. Keep the ranked // projection above the caller's limit so ancestry filtering happens @@ -1123,14 +1309,22 @@ impl DevupServer { description = "Explore screen candidates spatially related to a linked Figma node to locate the right screen before devup_figma_export. \ `limit` is the number of candidates returned and nothing else - it never changes how much of the design is read, so raising it can only lengthen the answer. \ `truncation` reports the two cuts apart: `candidates` means limit cut the list and raising it returns the rest, `projection` means the collected snapshot was itself incomplete and no limit will bring those screens back. \ - `includeTextPreview` uses a separate budget; turning it on or off does not change candidate count, IDs, or `truncation.projection`.", + `includeTextPreview` uses a separate budget; turning it on or off does not change candidate count, IDs, or `truncation.projection`. \ + url is optional while exactly one Devup Bridge plugin is attached: omitted, the anchor is the node selected in Figma in the file that plugin has open.", output_schema = permissive_object_output_schema() )] async fn devup_figma_explore( &self, Parameters(input): Parameters, ) -> Result { - let target = FigmaTarget::parse(&input.url).map_err(to_mcp_error)?; + let target = self + .bind_to_bridge( + parse_link(input.url.as_deref()).map_err(to_mcp_error)?, + Unnamed::Selection, + &json!({ "tool": "devup_figma_explore", "arguments": { "limit": input.limit } }), + ) + .await + .map_err(to_mcp_error)?; target.node_id.as_ref().ok_or_else(|| { to_mcp_error(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, @@ -1178,7 +1372,7 @@ impl DevupServer { } #[tool( - description = "Export a small Figma selection; the Figma-to-code entry point. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. \ + description = "Export a small Figma selection; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. \ The `tsx` this returns is devup-ui code, not plain React: its components are compile-time placeholders, `$token` names an entry in the project's devup.json, and a style prop takes a responsive array. Call devup_skills before you write or edit it - it reports which of those conventions this workspace is missing and installs them where your runtime loads skills from. An agent that skips this does not know it is guessing, and this server cannot see the guesses; the gap is repeated as `skillGap` on the response that carries the code. \ This output is the design, already read. Do not rewrite it from a screenshot or from `get_design_context` - pictures and visual reasoning verify, they do not author. Do not read the node tree and write devup-ui by hand, and do not infer layout from coordinates. Never guess a colour, spacing, radius or typography value: if a value did not come back, say so and stop, because an invented one is indistinguishable from a real one in the code and is the single failure this server exists to prevent. A failed call is a fact to report, not something to route around. \ Ask only for what you will read: `tsx` is the deliverable, and the response always carries `status`, `quality`, `cache.artifactId`, `collection` and `source` beside it. \ @@ -1330,13 +1524,7 @@ impl DevupServer { let url = target .as_ref() .zip(input.frame_ids.first()) - .map(|(target, id)| { - format!( - "{}?node-id={}", - file_scope_url(target), - id.replace(':', "-") - ) - }); + .map(|(target, id)| target.link(Some(id))); let mut arguments = json!(input); for key in ["artifactId", "frameIds", "allScreens", "url"] { arguments.as_object_mut().unwrap().remove(key); @@ -1537,22 +1725,45 @@ impl DevupServer { ))); } - let url = input.url.as_deref().ok_or_else(|| { - to_mcp_error(DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "Either url or artifactId is required.", - false, - )) - })?; - let target = FigmaTarget::parse(url).map_err(to_mcp_error)?; - if input.outputs.iter().any(|output| output == "tsx") && target.node_id.is_none() { + let collection_scope = parse_collection_scope(&input.scope).map_err(to_mcp_error)?; + let mut target = parse_link(input.url.as_deref()).map_err(to_mcp_error)?; + let mut frame_ids = input.frame_ids; + // With no link, frameIds naming one frame is that frame - exactly what + // its own link would say. Several are screens of the selected Section. + if target.file_key == BRIDGE_CURRENT_KEY + && target.node_id.is_none() + && frame_ids.len() == 1 + && !input.all_screens + { + target.node_id = frame_ids.pop(); + } + let wants_tsx = input.outputs.iter().any(|output| output == "tsx"); + let unnamed = if !frame_ids.is_empty() || input.all_screens { + Unnamed::SelectedSection + } else if collection_scope == CollectionScope::File && !wants_tsx { + Unnamed::SelectionOrFile + } else { + Unnamed::Selection + }; + let mut retry = + json!({ "tool": "devup_figma_export", "arguments": { "outputs": input.outputs } }); + if !frame_ids.is_empty() { + retry["arguments"]["frameIds"] = json!(frame_ids); + } + if input.all_screens { + retry["arguments"]["allScreens"] = json!(true); + } + let target = self + .bind_to_bridge(target, unnamed, &retry) + .await + .map_err(to_mcp_error)?; + if wants_tsx && target.node_id.is_none() { return Err(to_mcp_error(DevupError::new( ErrorCode::DevupFigmaNodeNotFound, "A TSX export link requires a node-id.", false, ))); } - let collection_scope = parse_collection_scope(&input.scope).map_err(to_mcp_error)?; let mut request = CollectionRequest::new(target, collection_scope); let (asset_selections, asset_output_paths) = parse_asset_requests(&input.asset_requests).map_err(to_mcp_error)?; @@ -1566,9 +1777,9 @@ impl DevupServer { request.variables_only = collection_scope == CollectionScope::File && input.outputs.iter().all(|output| output == "devupJson") && request.asset_selections.is_empty(); - if !input.frame_ids.is_empty() || input.all_screens { + if !frame_ids.is_empty() || input.all_screens { request.section = Some(SectionReadOptions { - frame_ids: input.frame_ids.clone(), + frame_ids: frame_ids.clone(), all_screens: input.all_screens, }); } @@ -1585,7 +1796,7 @@ impl DevupServer { output_paths: input.output_paths, page_scaffold: input.page_scaffold, previous_design_fingerprints: input.previous_design_fingerprints, - frame_ids: input.frame_ids, + frame_ids, all_screens: input.all_screens, asset_captures: asset_selections, asset_output_paths, @@ -1820,6 +2031,12 @@ impl DevupServer { } } +/// The target a tool's url names. No url is the file the attached Devup Bridge +/// plugin has open, which [`DevupServer::bind_to_bridge`] then resolves. +fn parse_link(url: Option<&str>) -> Result { + url.map_or_else(|| Ok(FigmaTarget::bridge_current()), FigmaTarget::parse) +} + fn section_index_from_payload(payload: &CollectedPayload) -> Option { serde_json::from_value(payload.metadata.get("sectionIndex")?.clone()).ok() } diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index 71d38b26..c6e42af9 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -54,6 +54,9 @@ pub struct AuthInput { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct FigmaExportInput { + /// The Figma link to export. Optional while exactly one Devup Bridge + /// plugin is attached: omitted, or `figma-bridge://current`, it is the + /// file that plugin has open and the node selected in Figma. #[serde(default)] pub url: Option, #[serde(default)] @@ -170,7 +173,10 @@ pub struct FigmaAssetRequestInput { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct FigmaSearchInput { - pub url: String, + /// The Figma link to search in. Optional while exactly one Devup Bridge + /// plugin is attached: omitted, the whole file that plugin has open. + #[serde(default)] + pub url: Option, pub query: String, #[serde(default)] pub node_types: Vec, @@ -184,7 +190,10 @@ pub struct FigmaSearchInput { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct FigmaExploreInput { - pub url: String, + /// The Figma link of the anchor node. Optional while exactly one Devup + /// Bridge plugin is attached: omitted, the node selected in Figma. + #[serde(default)] + pub url: Option, #[serde(default = "default_explore_limit")] pub limit: usize, #[serde(default = "default_true")] diff --git a/crates/devup-mcp/tests/bridge_first.rs b/crates/devup-mcp/tests/bridge_first.rs new file mode 100644 index 00000000..6b506a75 --- /dev/null +++ b/crates/devup-mcp/tests/bridge_first.rs @@ -0,0 +1,567 @@ +//! The bridge is the path to reach for first, and every answer an agent reads +//! before its first Figma call has to say so. +//! +//! A real session met a Devup Bridge plugin attached and serving, was told +//! `status: "disconnected"` because that word described only the OAuth path, +//! and asked its user to log in. When it pressed on, `devup_figma_export` +//! without a url was refused with no next step, so it invented +//! `/design/bridge/bridge` - and the answer then reported `source.kind: +//! "direct"` and echoed the invented key as the file's. +//! +//! These tests drive the tool surface with a real bridge socket and a plugin +//! standing in for Figma, across the three states that have to be told apart: +//! bridge only, direct only, and neither. + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use devup_mcp::server::{DevupAuth, DevupServer, Services}; +use devup_mcp_figma::{ + AuthStatus, BridgeFigmaClient, BridgeServer, DevupError, ErrorCode, FallbackUpstream, + FigmaUpstream, ReadToolCall, UpstreamResult, +}; +use futures_util::{SinkExt, StreamExt}; +use rmcp::{ServiceExt, model::CallToolRequestParams}; +use serde_json::{Map, Value, json}; +use tokio::{net::TcpStream, sync::Mutex}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message}; + +struct Auth { + status: AuthStatus, + logins: AtomicUsize, +} + +impl Auth { + fn new(status: AuthStatus) -> Arc { + Arc::new(Self { + status, + logins: AtomicUsize::new(0), + }) + } +} + +#[async_trait] +impl DevupAuth for Auth { + async fn status(&self) -> Result { + Ok(self.status) + } + + async fn login(&self) -> Result { + self.logins.fetch_add(1, Ordering::SeqCst); + Ok(AuthStatus::Connected) + } + + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } +} + +/// The metered path behind the bridge. Every read that reaches it is counted, +/// and none should while a plugin serves. +struct Remote(Arc); + +#[async_trait] +impl FigmaUpstream for Remote { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Err(DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + "the direct path was not expected to be used", + false, + )) + } +} + +/// A bridge listening on an ephemeral port in front of the metered path, and +/// a server that reads through both, as production wires them. +struct Setup { + bridge: BridgeServer, + remote_calls: Arc, + upstream: Arc, +} + +fn setup() -> Setup { + let bridge = BridgeServer::start(0).expect("an ephemeral port is free"); + let remote_calls = Arc::new(AtomicUsize::new(0)); + let upstream = Arc::new(FallbackUpstream::new( + BridgeFigmaClient::new(bridge.state()).with_port(bridge.port()), + Remote(remote_calls.clone()), + )); + Setup { + bridge, + remote_calls, + upstream, + } +} + +type Socket = WebSocketStream>; + +/// What the plugin says when it attaches: a file it could not report the key +/// of - the Dev Mode case the session ran into - with one frame selected. +fn keyless_hello(file_name: &str) -> Value { + json!({ + "kind": "hello", + "fileKey": null, + "fileName": file_name, + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [{ "id": "1:2", "name": "Synthetic Frame", "type": "FRAME" }], + "selectionCount": 1, + }) +} + +async fn attach(bridge: &BridgeServer, hello: Value) -> Socket { + let expected = bridge.state().attached_files().await.len() + 1; + let (mut socket, _) = connect_async(format!("ws://127.0.0.1:{}/plugin", bridge.port())) + .await + .expect("the bridge accepts a plugin"); + socket + .send(Message::Text(hello.to_string().into())) + .await + .expect("hello is sent"); + for _ in 0..200 { + if bridge.state().attached_files().await.len() == expected { + return socket; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("the plugin never registered"); +} + +fn frame_node() -> Value { + json!({ + "id": "1:2", "type": "FRAME", + "fields": { + "name": "Synthetic Frame", "parentId": "0:1", "childrenIds": [], + "layoutMode": "VERTICAL", + "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "width": 320, "height": 240 + }, + "extra": {}, "fieldErrors": {} + }) +} + +/// What the plugin's scripts would return for a one-frame file whose key the +/// plugin could not report - every `fileKey` empty, as `figma.fileKey` was. +fn script_answer(script: &str) -> Result { + let page = json!({ + "id": "0:1", "type": "PAGE", + "fields": { "name": "Page 1", "parentId": null, "childrenIds": ["1:2"] }, + "extra": {}, "fieldErrors": {} + }); + match script { + "pageCatalog" => Ok(json!({ + "fileKey": "", "version": null, "rootIds": ["0:1"], "nodes": [page], "diagnostics": [] + })), + "search" => Ok(json!({ + "fileKey": "", "version": null, "rootIds": ["0:1"], + "nodes": [page, frame_node()], "diagnostics": [] + })), + "metadata" => Ok(json!({ + "fileKey": "", "version": null, "rootId": "1:2", + "nodes": [{ + "id": "1:2", "type": "FRAME", "name": "Synthetic Frame", + "childrenIds": [], "descendantCount": 0 + }] + })), + "fastSnapshot" | "snapshot" => Ok(json!({ + "fileKey": "", "version": null, "rootIds": ["1:2"], "nodes": [frame_node()] + })), + _ => Err("DEVUP_TEST_SCRIPT_NOT_SIMULATED"), + } +} + +/// Answers every job the way the plugin would, and keeps what it was asked. +fn serve(mut socket: Socket) -> Arc>> { + let jobs = Arc::new(Mutex::new(Vec::new())); + let log = jobs.clone(); + tokio::spawn(async move { + while let Some(Ok(Message::Text(text))) = socket.next().await { + let job: Value = serde_json::from_str(&text).expect("a job is JSON"); + let answer = match script_answer(job["script"].as_str().unwrap_or_default()) { + Ok(data) => { + json!({ "kind": "devup-result", "requestId": job["requestId"], "data": data }) + } + Err(error) => { + json!({ "kind": "devup-result", "requestId": job["requestId"], "error": error }) + } + }; + log.lock().await.push(job); + if socket + .send(Message::Text(answer.to_string().into())) + .await + .is_err() + { + break; + } + } + }); + jobs +} + +/// Calls one tool and returns its structured answer, error or not, polling an +/// export job to completion the way a client does. +async fn call( + auth: Arc, + upstream: Arc, + tool: &str, + arguments: Value, +) -> anyhow::Result<(bool, Value)> { + let server = DevupServer::new(Services::new(auth, upstream)); + let (server_transport, client_transport) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + let arguments: Map = arguments.as_object().cloned().unwrap_or_default(); + let mut result = client + .call_tool(CallToolRequestParams::new(tool.to_owned()).with_arguments(arguments)) + .await?; + for _ in 0..500 { + let Some(id) = result + .structured_content + .as_ref() + .filter(|value| value["exportJob"]["state"] == "running") + .and_then(|value| value["exportJob"]["jobId"].as_str()) + .map(str::to_owned) + else { + break; + }; + result = client + .call_tool( + CallToolRequestParams::new("devup_figma_export") + .with_arguments(json!({ "jobId": id }).as_object().cloned().unwrap()), + ) + .await?; + } + client.cancel().await?; + task.await??; + Ok(( + result.is_error == Some(true), + result.structured_content.unwrap_or_default(), + )) +} + +/// Whether the answer shows the key the bridge routes a keyless plugin by. +/// It names a connection, not a file, and must never be offered as the file's. +fn shows_a_routing_key(answer: &Value) -> bool { + let text = answer.to_string(); + text.match_indices("bridge:") + .any(|(at, _)| !text[at + "bridge:".len()..].starts_with("//")) +} + +#[tokio::test] +async fn status_with_only_the_bridge_is_connected_through_it() -> anyhow::Result<()> { + let setup = setup(); + let _plugin = attach(&setup.bridge, keyless_hello("Landing")).await; + + let (failed, status) = call( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + "devup_figma_auth", + json!({ "action": "status" }), + ) + .await?; + assert!(!failed, "{status}"); + assert_eq!(status["connected"], true); + assert_eq!(status["status"], "connected"); + assert_eq!(status["activePath"], "bridge"); + assert!( + !status.to_string().contains("disconnected"), + "nothing may say disconnected while the bridge serves: {status}" + ); + let file = &status["paths"]["bridge"]["attachedFiles"][0]; + assert_eq!(file["fileName"], "Landing"); + assert!(file["fileKey"].is_null()); + assert_eq!(file["currentPage"]["name"], "Page 1"); + assert_eq!(file["selection"][0]["id"], "1:2"); + assert_eq!(status["nextAction"]["tool"], "devup_figma_export"); + assert!(status["nextAction"]["arguments"].get("url").is_none()); + assert!(!shows_a_routing_key(&status), "{status}"); + Ok(()) +} + +#[tokio::test] +async fn status_with_only_the_direct_path_is_connected_through_it() -> anyhow::Result<()> { + let setup = setup(); + let (failed, status) = call( + Auth::new(AuthStatus::Connected), + setup.upstream.clone(), + "devup_figma_auth", + json!({ "action": "status" }), + ) + .await?; + assert!(!failed, "{status}"); + assert_eq!(status["connected"], true); + assert_eq!(status["activePath"], "direct"); + assert_eq!(status["paths"]["bridge"]["listening"], true); + assert_eq!(status["paths"]["bridge"]["available"], false); + assert_eq!(status["nextAction"]["requiredArguments"], json!(["url"])); + Ok(()) +} + +#[tokio::test] +async fn status_with_neither_path_offers_the_plugin_then_login() -> anyhow::Result<()> { + let setup = setup(); + let (failed, status) = call( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + "devup_figma_auth", + json!({ "action": "status" }), + ) + .await?; + assert!(!failed, "{status}"); + assert_eq!(status["connected"], false); + assert_eq!(status["status"], "disconnected"); + assert!(status["activePath"].is_null()); + let options = status["nextAction"]["options"].as_array().unwrap(); + assert_eq!(options[0]["path"], "bridge"); + assert_eq!(options[1]["path"], "direct"); + assert_eq!(options[1]["arguments"]["action"], "login"); + Ok(()) +} + +/// The acceptance case: one plugin attached, no token, and no url. The export +/// reads the plugin's file and the frame selected in Figma, says the bridge +/// served it, and claims no file key the plugin did not report. +#[tokio::test] +async fn an_export_without_url_reads_the_selection_through_the_bridge() -> anyhow::Result<()> { + let setup = setup(); + let jobs = serve(attach(&setup.bridge, keyless_hello("Landing")).await); + + let (failed, output) = call( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + "devup_figma_export", + json!({ "outputs": ["tsx"] }), + ) + .await?; + assert!(!failed, "{output}"); + assert!( + output["tsx"] + .as_str() + .is_some_and(|tsx| tsx.contains("SyntheticFrame")), + "{output}" + ); + + let source = &output["source"]; + assert_eq!(source["kind"], "bridge"); + assert_eq!(source["bridgePort"], setup.bridge.port()); + assert!(source["fileKey"].is_null(), "{source}"); + assert!(source["fileKeyReason"].is_string(), "{source}"); + assert!( + source.get("requestedFileKey").is_none(), + "nothing was requested by key: {source}" + ); + assert_eq!(source["fileName"], "Landing"); + assert_eq!(source["pageName"], "Page 1"); + assert_eq!(source["nodeId"], "1:2"); + assert!(source["bridgeReads"].as_u64().unwrap_or_default() > 0); + assert!(!shows_a_routing_key(&output), "{output}"); + + assert_eq!(setup.remote_calls.load(Ordering::SeqCst), 0); + let jobs = jobs.lock().await; + assert!(!jobs.is_empty()); + assert!( + jobs.iter() + .filter(|job| job["script"] != "pageCatalog") + .all(|job| job["params"]["nodeId"] == "1:2"), + "every read targets the selected frame: {jobs:?}" + ); + Ok(()) +} + +/// A key a caller supplied is served by a keyless plugin while it is alone - +/// that is how a real Figma link keeps working in Dev Mode - but the plugin +/// cannot vouch for it, so it is reported as requested, never as the file's. +#[tokio::test] +async fn a_requested_key_the_plugin_cannot_confirm_is_not_claimed() -> anyhow::Result<()> { + let setup = setup(); + let _jobs = serve(attach(&setup.bridge, keyless_hello("Landing")).await); + + let (failed, output) = call( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + "devup_figma_export", + json!({ + "url": "https://www.figma.com/design/bridge/bridge?node-id=1-2", + "outputs": ["tsx"], + }), + ) + .await?; + assert!(!failed, "{output}"); + let source = &output["source"]; + assert_eq!(source["kind"], "bridge"); + assert!(source["fileKey"].is_null(), "{source}"); + assert_eq!(source["requestedFileKey"], "bridge"); + Ok(()) +} + +#[tokio::test] +async fn an_export_without_url_and_no_path_names_both_ways_forward() -> anyhow::Result<()> { + let setup = setup(); + let (failed, answer) = call( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + "devup_figma_export", + json!({ "outputs": ["tsx"] }), + ) + .await?; + assert!(failed, "{answer}"); + let error = &answer["error"]; + assert_eq!(error["code"], "DEVUP_FIGMA_HANDOFF_INVALID"); + let options = error["details"]["nextAction"]["options"] + .as_array() + .unwrap(); + assert_eq!(options[0]["path"], "bridge"); + assert_eq!(options[0]["then"]["tool"], "devup_figma_export"); + assert_eq!(options[1]["path"], "direct"); + assert_eq!(options[1]["tool"], "devup_figma_auth"); + assert_eq!(options[1]["arguments"]["action"], "login"); + assert_eq!(options[1]["then"]["requiredArguments"], json!(["url"])); + assert_eq!(setup.remote_calls.load(Ordering::SeqCst), 0); + Ok(()) +} + +/// Signed in but no plugin: a link is all the call is missing, so no login is +/// suggested - only the link, and the plugin as the cheaper way. +#[tokio::test] +async fn an_export_without_url_on_the_direct_path_asks_for_the_link() -> anyhow::Result<()> { + let setup = setup(); + let (failed, answer) = call( + Auth::new(AuthStatus::Connected), + setup.upstream.clone(), + "devup_figma_export", + json!({ "outputs": ["tsx"] }), + ) + .await?; + assert!(failed, "{answer}"); + let options = answer["error"]["details"]["nextAction"]["options"] + .as_array() + .unwrap(); + let direct = options + .iter() + .find(|option| option["path"] == "direct") + .expect("a direct option"); + assert_eq!(direct["tool"], "devup_figma_export"); + assert_eq!(direct["requiredArguments"], json!(["url"])); + assert!(!answer.to_string().contains("\"login\""), "{answer}"); + Ok(()) +} + +#[tokio::test] +async fn an_export_without_url_between_two_plugins_lists_both() -> anyhow::Result<()> { + let setup = setup(); + let _first = attach(&setup.bridge, keyless_hello("Landing")).await; + let _second = attach( + &setup.bridge, + json!({ "kind": "hello", "fileKey": "OtherFile123", "fileName": "Other", + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [{ "id": "7:8", "name": "Card", "type": "FRAME" }], + "selectionCount": 1 }), + ) + .await; + + let (failed, answer) = call( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + "devup_figma_export", + json!({ "outputs": ["tsx"] }), + ) + .await?; + assert!(failed, "{answer}"); + let options = answer["error"]["details"]["nextAction"]["options"] + .as_array() + .unwrap(); + assert_eq!(options.len(), 2); + assert!( + options[0].get("arguments").is_none(), + "a keyless file has no link to offer" + ); + assert_eq!( + options[1]["arguments"]["url"], + "https://www.figma.com/design/OtherFile123/devup?node-id=7-8" + ); + assert!(!shows_a_routing_key(&answer), "{answer}"); + Ok(()) +} + +/// Search without url reads the whole file the plugin has open, and every +/// link it hands back routes to the same place - the bridge placeholder, not +/// a Figma URL around a key no Figma file has. +#[tokio::test] +async fn a_search_without_url_links_back_through_the_bridge() -> anyhow::Result<()> { + let setup = setup(); + let _jobs = serve(attach(&setup.bridge, keyless_hello("Landing")).await); + + let (failed, output) = call( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + "devup_figma_search", + json!({ "query": "syntheticframe" }), + ) + .await?; + assert!(!failed, "{output}"); + assert_eq!(output["matches"][0]["nodeId"], "1:2"); + assert_eq!( + output["matches"][0]["canonicalUrl"], + "figma-bridge://current?node-id=1-2" + ); + assert_eq!(output["source"]["kind"], "bridge"); + assert!(!shows_a_routing_key(&output), "{output}"); + assert_eq!(setup.remote_calls.load(Ordering::SeqCst), 0); + Ok(()) +} + +#[tokio::test] +async fn an_explore_without_url_anchors_on_the_selection() -> anyhow::Result<()> { + let setup = setup(); + let jobs = serve(attach(&setup.bridge, keyless_hello("Landing")).await); + + // The simulated plugin does not run the explore script; what matters here + // is which node the read was addressed to. + let _ = call( + Auth::new(AuthStatus::Disconnected), + setup.upstream.clone(), + "devup_figma_explore", + json!({}), + ) + .await?; + let jobs = jobs.lock().await; + let explore = jobs + .iter() + .find(|job| job["script"] == "explore") + .expect("the explore read went to the plugin"); + assert_eq!(explore["params"]["nodeId"], "1:2"); + assert_eq!(setup.remote_calls.load(Ordering::SeqCst), 0); + Ok(()) +} + +/// Login is not refused while a plugin is attached - the bridge cannot serve +/// every read - but the answer says it was probably not needed. +#[tokio::test] +async fn login_with_a_plugin_attached_warns_and_still_logs_in() -> anyhow::Result<()> { + let setup = setup(); + let _plugin = attach(&setup.bridge, keyless_hello("Landing")).await; + let auth = Auth::new(AuthStatus::Disconnected); + + let (failed, answer) = call( + auth.clone(), + setup.upstream.clone(), + "devup_figma_auth", + json!({ "action": "login" }), + ) + .await?; + assert!(!failed, "{answer}"); + assert_eq!(auth.logins.load(Ordering::SeqCst), 1); + assert!(answer["warning"].is_string(), "{answer}"); + assert_eq!(answer["activePath"], "bridge"); + Ok(()) +} From 3426de172070d141d2a4f207e1f622562f871cfa Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 25 Sep 2026 22:09:12 +0900 Subject: [PATCH 08/10] feat(server): state the connection order, bridge then direct, in the instructions and the guide Rule 1a says it where every session reads it: call devup_figma_auth status before asking anyone to log in, the bridge needs no login, and with one plugin attached an export takes no url. devup://guide/usage carries the full rule - both paths, url-less calls, figma-bridge://current, the refusals, and how an answer names the bridge. The instructions ceiling moves from 1,200 to 1,400 bytes for it. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/devup-mcp/src/server/guide.rs | 46 ++++++++++++++++++++-- crates/devup-mcp/tests/self_description.rs | 8 +++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index 40c1adbb..f57e76a6 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -17,8 +17,7 @@ pub const GUIDE_URI: &str = "devup://guide/usage"; pub const GUIDE_NAME: &str = "devup-usage-guide"; pub const GUIDE_TITLE: &str = "Using devup-mcp"; -pub const GUIDE_DESCRIPTION: &str = - "Output selection, verification boundaries, SECTION batching and asset rules for devup-mcp"; +pub const GUIDE_DESCRIPTION: &str = "Connection order (bridge -> direct), output selection, verification boundaries, SECTION batching and asset rules for devup-mcp"; pub const GUIDE_MIME_TYPE: &str = "text/markdown"; /// What `initialize` still says. Only the rules that change the very next @@ -33,6 +32,8 @@ pub const INSTRUCTIONS: &str = concat!( "server.updateAvailable reports whether a newer release exists; state \"unknown\" means the check is disabled, ", "not yet run, or could not reach the network, and it never blocks a call.\n", "1. devup-mcp is the primary source for turning a Figma design into code. Do not replace it with another source.\n", + "1a. Connection order: bridge -> direct. Call devup_figma_auth status before asking anyone to log in: the Devup ", + "Bridge plugin needs no login, and with one attached devup_figma_export takes no url.\n", "2. When the goal is implementation, call devup_figma_export first and take tsx. That is the deliverable; ", "a complete response marks it with deliverable.isFinal.\n", "2b. The tsx is devup-ui code. Call devup_skills before writing it: it reports the conventions this ", @@ -51,6 +52,38 @@ These are the rules that used to be pushed to every client in `initialize`. They are published here instead so a caller that never converts a Figma design never carries them. Rule numbers match the original list, which skipped 9. +## 1a. Connection order: bridge -> direct + +Two paths reach Figma and they are not equals. The Devup Bridge plugin, run in +the Figma desktop app on the file, needs no login and spends none of Figma's +allowance. The direct path is OAuth against Figma's MCP and is metered. Use the +bridge first; direct is the fallback for what the bridge cannot serve - a +file-scope metadata read and `referencePng`. + +- Call `devup_figma_auth` with `action: "status"` before asking anyone to log + in. It reports both paths: `connected`, `activePath` (`bridge`, `direct` or + null), `paths.bridge.attachedFiles` with each plugin's file name, current + page and selection, and a `nextAction`. `status` reads `disconnected` only + when neither path can serve. Do not ask for a login while `activePath` is + `bridge`. +- With exactly one plugin attached, `devup_figma_export`, + `devup_figma_search` and `devup_figma_explore` take no `url`. The export + reads the file that plugin has open and the node selected in Figma; + `frameIds` without `url` name frames in that file - one id is that frame, + several are screens of the selected Section. Explore anchors on the + selection, and search reads the whole file. +- When a link is still needed, use `figma-bridge://current`, with + `?node-id=1-2` to name a node. Never invent a Figma file key to fill `url`: + a plugin that cannot report its key answers any key while it is alone, so an + invented one is served and then mistaken for the file's. +- With no plugin attached, or several, a call without `url` is refused with a + `nextAction` that lists the ways forward: run the plugin, log in and pass a + link, or pick one of the attached files. +- An answer the plugin served says so: `source.kind` is `bridge`, with + `bridgePort`, `fileName`, `pageName` and `bridgeReads`. `source.fileKey` is + the key the plugin reported, or `null` with `fileKeyReason` when it could + not report one. + ## 2a. Ask for an output only when you will read it Measured on the Korean WQUW-120 modal, semantic sourceMap is about 5.4x TSX @@ -141,7 +174,8 @@ mod tests { fn every_original_rule_number_survives_the_move() { let combined = format!("{INSTRUCTIONS}\n{GUIDE}"); for number in [ - "1.", "2.", "2a.", "2b.", "3.", "4.", "5.", "6.", "7.", "8.", "10.", "11.", "12.", + "1.", "1a.", "2.", "2a.", "2b.", "3.", "4.", "5.", "6.", "7.", "8.", "10.", "11.", + "12.", ] { assert!( combined.contains(number), @@ -156,10 +190,14 @@ mod tests { /// The whole point was to stop charging every session for Figma prose, so /// the measurement that justified the move is itself a regression test. + /// + /// The ceiling moved once, for rule 1a: the connection order has to be + /// read before the first Figma call, because an agent that met a plugin + /// attached and a `disconnected` direct path asked its user to log in. #[test] fn instructions_are_far_smaller_than_the_guidance_they_point_at() { assert!( - INSTRUCTIONS.len() < 1_200, + INSTRUCTIONS.len() < 1_400, "instructions grew back to {} bytes; the per-session cost is the thing being fixed", INSTRUCTIONS.len() ); diff --git a/crates/devup-mcp/tests/self_description.rs b/crates/devup-mcp/tests/self_description.rs index a86caee0..112b49be 100644 --- a/crates/devup-mcp/tests/self_description.rs +++ b/crates/devup-mcp/tests/self_description.rs @@ -40,11 +40,17 @@ async fn initialize_instructions_are_small_and_name_the_guide() -> anyhow::Resul .as_deref() .expect("the server still publishes instructions"); + // The ceiling moved once, for the connection order: it has to be read + // before the first Figma call, not fetched after the wrong one. assert!( - instructions.len() < 1_200, + instructions.len() < 1_400, "instructions are {} bytes; every client pays this on every session", instructions.len() ); + assert!( + instructions.contains("devup_figma_auth"), + "the connection order names the tool that reports both paths" + ); assert!( instructions.contains("devup://guide/usage"), "instructions must name the guide, or the moved rules are unreachable" From c2e349558ffa92a9d43b43916d93708512bd1117 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 25 Sep 2026 22:09:12 +0900 Subject: [PATCH 09/10] docs: describe bridge-first connectivity in the README and the bundle manifest The bundle manifest told hosts that connecting to Figma is a browser OAuth login; it now names the bridge first. The README documents the path-aware status, url-less calls with figma-bridge://current, the refusals and their nextAction, and the bridge provenance in source. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 94 +++++++++++++++++++++++++++++------- packaging/mcpb/manifest.json | 10 ++-- 2 files changed, 82 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index f591e712..0b061a2b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Figma 쪽 4개, 프로젝트 쪽 5개, 스킬 1개, 모두 10개입니다. - `devup_figma_export`: Figma를 한 번 수집해 요청한 `outputs`만 투영합니다. TSX가 산출물이고, `componentTsx`·`responsiveTsx`·`devup.json`·source map·asset manifest·reference PNG를 같은 수집에서 함께 얻거나, `cache.artifactId`로 재수집 없이 추가 투영할 수 있습니다. raw snapshot·raw payload는 구현이 아니라 진단에 쓰는 것이라 `debug: true`로만 열립니다. - `devup_figma_search`: page, section, frame, component를 이름으로 탐색. URL에 `node-id`가 있으면 **그 노드와 그 아래로 범위를 좁히고**, 없으면 파일 전체를 검색합니다. 둘 중 무엇을 했는지는 응답의 `scope`가 알려줍니다 - `devup_figma_explore`: 링크된 요구사항/라벨 주변의 실제 화면 후보를 공간 순서로 탐색 -- `devup_figma_auth`: 연결 상태 확인, 브라우저 OAuth 로그인, 로그아웃, 사전 등록 자격증명 주입(`configure`), 연결 실패 원인을 실측해 보고하는 `doctor` +- `devup_figma_auth`: Figma에 닿는 **두 경로**(브리지 플러그인·direct OAuth)를 함께 보고(`status`) — 붙어 있는 플러그인의 파일·현재 페이지·선택까지. 그 밖에 브라우저 OAuth 로그인, 로그아웃, 사전 등록 자격증명 주입(`configure`), 참조 데이터까지 붙인 `doctor`. **로그인을 요청하기 전에 `status`부터 부르세요** — 플러그인이 붙어 있으면 로그인은 필요 없습니다 - `devup_skills`: devup-mcp가 내놓는 코드에 필요한 에이전트 스킬이 이 워크스페이스에 있는지 보고(`status`)하고, devup-mcp가 품고 있는 것을 설치(`install`). **텍스트를 응답에 실어 보내는 게 아니라 스킬 디렉터리에 설치해서 에이전트 자신의 로더가 읽게 합니다** — 한 번 읽은 문서는 한 번 쓰이지만, 설치된 스킬은 이후 모든 세션에 계속 적용됩니다. 어느 디렉터리인지는 `clientInfo`로 판별한 런타임이 정하고, `projectRoot`로 어느 프로젝트인지 지정합니다 - `devup_project_context`: 프로젝트의 실제 `devup.json` 토큰, `openapi.json` 엔드포인트, Vespertide 모델을 읽음. 중첩 체크아웃과 빌드 산출물 디렉터리는 스캔에서 제외하고 무엇을 제외했는지 보고 - `devup_ui_validate`: 생성한 TSX를 프로젝트의 실제 `devup.json`에 대조해 검증. `ok`는 개수가 아니라 심각도로 판정 @@ -172,31 +172,35 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. 시스템 브라우저는 `devup_figma_auth`의 `login`을 명시적으로 호출할 때만 열립니다. 변환 도구는 자격증명이 없으면 브라우저를 열지 않고 로그인이 필요하다는 오류를 반환합니다. 인증 정보는 운영체제 credential store에만 저장되며 `logout`은 해당 정보만 삭제합니다. -### 인증 +### 연결 상태와 인증 ```json { "action": "status" } ``` -`action`은 `status`, `login`, `logout`, `configure`, `doctor` 중 하나이며, 스키마가 이 목록을 그대로 게시합니다. `status`/`login`/`logout`의 응답 형태는 항상 `{ "status": "connected" | "disconnected" }`입니다. Figma에 붙지 못하는 이유를 알고 싶으면 `doctor`를 호출하세요. - -```json -{ "action": "doctor" } -``` +`action`은 `status`, `login`, `logout`, `configure`, `doctor` 중 하나이며, 스키마가 이 목록을 그대로 게시합니다. **`status`는 OAuth 상태가 아니라 Figma에 닿는 경로 전체를 보고합니다.** `login`/`logout`도 동작을 마친 뒤 같은 보고를 돌려줍니다. ```json { - "status": "disconnected", + "connected": true, + "status": "connected", + "activePath": "bridge", "preferredPath": "bridge", - "preferredPathNote": "Two paths reach Figma and they are not equals. ...", "paths": { "bridge": { - "available": false, + "available": true, "listening": true, "port": 1993, - "attachedFiles": [], - "attachedFilesNote": "File keys the attached plugins have open. ...", - "reason": "The bridge is listening on 127.0.0.1:1993 but no plugin is attached. ..." + "attachedFiles": [{ + "fileKey": null, + "fileName": "Landing", + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [{ "id": "1:2", "name": "Hero", "type": "FRAME" }], + "selectionCount": 1, + "fileKeyNote": "This plugin could not report its file key ..." + }], + "attachedFilesNote": "The files the attached plugins have open, with the page in view and what is selected on it. ...", + "reason": "A plugin is attached. ..." }, "direct": { "available": false, @@ -205,14 +209,31 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. "tokenState": "absent", "callbackPort": { "port": null, "free": null }, "registrationClientName": { "value": "Codex", "isDefault": true }, - "reason": "저장된 자격증명 없음. ..." + "reason": "No access token is stored. ... While a bridge plugin is attached this path is needed only for ..." } }, - "clientSetup": { "constraints": { ... }, "opencode": { ... }, "claudeCode": "...", "codex": "..." } + "nextAction": { + "tool": "devup_figma_export", + "arguments": { "outputs": ["tsx"] }, + "note": "One Devup Bridge plugin is attached, so no url is needed: ..." + } } ``` -`doctor`는 네트워크 호출을 전혀 하지 않습니다. +- `connected`/`activePath` — 지금 읽기가 실제로 어느 경로로 가는지. 플러그인이 붙어 있으면 `bridge`, 아니면 토큰이 있을 때 `direct`, 둘 다 없으면 `null`입니다. +- **최상위 `status`가 `disconnected`인 것은 두 경로가 모두 막혔을 때뿐입니다.** 예전에는 direct(OAuth) 하나만 보고 `disconnected`라고 답해서, 플러그인이 붙어 있는데도 에이전트가 사용자에게 로그인을 요청했습니다. `status` 키는 그대로라 그 값만 읽는 호출자도 깨지지 않습니다. +- `paths.bridge.attachedFiles` — 붙어 있는 플러그인마다 파일 이름, 보고 있는 페이지, 선택한 노드(앞 20개)와 전체 수. 파일 키를 보고하지 못한 플러그인(Dev Mode 등)은 `fileKey: null`이며, 페이지·선택을 보고하기 전에 빌드된 플러그인은 `selection: null`입니다. +- `nextAction` — 지금 상태에서 할 다음 호출. 플러그인 하나면 url 없는 `devup_figma_export`, direct만 열려 있으면 frame 링크가 필요한 export, 둘 다 막혔으면 `options`에 플러그인 실행(권장)과 로그인을 차례로 담습니다. + +`login`은 **막지 않고 경고합니다.** 플러그인이 붙어 있을 때 부르면 로그인은 그대로 진행하고, 응답에 `warning: "A bridge is attached; login is only needed for file-scope metadata or referencePng."`이 붙습니다. + +```json +{ "action": "doctor" } +``` + +`doctor`는 같은 보고에 `preferredPathNote`와 클라이언트 설정 참조 데이터(`clientSetup`)를 더합니다. `status`와 `doctor` 모두 네트워크 호출을 전혀 하지 않습니다. + +연결을 "인증"과 떼어 놓으려고 별도 도구(`devup_figma_connection` 등)를 두는 방안도 검토했지만 두지 않았습니다. 도구가 하나 늘면 모든 클라이언트가 매 세션 그 스키마를 싣고, 이름을 바꾸면 기존 호출이 깨집니다. 대신 `devup_figma_auth`의 설명과 `status`의 응답이 두 경로를 먼저 말합니다. **경로는 둘이고 대등하지 않습니다.** `preferredPath`가 언제나 `bridge`인 이유입니다 — 브리지는 로그인도 필요 없고 Figma 한도도 쓰지 않습니다. `paths.bridge`의 세 상태는 고치는 방법이 서로 다르니 구분해서 읽어야 합니다. @@ -356,10 +377,49 @@ devup-mcp가 Figma에 붙는 경로는 **둘**이고, 대등하지 않습니다. **플러그인이 이 파일을 맡고 있으면 로그인을 요구하지 않습니다.** 예전에는 수집을 시작하기 전에 토큰부터 확인해서, 한도를 아끼려고 플러그인을 띄운 사람에게 "먼저 한도 쓰는 경로를 여세요"라고 거절했습니다. 지금은 브리지를 먼저 보고, 이 파일을 맡은 플러그인이 없을 때만 로그인을 요구합니다. 수집 도중 브리지가 못 하는 읽기가 있으면 **그 읽기가** 자기 이유로 거절하므로, 무엇이 왜 막혔는지가 그대로 드러납니다. -지금 어느 경로가 살아 있는지는 `devup_figma_auth { action: "doctor" }`의 `paths.bridge`/`paths.direct`로 확인하세요. +지금 어느 경로가 살아 있는지는 `devup_figma_auth { action: "status" }`의 `activePath`와 `paths.bridge`/`paths.direct`로 확인하세요. **연결 순서는 bridge → direct입니다** — 로그인을 요청하기 전에 이것부터 부르세요. 현재 브리지가 **못** 하는 읽기는 둘입니다 — `scope: "file"`의 노드 없는 metadata 읽기(최상위 페이지 목록이라 계약이 다릅니다)와 `referencePng`의 `get_screenshot`. 그 밖의 tsx export 경로는 전부 브리지로 갑니다. +### 링크 없이 쓰기 — 플러그인이 연 파일과 Figma 선택 + +플러그인이 **정확히 하나** 붙어 있으면 `url`을 주지 않아도 됩니다. + +| 도구 | `url` 없이 부르면 | +|---|---| +| `devup_figma_export` | 플러그인이 연 파일의, **Figma에서 선택한 노드**. `frameIds`를 하나 주면 그 프레임이고, 여럿이면 선택한 Section 안의 화면들입니다 | +| `devup_figma_search` | 플러그인이 연 파일 전체 | +| `devup_figma_explore` | Figma에서 선택한 노드를 기준으로 | + +```json +{ "outputs": ["tsx"] } +``` + +링크 자리가 꼭 필요하면 공식 자리표시자 **`figma-bridge://current`**를 쓰세요. `?node-id=1-2`로 노드를 지정할 수 있고, 파일 키를 모를 때 응답이 돌려주는 링크(`canonicalUrl`, `nextAction`)도 이 형태로 옵니다. **Figma 파일 키를 지어내지 마세요.** 키를 보고하지 못한 플러그인은 혼자 붙어 있을 때 아무 키나 받아 주므로 지어낸 키로도 읽기는 성공하고, 그 키가 파일의 키로 오인됩니다. 실제 세션에서 에이전트가 `/design/bridge/bridge`를 지어낸 경위가 이것입니다. + +플러그인이 없거나 여럿이면 url 없는 호출은 거절되고, 거절의 `details.nextAction`이 다음 걸음을 담습니다. 없으면 `options`에 플러그인 실행(권장, 로그인 불필요)과 direct 경로(토큰이 없으면 로그인 후 링크)를, 여럿이면 붙어 있는 파일마다 그 파일을 가리키는 예시 인자를 줍니다. 선택이 비었거나 여럿이거나 플러그인이 선택을 보고하지 않으면, 노드를 지정하는 예시(`figma-bridge://current?node-id=...`)를 줍니다. + +#### 어느 경로가 답했는지 — `source` + +브리지가 답한 응답은 그렇다고 말합니다. + +```json +"source": { + "kind": "bridge", + "bridgePort": 1993, + "fileKey": null, + "fileKeyReason": "The Devup Bridge plugin that served this could not report its file key ...", + "fileName": "Landing", + "pageName": "Page 1", + "nodeId": "1:2", + "bridgeReads": 3 +} +``` + +- `fileKey`는 **플러그인이 보고한 키**뿐입니다. 보고하지 못했으면 `null`과 `fileKeyReason`이 오고, 요청에 실려 온 키가 있었다면 `requestedFileKey`로 따로 보고합니다 — 그 키를 파일의 키로 내세우지 않습니다. `sourceMap.source.fileKey`도 같은 규칙을 따릅니다. +- `bridgeReads`는 이 수집에서 플러그인이 답한 읽기 수입니다. `collection.figmaToolCalls`보다 작으면 나머지(예: `referencePng`)는 direct 경로가 답했습니다. +- 캐시된 수집을 다시 투영한 응답은 `kind: "artifact"`를 유지하고 `origin: "bridge"`와 같은 출처 정보를 답니다. + ### 브리지 플러그인 — 한도를 쓰지 않고 읽기 그래서 **우리가 직접 만든 Figma 플러그인**을 통해 같은 읽기를 할 수 있습니다. 이 경로는 한도를 쓰지 않습니다. 플러그인이 붙어 있으면 스크립트 읽기가 그쪽으로 가고, **안 붙어 있으면 아무 일도 일어나지 않고 그대로 원격 경로로** 갑니다. 설치하지 않은 사람의 동작은 바뀌지 않습니다. diff --git a/packaging/mcpb/manifest.json b/packaging/mcpb/manifest.json index 9b0a3c53..d1e1602a 100644 --- a/packaging/mcpb/manifest.json +++ b/packaging/mcpb/manifest.json @@ -4,7 +4,7 @@ "display_name": "Devup MCP", "version": "0.0.0", "description": "Read Figma designs and generate DevupUI TSX, devup.json theme tokens and exported assets", - "long_description": "devup-mcp is a Rust-native stdio MCP server and a read-only client of Figma Remote MCP. It converts a Figma node link into deterministic @devup-ui/react TSX, projects Figma variables and local styles into devup.json, exports the assets a screen actually paints, and reports how much of the source it reproduced exactly. Connecting to Figma is a browser OAuth login run once from the devup_figma_auth tool; no Figma personal access token is involved and credentials live only in the operating system credential store. This bundle carries a self-contained binary for Linux, Windows and macOS (universal), so no Rust toolchain, Node runtime or cargo install is needed.", + "long_description": "devup-mcp is a Rust-native stdio MCP server and a read-only client of Figma Remote MCP. It converts a Figma node link into deterministic @devup-ui/react TSX, projects Figma variables and local styles into devup.json, exports the assets a screen actually paints, and reports how much of the source it reproduced exactly. Two paths reach Figma, bridge first: the Devup Bridge plugin running in the Figma desktop app needs no login, spends none of Figma's allowance, and lets an export take the file and node selected in Figma with no link at all; otherwise a browser OAuth login run once from the devup_figma_auth tool opens the metered direct path. No Figma personal access token is involved and credentials live only in the operating system credential store. This bundle carries a self-contained binary for Linux, Windows and macOS (universal), so no Rust toolchain, Node runtime or cargo install is needed.", "author": { "name": "dev-five-git", "url": "https://github.com/dev-five-git" @@ -49,19 +49,19 @@ "tools": [ { "name": "devup_figma_auth", - "description": "Check, start or clear the Figma connection, or diagnose why it is not connecting" + "description": "Report both paths to Figma - the Devup Bridge plugin (preferred, no login) and the OAuth direct path - with what attached plugins have open; call status before asking anyone to log in, then log in, out or diagnose" }, { "name": "devup_figma_export", - "description": "Acquire a Figma design once and project TSX, component TSX, responsive TSX, devup.json, source map and assets from it; raw snapshot and raw payload are diagnosis-only and need debug: true" + "description": "Acquire a Figma design once and project TSX, component TSX, responsive TSX, devup.json, source map and assets from it; with one Devup Bridge plugin attached url is optional and the Figma selection is exported; raw snapshot and raw payload are diagnosis-only and need debug: true" }, { "name": "devup_figma_search", - "description": "Find pages, sections, frames and components in a Figma file by name" + "description": "Find pages, sections, frames and components in a Figma file by name; with one Devup Bridge plugin attached url is optional" }, { "name": "devup_figma_explore", - "description": "List the screen candidates spatially related to a linked Figma node" + "description": "List the screen candidates spatially related to a linked Figma node, or to the node selected in Figma when one Devup Bridge plugin is attached" }, { "name": "devup_project_context", From cc8137f535270fbacaa222c73e0ab23c6a73abb1 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 25 Sep 2026 22:09:12 +0900 Subject: [PATCH 10/10] chore: add the changepack for bridge-first connectivity Minor for crates/devup-mcp/Cargo.toml. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_bridge_first.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changepacks/changepack_log_bridge_first.json diff --git a/.changepacks/changepack_log_bridge_first.json b/.changepacks/changepack_log_bridge_first.json new file mode 100644 index 00000000..2ac3f2c5 --- /dev/null +++ b/.changepacks/changepack_log_bridge_first.json @@ -0,0 +1,7 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor" + }, + "note": "An agent asked for one screen with the Devup Bridge plugin attached and serving, was told `status: \"disconnected\"` by `devup_figma_auth` - a word that described only the OAuth path - and asked its user to log in. Pressing on, `devup_figma_export` without a url was refused as \"url or artifactId is required\" with no next step, so it invented `/design/bridge/bridge`; the plugin, which could not report its own file key, served it because it was the only one attached, and the answer reported `source.kind: \"direct\"` and the invented key as the file's. Connectivity is now path-aware: `status` (and the `login`/`logout` answers) report `connected`, `activePath` (`bridge`, `direct` or null), both paths with the files attached plugins have open - file name, current page and selection, which the plugin now reports on attach and on every change - and a `nextAction`; top-level `disconnected` is left only for when neither path can serve, and `doctor` is the same report plus reference data. With exactly one plugin attached, `devup_figma_export`, `devup_figma_search` and `devup_figma_explore` take no url: they read the plugin's file, export and explore start from the node selected in Figma, and `frameIds` without url name frames in it. `figma-bridge://current[?node-id=]` is the official placeholder where a link is still needed, and every link devup-mcp hands back for a file whose key is unknown uses it. With no plugin or several, the refusal carries a `nextAction` listing the ways forward - run the plugin, log in and pass a link, or pick one of the attached files. Answers the plugin served say `source.kind: \"bridge\"` with `bridgePort`, `fileName`, `pageName` and `bridgeReads`, and `source.fileKey` is only ever the key the plugin reported: null with a reason when it reported none, and a key the request carried is reported apart as `requestedFileKey`. A plugin that could not report its key is routed by a connection-scoped key that never reaches the direct path and never appears in an answer. Plugins are now tracked per connection, so two keyless plugins no longer collapse into one slot, with the second's registration erased when the first disconnects. `login` warns rather than blocks when a bridge is attached. The auth tool description, the server instructions (rule 1a) and `devup://guide/usage` now state the connection order: bridge, then direct.", + "date": "2026-09-25T09:00:00.000000000Z" +}