From 91540f176fc5b7ca39a585a2cc7ca14623b24070 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 26 Sep 2026 22:40:17 +0900 Subject: [PATCH 1/2] feat(bridge): let every devup-mcp on the machine share the plugin The plugin can only reach the one port its manifest allows, and one devup-mcp per MCP client session is normal, so every process but the first reported `listening: false` and could read Figma only through the metered direct path - with the plugin attached and serving, the only remedy was to kill another session's process. The process holding the port is now the host; the others relay through its new `/relay` WebSocket. The host assigns every plugin `requestId`, so reads from several processes never collide and each answer returns only on the connection that asked; the attached-file list (page and selection included) is pushed to every relay, so `attachedFiles` reads the same everywhere. When the host exits, a remaining process binds the port at once (the OS gives it to exactly one) and the plugin re-attaches on its own 2-second retry. Reads in flight fail at once instead of after 90 s, and a host drops the reads of a relay that went away. Relaying is limited to the same user's devup-mcp: both sides prove, by HMAC over fresh nonces, that they hold a secret kept in a user-only file; the secret never crosses the wire, a relay handshake carrying `Origin` (a browser page) is refused before the upgrade, and nothing listens beyond 127.0.0.1. The handshake carries a protocol version and an incompatible peer is refused, not trusted. `devup_figma_auth` status/doctor report `paths.bridge.role` (host, relay, connecting, unavailable), the holder's pid/version/buildId, `handoverFrom` while the port changes hands, and for a port that cannot be read through an `issue` - `legacy-host` for a devup-mcp from before sharing, `foreign-program`, `incompatible-protocol`, `authentication-failed` - with the step that fixes it, within seconds. `available` is true only when a read can be sent now; a relay that reaches the plugin never suggests logging in. Tests that spawn the binary now turn the bridge off, so they never touch the machine's real port 1993. --- .../changepack_log_MgCZ-FfadImg96jdhk8Hq.json | 7 + Cargo.lock | 44 +- Cargo.toml | 5 + README.md | 29 +- crates/devup-mcp-figma/Cargo.toml | 11 +- .../devup-mcp-figma/examples/bridge_probe.rs | 12 +- .../examples/original_image_probe.rs | 9 +- crates/devup-mcp-figma/src/bridge.rs | 645 ++++++++++++-- crates/devup-mcp-figma/src/bridge/relay.rs | 809 ++++++++++++++++++ crates/devup-mcp-figma/src/bridge/secret.rs | 348 ++++++++ crates/devup-mcp-figma/src/lib.rs | 5 +- crates/devup-mcp-figma/tests/bridge_relay.rs | 529 ++++++++++++ crates/devup-mcp/Cargo.toml | 4 +- crates/devup-mcp/src/server/diagnostics.rs | 386 ++++++++- crates/devup-mcp/src/server/mod.rs | 39 +- crates/devup-mcp/tests/bridge_handover.rs | 387 +++++++++ crates/devup-mcp/tests/bridge_relay.rs | 455 ++++++++++ crates/devup-mcp/tests/cli.rs | 2 + .../tests/stdio_schema_compat_smoke.rs | 2 + crates/devup-mcp/tests/stdio_smoke.rs | 4 + plugin/README.md | 26 +- 21 files changed, 3553 insertions(+), 205 deletions(-) create mode 100644 .changepacks/changepack_log_MgCZ-FfadImg96jdhk8Hq.json create mode 100644 crates/devup-mcp-figma/src/bridge/relay.rs create mode 100644 crates/devup-mcp-figma/src/bridge/secret.rs create mode 100644 crates/devup-mcp-figma/tests/bridge_relay.rs create mode 100644 crates/devup-mcp/tests/bridge_handover.rs create mode 100644 crates/devup-mcp/tests/bridge_relay.rs diff --git a/.changepacks/changepack_log_MgCZ-FfadImg96jdhk8Hq.json b/.changepacks/changepack_log_MgCZ-FfadImg96jdhk8Hq.json new file mode 100644 index 00000000..4aa44261 --- /dev/null +++ b/.changepacks/changepack_log_MgCZ-FfadImg96jdhk8Hq.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Minor" + }, + "note": "Several devup-mcp processes on one machine now share the Devup Bridge plugin. Only one can hold the port the plugin's manifest allows (ws://localhost:1993), and one devup-mcp per MCP client session is normal, so every process but the first used to report listening: false and could read Figma only through the metered direct path - with a plugin attached and serving, the only remedy was to kill another session's process. The process holding the port is now the host and the others relay through its new /relay endpoint: reads, results and the attached-file list travel over an authenticated WebSocket, request ids are assigned by the host so answers reach only the process that asked, and attachedFiles and the selection read the same in every process. When the host exits, a remaining process takes the port over at once (the OS gives it to exactly one) and the plugin re-attaches on its own two-second retry; reads in flight fail at once instead of after 90 s, and a host drops the reads of a relay that went away. Relaying is limited to the same user's devup-mcp: both sides prove knowledge of a secret kept in a user-only file with HMAC over fresh nonces, the secret never crosses the wire, relay handshakes carrying a browser Origin are refused before the upgrade, and nothing listens beyond 127.0.0.1. The handshake carries a protocol version and an incompatible peer is refused rather than trusted. devup_figma_auth status/doctor report paths.bridge.role (host, relay, connecting, unavailable), the holder's pid/version/buildId, handoverFrom while the port changes hands, and for an unusable port an issue - legacy-host for a devup-mcp from before sharing, foreign-program, incompatible-protocol, authentication-failed - with the step that fixes it, within seconds rather than waiting. available is true only when a read can be sent now, and a relay with the plugin reachable never suggests logging in. DEVUP_FIGMA_BRIDGE_PORT keeps its meaning and a single process behaves and answers as before.", + "date": "2026-09-26T13:22:28.913335Z" +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 00d21ac0..7b234abc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,7 +261,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.29.0", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -718,7 +718,7 @@ dependencies = [ "sha2 0.11.0", "syn 2.0.119", "tokio", - "tokio-tungstenite 0.28.0", + "tokio-tungstenite", "tracing-subscriber", ] @@ -748,6 +748,7 @@ dependencies = [ "axum", "base64 0.23.1", "futures-util", + "hmac", "image", "keyring", "quick-xml", @@ -760,7 +761,7 @@ dependencies = [ "subtle", "tempfile", "tokio", - "tokio-tungstenite 0.28.0", + "tokio-tungstenite", "unicode-normalization", "url", "webbrowser", @@ -3340,18 +3341,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.28.0", -] - [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -3361,7 +3350,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.29.0", + "tungstenite", ] [[package]] @@ -3522,23 +3511,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.5", - "sha1", - "thiserror 2.0.20", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.29.0" @@ -3624,12 +3596,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 174389ec..ee538053 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ axum = "0.8" base64 = "0.23" cap-std = "4.0.3" dunce = "1.0.5" +futures-util = "0.3" +hmac = "0.13" keyring = "4.2" insta = { version = "1.48.0", features = ["glob", "json"] } image = { version = "=0.25.10", default-features = false, features = ["png"] } @@ -40,6 +42,9 @@ sha2 = "0.11" subtle = "2" syn = { version = "2.0.119", features = ["full"] } tokio = { version = "1", features = ["full"] } +# The version axum's `ws` feature already builds, so the bridge relay's client +# and the tests' stand-in plugin add no second WebSocket implementation. +tokio-tungstenite = "0.29" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } url = "2" diff --git a/README.md b/README.md index 0b061a2b..05916863 100644 --- a/README.md +++ b/README.md @@ -189,8 +189,10 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. "paths": { "bridge": { "available": true, - "listening": true, + "role": "relay", + "listening": false, "port": 1993, + "host": { "pid": 32536, "version": "0.12.0", "buildId": "f1962d5", "thisProcess": false }, "attachedFiles": [{ "fileKey": null, "fileName": "Landing", @@ -199,8 +201,8 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. "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. ..." + "attachedFilesNote": "The files the attached plugins have open, with the page in view and what is selected on it - as the devup-mcp holding the bridge port sees them ...", + "reason": "Another devup-mcp on this machine (pid 32536, ...) holds the bridge port 1993 and the plugins attach to it; this process reads through it. ..." }, "direct": { "available": false, @@ -235,11 +237,21 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. 연결을 "인증"과 떼어 놓으려고 별도 도구(`devup_figma_connection` 등)를 두는 방안도 검토했지만 두지 않았습니다. 도구가 하나 늘면 모든 클라이언트가 매 세션 그 스키마를 싣고, 이름을 바꾸면 기존 호출이 깨집니다. 대신 `devup_figma_auth`의 설명과 `status`의 응답이 두 경로를 먼저 말합니다. -**경로는 둘이고 대등하지 않습니다.** `preferredPath`가 언제나 `bridge`인 이유입니다 — 브리지는 로그인도 필요 없고 Figma 한도도 쓰지 않습니다. `paths.bridge`의 세 상태는 고치는 방법이 서로 다르니 구분해서 읽어야 합니다. +**경로는 둘이고 대등하지 않습니다.** `preferredPath`가 언제나 `bridge`인 이유입니다 — 브리지는 로그인도 필요 없고 Figma 한도도 쓰지 않습니다. -- `listening: false` — 이 프로세스가 브리지 포트를 아예 잡지 못했습니다. `DEVUP_FIGMA_BRIDGE_PORT`가 `off`이거나, 다른 devup-mcp가 이미 그 포트를 쥐고 있는 경우입니다(MCP 클라이언트를 여러 개 띄우면 정상입니다). 플러그인을 아무리 실행해도 이 프로세스로는 오지 않습니다. -- `listening: true`, `attachedFiles: []` — 문은 열려 있는데 아무도 들어오지 않았습니다. 대상 파일에서 `Devup Bridge` 플러그인을 실행하세요. -- `attachedFiles`에 파일 키가 있음 — 정상 동작입니다. **이 상태면 로그인 없이 그 파일의 수집이 그대로 됩니다.** +한 기기에 devup-mcp가 여럿 떠 있어도(MCP 클라이언트·세션마다 하나씩 뜨는 것이 정상입니다) **모두가 같은 플러그인을 씁니다.** 플러그인이 붙는 포트를 잡은 프로세스가 호스트가 되고, 나머지는 호스트를 통해 읽습니다. `paths.bridge.role`이 이 프로세스가 무엇을 하는지 말하고, 상태마다 고치는 방법이 다르니 구분해서 읽어야 합니다. + +| `role` | 뜻 | 할 일 | +|---|---|---| +| `host` | 이 프로세스가 포트를 쥐었고(`listening: true`) 플러그인이 여기에 붙습니다 | `attachedFiles`가 비어 있으면 대상 파일에서 `Devup Bridge` 플러그인을 실행하세요 | +| `relay` | 다른 devup-mcp가 쥔 포트를 통해 읽습니다. `host`가 그 프로세스의 pid·버전·buildId입니다 | 호스트와 같습니다. `attachedFiles`는 어느 프로세스에서 보든 같습니다 | +| `connecting` | 포트를 쥔 프로세스가 방금 떠나 이어받거나 다시 붙는 중입니다(`handoverFrom`이 떠난 프로세스) | 몇 초 뒤 `status`를 다시 부르세요. 플러그인은 2초마다 스스로 다시 붙습니다 | +| `unavailable` | 이 프로세스는 지금 브리지를 쓸 수 없습니다. `issue`가 이유입니다 | `reason`과 `nextAction`의 첫 항목이 고치는 방법입니다 | +| `off` | `DEVUP_FIGMA_BRIDGE_PORT`가 `off`/`0`이거나 포트 번호가 아닙니다 | 브리지를 쓰려면 그 값을 지우세요 | + +`issue`는 다음 중 하나입니다 — `legacy-host`(이 기능 이전의 devup-mcp가 포트를 쥐었습니다. 그 클라이언트를 재시작·갱신하면 이 프로세스가 스스로 이어받습니다), `foreign-program`(devup-mcp가 아닌 프로그램), `incompatible-protocol`(중계 규약의 판이 다른 devup-mcp — 틀린 답을 내느니 잇지 않습니다), `authentication-failed`(같은 사용자의 devup-mcp임을 증명하지 못함), `secret-unavailable`, `bind-failed`. 어느 경우든 `status`는 몇 초 안에 답하고 기다리지 않습니다. + +**`available: true`는 지금 실제로 읽기를 보낼 수 있을 때만입니다** — 플러그인이 보이고, 그것에 닿는 길(이 프로세스의 포트, 또는 포트를 쥔 프로세스와의 연결)이 있을 때. 이 상태면 로그인 없이 그 파일의 수집이 그대로 되고, 중계 프로세스에서도 로그인하라는 안내를 하지 않습니다. `paths.direct`의 세 필드는 **서로 다른 것**을 말하므로 함께 읽어야 합니다. @@ -457,7 +469,8 @@ Figma에서 대상 파일을 열고 `Devup Bridge`를 실행하면 창이 하나 #### 알아 두어야 할 것 -- **기본으로 켜져 있습니다.** devup-mcp는 시작할 때 `127.0.0.1:1993`에 대기하고, 그 포트를 잡지 못하면 조용히 원격 경로만 씁니다. 끄려면 `DEVUP_FIGMA_BRIDGE_PORT=off`. +- **기본으로 켜져 있습니다.** devup-mcp는 시작할 때 `127.0.0.1:1993`에 대기합니다. 그 포트를 다른 devup-mcp가 이미 쥐고 있으면 그 프로세스를 통해 같은 플러그인을 읽고, 그 프로세스가 끝나면 남은 devup-mcp 가운데 하나가 포트를 이어받습니다(플러그인은 2초 안에 새 호스트에 다시 붙습니다). 끄려면 `DEVUP_FIGMA_BRIDGE_PORT=off`. +- **여러 devup-mcp가 나눠 쓰는 것은 같은 사용자의 것끼리입니다.** 중계는 그 사용자만 읽을 수 있는 파일(Windows `%USERPROFILE%\AppData\Local\devup-mcp\bridge-relay.key`, macOS `~/Library/Application Support/devup-mcp/`, Linux `~/.local/state/devup-mcp/`)의 비밀값으로 서로를 증명해야 이어지고, 비밀값 자체는 연결로 오가지 않습니다. 브라우저 페이지처럼 `Origin`이 붙은 요청은 중계 문에서 거절됩니다. - **읽기 전용입니다.** 플러그인이 실행하는 스크립트는 **빌드 시점에 플러그인 안에 박혀 있고**, devup-mcp는 그중 어느 것을 실행할지 **이름만** 보냅니다. 소켓으로 코드가 오가지 않으며 Figma 문서를 바꾸는 호출은 존재하지 않습니다. - **이 기기에서만 됩니다.** 대기 주소는 `127.0.0.1`이라 다른 기기에서는 붙을 수 없고, 원격 CI에서는 브리지가 없으니 자동으로 원격 경로를 씁니다. - **파일은 한 번에 하나입니다.** 플러그인이 자기 파일 키를 보고하지 못하는 경우가 있어(Dev Mode 등), 키 없는 플러그인은 **혼자 붙어 있을 때만** 읽기를 받습니다. 두 개 이상이면 어느 파일인지 알 수 없으므로 원격 경로로 넘어갑니다. diff --git a/crates/devup-mcp-figma/Cargo.toml b/crates/devup-mcp-figma/Cargo.toml index 55c20258..39b9f70b 100644 --- a/crates/devup-mcp-figma/Cargo.toml +++ b/crates/devup-mcp-figma/Cargo.toml @@ -12,6 +12,10 @@ async-trait.workspace = true # `ws` 는 기본 기능이 아니다. 브리지가 플러그인을 받는 데 쓴다. axum = { workspace = true, features = ["ws"] } base64.workspace = true +# 중계 연결의 WebSocket 클라이언트가 스트림으로 읽고 쓴다. +futures-util.workspace = true +# 중계 연결의 증명(HMAC-SHA256). sha2 와 같은 digest 세대다. +hmac.workspace = true image.workspace = true keyring.workspace = true rand.workspace = true @@ -23,6 +27,9 @@ serde_json.workspace = true sha2.workspace = true subtle.workspace = true tokio.workspace = true +# 포트를 잡지 못한 프로세스가 잡은 프로세스에 붙는 클라이언트. 테스트의 가짜 +# 플러그인도 이것을 쓴다. +tokio-tungstenite.workspace = true url.workspace = true unicode-normalization.workspace = true webbrowser.workspace = true @@ -30,7 +37,3 @@ webbrowser.workspace = true [dev-dependencies] anyhow.workspace = true tempfile = "3" -# 브리지 서버에 붙는 가짜 플러그인 역할. axum 의 `ws` 가 이미 끌어오는 것과 같은 -# 구현이라 의존성 트리가 늘지 않는다. -tokio-tungstenite = "0.28" -futures-util = "0.3" diff --git a/crates/devup-mcp-figma/examples/bridge_probe.rs b/crates/devup-mcp-figma/examples/bridge_probe.rs index 7603e719..3918761a 100644 --- a/crates/devup-mcp-figma/examples/bridge_probe.rs +++ b/crates/devup-mcp-figma/examples/bridge_probe.rs @@ -18,14 +18,12 @@ use serde_json::Value; #[tokio::main] async fn main() { - let server = match BridgeServer::start(DEFAULT_BRIDGE_PORT) { - Some(server) => server, - None => { - eprintln!("PROBE_FAIL: port {DEFAULT_BRIDGE_PORT} is already taken"); - std::process::exit(1); - } + // 포트를 다른 devup-mcp 가 쥐고 있으면 그쪽을 통해 읽는다. 이 프로브도 그렇다. + let Some(server) = BridgeServer::start(DEFAULT_BRIDGE_PORT) else { + eprintln!("PROBE_FAIL: the bridge could not start"); + std::process::exit(1); }; - println!("listening on ws://127.0.0.1:{}/plugin", server.port()); + println!("bridge on ws://127.0.0.1:{}/plugin", server.port()); println!("waiting for the Devup Bridge plugin in Figma..."); // 플러그인을 가져오고 파일을 여는 데 시간이 걸리므로 넉넉히 기다린다. diff --git a/crates/devup-mcp-figma/examples/original_image_probe.rs b/crates/devup-mcp-figma/examples/original_image_probe.rs index b1824e35..1eafad94 100644 --- a/crates/devup-mcp-figma/examples/original_image_probe.rs +++ b/crates/devup-mcp-figma/examples/original_image_probe.rs @@ -17,9 +17,12 @@ async fn main() -> anyhow::Result<()> { "usage: original_image_probe FILE_KEY NODE_ID FILL_INDEX IMAGE_HASH OUTPUT_PATH" ); let fill_index = args[2].parse::()?; - let server = BridgeServer::from_env().ok_or_else(|| anyhow::anyhow!( - "bridge port unavailable or disabled; stop its current owner or configure the same free port in both plugin and DEVUP_FIGMA_BRIDGE_PORT" - ))?; + // A devup-mcp already holding the port is read through, not fought over. + let server = BridgeServer::from_env().ok_or_else(|| { + anyhow::anyhow!( + "the bridge is disabled; unset DEVUP_FIGMA_BRIDGE_PORT or set it to the plugin's port" + ) + })?; eprintln!( "Waiting up to 60 seconds for the updated plugin on port {}", server.port() diff --git a/crates/devup-mcp-figma/src/bridge.rs b/crates/devup-mcp-figma/src/bridge.rs index a5d3c6ca..8be3f9ca 100644 --- a/crates/devup-mcp-figma/src/bridge.rs +++ b/crates/devup-mcp-figma/src/bridge.rs @@ -7,16 +7,29 @@ //! 방향이 거꾸로인 점이 설계를 결정한다. Figma 플러그인은 들어오는 연결을 받지 //! 못하고 나가는 WebSocket 만 열 수 있어서, **서버는 이쪽**이고 플러그인이 붙는다. //! +//! 한 기기에 devup-mcp 가 여럿 떠 있는 것은 정상이다 — MCP 클라이언트마다, 세션마다 +//! 하나씩 뜬다. 플러그인이 붙을 수 있는 포트는 manifest 에 적힌 하나뿐이라, 그 포트를 +//! 잡은 프로세스(호스트)만 플러그인을 받는다. 잡지 못한 프로세스는 호스트의 `/relay` +//! 에 붙어 그를 통해 읽고, 호스트가 떠나면 그중 하나가 포트를 이어받는다. 어떻게, +//! 왜 그렇게 하는지는 [`relay`] 에 있다. +//! //! 응답은 원격이 주는 모양 그대로 감싼다. 디코더들은 `content[].text` 안의 JSON -//! 을 찾도록 쓰여 있으므로, 여기서 모양을 바꾸면 두 경로가 조용히 갈라진다. +//! 을 찾도록 쓰여 있으므로, 여기서 모양을 바꾸면 두 경로가 조용히 갈라진다. 중계를 +//! 거친 답도 이 모양이다 — 감싸는 일은 요청한 프로세스가 한다. + +mod relay; +mod secret; use std::{ collections::HashMap, + hash::Hash, + io, + path::PathBuf, sync::{ - Arc, + Arc, Mutex as StdMutex, atomic::{AtomicU64, AtomicUsize, Ordering}, }, - time::Duration, + time::{Duration, Instant}, }; use async_trait::async_trait; @@ -33,7 +46,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::{ net::TcpListener, - sync::{Mutex, mpsc, oneshot}, + sync::{Mutex, mpsc, oneshot, watch}, time::timeout, }; @@ -54,6 +67,18 @@ const JOB_TIMEOUT: Duration = Duration::from_secs(90); /// 브리지가 답한 결과의 `_meta` 에서 어느 플러그인이 답했는지를 싣는 자리. const SERVED_META_KEY: &str = "devup/bridge"; +/// 플러그인이 끊긴 뒤 다시 붙기를 시도하는 간격(`plugin/src/ui.ts` 의 `RETRY_MS`). +const PLUGIN_RETRY: Duration = Duration::from_secs(2); + +/// 호스트가 바뀐 직후, 붙어 있던 플러그인이 새 호스트에 다시 붙기를 기다리는 시간. +/// 플러그인의 재시도 간격에 연결·인사에 걸리는 시간을 얹었다. 이 사이의 읽기는 +/// 플러그인이 없다고 단정해 요금이 드는 경로로 넘기지 않고, 붙기를 기다린다. +const REATTACH_GRACE: Duration = Duration::from_secs(PLUGIN_RETRY.as_secs() + 3); + +/// 누가 포트를 쥐었는지 아직 모를 때 status 와 읽기가 답을 기다리는 한도. 확인은 +/// 몇 밀리초면 끝나고, 대답하지 않는 상대도 [`relay`] 의 제한 시간 안에 판정된다. +const SETTLE_TIMEOUT: Duration = Duration::from_secs(5); + /// 플러그인이 가리키는 노드 하나 — 보고 있는 페이지, 또는 거기서 선택한 것. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NodeRef { @@ -142,12 +167,15 @@ struct PluginResult { } /// 서버가 플러그인에 보내는 작업. +/// +/// `requestId` 는 늘 호스트가 매긴다. 중계로 온 읽기도 마찬가지라, 여러 프로세스의 +/// 읽기가 한 플러그인에 모여도 번호가 겹치지 않고, 답은 번호로 물은 쪽에만 간다. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -struct Job { +struct Job<'a> { kind: &'static str, request_id: String, - script: &'static str, + script: &'a str, params: Value, } @@ -174,12 +202,12 @@ impl Connected { } 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(), - } + attached_file( + id, + self.reported_key(), + self.file_name.clone(), + self.context.clone(), + ) } fn served(&self) -> BridgeServed { @@ -197,6 +225,25 @@ impl Connected { } } +/// 붙어 있는 플러그인 하나를, 어느 프로세스에서 보든 같은 모양으로. +/// +/// 호스트는 제 연결에서 만들고, 중계는 호스트가 보낸 목록에서 같은 함수로 만든다. +/// 그래서 `attachedFiles` 가 모든 프로세스에서 같고, 키 없는 플러그인의 라우팅 키도 +/// 호스트가 매긴 연결 번호 그대로다. +fn attached_file( + id: u64, + file_key: Option, + file_name: Option, + context: PluginContext, +) -> AttachedFile { + AttachedFile { + target_key: file_key.clone().unwrap_or_else(|| connection_key(id)), + file_key, + file_name, + context, + } +} + #[derive(Default)] struct Inner { /// 연결 번호 → 플러그인. @@ -205,19 +252,105 @@ struct Inner { /// 그렇게 묶으면 둘째가 첫째를 덮어쓰고 첫째가 끊길 때 둘째의 등록까지 /// 지운다. 그러면 "플러그인이 정확히 하나"를 셀 수도 없다. plugins: HashMap, - /// requestId → 결과를 기다리는 쪽. - pending: HashMap>, + /// 연결 번호 → 이 호스트를 통해 읽는 다른 devup-mcp. 플러그인이 붙거나 떠나거나 + /// 선택이 바뀔 때마다 모두에게 새 목록을 보낸다. + relays: HashMap>, +} + +/// 결과를 기다리는 쪽이 사라지면 그 대기표를 걷는다. +/// +/// 기다리던 future 가 버려지는 일은 흔하다 — 중계를 요청한 프로세스가 끊기면 호스트는 +/// 그 읽기들을 취소한다. 걷지 않으면 플러그인이 끝내 답하지 않는 한 대기표가 남는다. +struct Waiting<'a, K: Eq + Hash, V> { + pending: &'a StdMutex>, + key: K, } -/// 플러그인 연결과 대기 중인 요청을 함께 들고 있는 공유 상태. -#[derive(Clone, Default)] +impl Drop for Waiting<'_, K, V> { + fn drop(&mut self) { + if let Ok(mut pending) = self.pending.lock() { + pending.remove(&self.key); + } + } +} + +/// 이 프로세스가 브리지에서 맡은 역할. +#[derive(Clone)] +enum LinkState { + /// 누가 포트를 쥐었는지 아직 모른다 — 시작할 때, 또는 쥐고 있던 호스트가 떠난 뒤. + Connecting { after: Option }, + /// 이 프로세스가 포트를 쥐었다. + /// + /// `expecting` 은 방금 떠난 호스트다. 그때 플러그인이 붙어 있었으면 곧 이리로 + /// 다시 붙는다. + Host { + expecting: Option, + since: Instant, + }, + /// 다른 devup-mcp 가 포트를 쥐었고, 이 프로세스는 그를 통해 읽는다. `files` 는 + /// 그 호스트가 보낸 플러그인 목록이다. + Relay { + connection: Arc, + files: Vec<(u64, AttachedFile)>, + expecting: Option, + since: Instant, + }, + /// 지금은 브리지를 쓸 수 없다. `holder` 는 포트를 쥔 쪽이 스스로 밝힌 것이다. + Unavailable { + issue: BridgeIssue, + holder: Option, + }, +} + +struct Link { + state: watch::Sender, + /// 중계 핸드셰이크에서 상대에게 밝히는 이 프로세스. + me: BridgePeer, + secret_path: Option, + shutdown: watch::Sender, +} + +impl Link { + fn current(&self) -> LinkState { + self.state.borrow().clone() + } + + fn set(&self, state: LinkState) { + self.state.send_replace(state); + } + + /// 역할은 그대로지만 보이는 것이 바뀌었다(플러그인이 붙거나 떠났다). + fn touch(&self) { + self.state.send_modify(|_| {}); + } + + fn shutdown_signal(&self) -> watch::Receiver { + self.shutdown.subscribe() + } + + fn is_shut_down(&self) -> bool { + *self.shutdown.borrow() + } +} + +/// 닫으라는 신호가 올 때까지 기다린다. +async fn shut_down(signal: &mut watch::Receiver) { + let _ = signal.wait_for(|down| *down).await; +} + +/// 플러그인 연결과 대기 중인 요청, 이 프로세스의 역할을 함께 들고 있는 공유 상태. +#[derive(Clone)] pub struct BridgeState { inner: Arc>, + /// requestId → 결과를 기다리는 쪽. 잠금을 쥔 채 기다리지 않으므로 동기 잠금이면 + /// 되고, 그래야 기다리던 쪽이 사라질 때 [`Waiting`] 이 곧바로 걷을 수 있다. + pending: Arc>>>, /// 요청 번호와 연결 번호를 함께 매긴다. 둘 다 유일하기만 하면 된다. counter: Arc, - /// 붙어 있는 플러그인 수. 배치 크기를 정할 때는 잠금을 기다릴 수 없어 + /// 이 프로세스에서 보이는 플러그인 수. 배치 크기를 정할 때는 잠금을 기다릴 수 없어 /// (그 자리가 async 가 아니다) 따로 센다. connected: Arc, + link: Arc, } fn unavailable(message: impl Into) -> DevupError { @@ -256,63 +389,282 @@ fn describe_file(file_key: &str) -> String { /// /// 연결 키는 그 연결 하나만 가리킨다. 몇 개가 붙어 있든 모호하지 않고, 그 연결이 /// 끊기면 아무도 맡지 않는다 — 다른 파일의 플러그인이 대신 답하면 안 된다. -fn resolve(plugins: &HashMap, file_key: &str) -> Option { +/// +/// `plugins` 는 (연결 번호, 보고한 파일 키 — 없으면 빈 문자열) 이다. 호스트는 제 +/// 연결로, 중계는 호스트가 보낸 목록으로 같은 규칙을 쓴다. +fn resolve<'a>(plugins: impl IntoIterator, file_key: &str) -> Option { + let plugins: Vec<(u64, &str)> = plugins.into_iter().collect(); if is_bridge_only_key(file_key) { - return connection_id(file_key).filter(|id| plugins.contains_key(id)); + return connection_id(file_key).filter(|id| plugins.iter().any(|(held, _)| held == id)); } let holding = plugins .iter() - .filter(|(_, plugin)| plugin.file_key == file_key) + .filter(|(_, key)| *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), + match plugins.as_slice() { + [(id, "")] => Some(*id), _ => None, } } +fn keys_of(plugins: &HashMap) -> impl Iterator { + plugins + .iter() + .map(|(id, plugin)| (*id, plugin.file_key.as_str())) +} + +/// 중계가 다룰 수 없는 상태에서 읽기가 왔을 때의 거절. +fn not_reachable(link: &LinkState) -> DevupError { + match link { + LinkState::Unavailable { issue, .. } => unavailable(format!( + "this devup-mcp cannot use the Devup Bridge right now ({}); devup_figma_auth \ + {{ action: \"status\" }} says why and what to do", + issue.code() + )), + _ => unavailable( + "the Devup Bridge port is changing hands: the devup-mcp that held it went away and \ + this process is taking it over or reconnecting. Repeat the call in a few seconds.", + ), + } +} + impl BridgeState { + fn new(options: BridgeOptions) -> Self { + Self { + inner: Arc::default(), + pending: Arc::default(), + // 연결 번호가 키 없는 플러그인의 라우팅 키(`bridge:<번호>`)가 된다. 호스트가 + // 바뀐 뒤 옛 호스트가 매긴 번호가 새 호스트의 다른 플러그인을 가리키면, 읽기가 + // 엉뚱한 파일로 조용히 간다. 프로세스마다 멀리 떨어진 자리에서 센다. + counter: Arc::new(AtomicU64::new(rand::random::() >> 16)), + connected: Arc::default(), + link: Arc::new(Link { + state: watch::channel(LinkState::Connecting { after: None }).0, + me: BridgePeer { + pid: std::process::id(), + version: env!("CARGO_PKG_VERSION").to_owned(), + build_id: options.build_id, + }, + secret_path: options.secret_path.or_else(secret::default_path), + shutdown: watch::channel(false).0, + }), + } + } + fn next_request_id(&self) -> String { format!("job-{}", self.counter.fetch_add(1, Ordering::Relaxed)) } - /// 이 파일을 열어 둔 플러그인이 있는지. + /// 포트를 쥔 이 프로세스가 플러그인과 중계를 받기 시작한다. + fn serve( + &self, + listener: std::net::TcpListener, + expecting: Option, + ) -> io::Result<()> { + listener.set_nonblocking(true)?; + let listener = TcpListener::from_std(listener)?; + let app = Router::new() + .route("/plugin", any(plugin_socket)) + .route("/relay", any(relay::relay_socket)) + .with_state(self.clone()); + let mut shutdown = self.link.shutdown_signal(); + self.connected.store(0, Ordering::Relaxed); + self.link.set(LinkState::Host { + expecting, + since: Instant::now(), + }); + tokio::spawn(async move { + // 닫으라는 신호가 오면 듣기를 멈추고 포트를 놓는다. 이어받을 프로세스가 곧장 + // bind 할 수 있어야 한다. + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async move { shut_down(&mut shutdown).await }) + .await; + }); + Ok(()) + } + + /// 플러그인 목록이 바뀌었다. 세고, 중계들에게 알리고, 기다리는 쪽을 깨운다. + fn plugins_changed(&self, inner: &mut Inner) { + self.connected.store(inner.plugins.len(), Ordering::Relaxed); + if !inner.relays.is_empty() { + let message = relay::files_message(&inner.plugins); + inner + .relays + .retain(|_, outbox| outbox.send(message.clone()).is_ok()); + } + self.link.touch(); + } + + /// 역할이 정해질 때까지, 그리고 방금 호스트가 바뀌었다면 플러그인이 다시 붙을 + /// 때까지 기다린다. 둘 다 짧고, 한도가 있다. + /// + /// 그 사이에 판정하면 "플러그인이 없다"가 된다. 그러면 읽기는 요금이 드는 경로로 + /// 넘어가고, status 는 로그인하라고 한다 — 플러그인이 2초 뒤면 다시 붙는데도. + async fn settle(&self) { + let deadline = tokio::time::Instant::now() + SETTLE_TIMEOUT; + let mut changes = self.link.state.subscribe(); + loop { + let until = match &*changes.borrow_and_update() { + LinkState::Connecting { .. } => Some(deadline), + LinkState::Host { + expecting: Some(_), + since, + } + | LinkState::Relay { + expecting: Some(_), + since, + .. + } if self.connected.load(Ordering::Relaxed) == 0 => { + Some(deadline.min(tokio::time::Instant::from_std(*since + REATTACH_GRACE))) + } + _ => None, + }; + let Some(until) = until else { return }; + if !matches!( + tokio::time::timeout_at(until, changes.changed()).await, + Ok(Ok(())) + ) { + return; + } + } + } + + /// 이 프로세스에서 보이는 플러그인, 연결 번호와 함께 붙은 순서대로. + async fn view(&self, link: &LinkState) -> Vec<(u64, AttachedFile)> { + match link { + LinkState::Host { .. } => { + 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)| (*id, plugin.attached(*id))) + .collect() + } + LinkState::Relay { files, .. } => files.clone(), + LinkState::Connecting { .. } | LinkState::Unavailable { .. } => Vec::new(), + } + } + + /// 이 파일을 열어 둔 플러그인이 있는지. 포트를 쥔 프로세스를 통해 보이는 것도 센다. pub async fn has_plugin(&self, file_key: &str) -> bool { - resolve(&self.inner.lock().await.plugins, file_key).is_some() + self.settle().await; + let link = self.link.current(); + let view = self.view(&link).await; + resolve( + view.iter() + .map(|(id, file)| (*id, file.file_key.as_deref().unwrap_or_default())), + file_key, + ) + .is_some() } /// 붙어 있는 플러그인이 보고한 파일 키. 보고하지 못한 플러그인은 빈 문자열이다. pub async fn connected_files(&self) -> Vec { let mut keys: Vec = self - .inner - .lock() + .attached_files() .await - .plugins - .values() - .map(|plugin| plugin.file_key.clone()) + .into_iter() + .map(|file| file.file_key.unwrap_or_default()) .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 + self.settle().await; + let link = self.link.current(); + self.view(&link) + .await .into_iter() - .map(|(id, plugin)| plugin.attached(*id)) + .map(|(_, file)| file) .collect() } + /// 아직 플러그인의 답을 기다리는 읽기 수. 요청한 쪽이 사라지면 그 읽기가 걷히는지 + /// 테스트가 확인한다. + #[doc(hidden)] + pub fn pending_reads(&self) -> usize { + self.pending.lock().map_or(0, |pending| pending.len()) + } + + async fn path_snapshot(&self, port: Option) -> BridgePathSnapshot { + self.settle().await; + let link = self.link.current(); + let attached_files: Vec = self + .view(&link) + .await + .into_iter() + .map(|(_, file)| file) + .collect(); + let recently = |expecting: &Option, since: &Instant| { + expecting + .clone() + .filter(|_| attached_files.is_empty() && since.elapsed() < REATTACH_GRACE) + }; + let (role, host, issue, handover_from) = match &link { + LinkState::Host { expecting, since } => ( + BridgeRole::Host, + Some(self.link.me.clone()), + None, + recently(expecting, since), + ), + LinkState::Relay { + connection, + expecting, + since, + .. + } => ( + BridgeRole::Relay, + Some(connection.host.clone()), + None, + recently(expecting, since), + ), + LinkState::Connecting { after } => (BridgeRole::Connecting, None, None, after.clone()), + LinkState::Unavailable { issue, holder } => ( + BridgeRole::Unavailable, + holder.clone(), + Some(issue.clone()), + None, + ), + }; + BridgePathSnapshot { + port, + attached_files, + role, + host, + issue, + handover_from, + } + } + + /// 읽기 하나를 플러그인에 보낸다. 포트를 쥐었으면 곧장, 아니면 쥔 프로세스를 통해. async fn dispatch( &self, file_key: &str, - script: &'static str, + script: &str, + params: Value, + ) -> Result<(Value, BridgeServed), DevupError> { + match self.link.current() { + LinkState::Host { .. } => self.dispatch_local(file_key, script, params).await, + LinkState::Relay { connection, .. } => { + connection.dispatch(file_key, script, params).await + } + other => Err(not_reachable(&other)), + } + } + + /// 이 프로세스에 붙은 플러그인으로 보낸다. 중계로 온 읽기도 여기로 온다. + async fn dispatch_local( + &self, + file_key: &str, + script: &str, params: Value, ) -> Result<(Value, BridgeServed), DevupError> { let request_id = self.next_request_id(); @@ -320,7 +672,7 @@ impl BridgeState { let served = { let mut inner = self.inner.lock().await; - let Some((id, plugin)) = resolve(&inner.plugins, file_key) + let Some((id, plugin)) = resolve(keys_of(&inner.plugins), file_key) .and_then(|id| inner.plugins.get(&id).map(|plugin| (id, plugin))) else { return Err(unavailable(format!( @@ -336,25 +688,36 @@ impl BridgeState { }; let encoded = serde_json::to_string(&job) .map_err(|error| unavailable(format!("bridge job encode failed: {error}")))?; + // 답이 보내기보다 먼저 올 수는 없지만, 대기표는 보내기 전에 둔다. + self.pending + .lock() + .expect("the pending table is never poisoned") + .insert(request_id.clone(), tx); let sent = plugin.outbox.send(encoded).is_ok(); let served = plugin.served(); if !sent { + self.pending + .lock() + .expect("the pending table is never poisoned") + .remove(&request_id); // 소켓이 막 닫혔다. 등록을 지워 다음 호출이 곧장 폴백하도록 한다. inner.plugins.remove(&id); - self.connected.store(inner.plugins.len(), Ordering::Relaxed); + self.plugins_changed(&mut inner); return Err(unavailable(format!( "the Devup Bridge plugin for {} disconnected", describe_file(file_key) ))); } - inner.pending.insert(request_id.clone(), tx); served }; + // 성공이든 실패든, 기다리던 쪽이 도중에 사라지든 대기표는 반드시 걷는다. + // 남겨 두면 연결이 오래 살아 있는 동안 계속 쌓인다. + let _waiting = Waiting { + pending: &self.pending, + key: request_id, + }; let received = timeout(JOB_TIMEOUT, rx).await; - // 성공이든 실패든 대기표는 반드시 걷는다. 남겨 두면 연결이 오래 살아 있는 - // 동안 계속 쌓인다. - self.inner.lock().await.pending.remove(&request_id); match received { Ok(Ok(result)) => match (result.data, result.error) { @@ -445,6 +808,13 @@ fn wrap_as_tool_result(data: &Value, served: &BridgeServed) -> Result, upgrade: WebSocketUpgrade) -> Response { upgrade.on_upgrade(move |socket| handle_plugin(state, socket)) } @@ -452,11 +822,14 @@ 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 id = state.counter.fetch_add(1, Ordering::Relaxed); + let mut shutdown = state.link.shutdown_signal(); // 보내기와 받기를 한 루프에서 번갈아 본다. 소켓을 쪼개려면 Stream/Sink 트레이트 // 의존성이 필요한데, 그것을 들이는 것보다 select 가 싸다. loop { tokio::select! { + // 이 프로세스가 브리지를 내려놓는다. 소켓을 닫아야 플러그인이 다음 호스트를 찾는다. + () = shut_down(&mut shutdown) => break, outgoing = outbox_rx.recv() => { let Some(text) = outgoing else { break }; if socket.send(Message::Text(text.into())).await.is_err() { @@ -492,24 +865,31 @@ async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { }; let mut inner = state.inner.lock().await; inner.plugins.insert(id, plugin); - state.connected.store(inner.plugins.len(), Ordering::Relaxed); + state.plugins_changed(&mut inner); } // 페이지를 옮기거나 선택을 바꿀 때마다 온다. 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) { + let mut inner = state.inner.lock().await; + if let Some(plugin) = inner.plugins.get_mut(&id) { plugin.context = context; + state.plugins_changed(&mut inner); } } Some("devup-result") => { let Ok(result) = serde_json::from_value::(value) else { continue; }; - let waiting = state.inner.lock().await.pending.remove(&result.request_id); - // 받는 쪽이 이미 타임아웃했으면 버린다. + let waiting = state + .pending + .lock() + .expect("the pending table is never poisoned") + .remove(&result.request_id); + // 받는 쪽이 이미 타임아웃했거나 떠났으면 버린다. if let Some(waiting) = waiting { let _ = waiting.send(result); } @@ -521,13 +901,79 @@ async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { } let mut inner = state.inner.lock().await; - inner.plugins.remove(&id); - state - .connected - .store(inner.plugins.len(), Ordering::Relaxed); + if inner.plugins.remove(&id).is_some() { + state.plugins_changed(&mut inner); + } +} + +/// 브리지를 여는 데 쓰는 것. +#[derive(Debug, Clone, Default)] +pub struct BridgeOptions { + /// 이 빌드의 식별자(`devup-mcp --version` 의 괄호 안). 중계 핸드셰이크가 상대에게 + /// 알리고, status 가 포트를 쥔 프로세스를 밝힐 때 쓴다. + pub build_id: Option, + /// 중계 비밀값 파일. 비우면 그 사용자만 쓰는 기본 자리다. + pub secret_path: Option, +} + +/// 중계 핸드셰이크에서 밝히는 devup-mcp 하나. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgePeer { + pub pid: u32, + pub version: String, + #[serde(default)] + pub build_id: Option, +} + +/// 이 프로세스가 브리지에서 맡은 역할. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BridgeRole { + /// 포트를 쥐었다. 플러그인은 이 프로세스에 붙는다. + Host, + /// 다른 devup-mcp 가 쥔 포트를 통해 읽는다. + Relay, + /// 누가 포트를 쥐었는지 확인하는 중이다 — 시작할 때, 또는 호스트가 떠난 뒤. + Connecting, + /// 지금은 브리지를 쓸 수 없다. 이유는 [`BridgeIssue`] 다. + Unavailable, +} + +/// 브리지를 쓸 수 없는 이유. 고치는 방법이 서로 다르다. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BridgeIssue { + /// 중계를 모르는 예전 devup-mcp 가 포트를 쥐었다. `/plugin` 만 있고 `/relay` 가 없다. + LegacyHost, + /// devup-mcp 가 아닌 프로그램이 포트를 쥐었다. + ForeignProgram { detail: String }, + /// 다른 판의 중계 규약을 쓰는 devup-mcp 가 쥐었다. 틀린 답을 내느니 잇지 않는다. + IncompatibleProtocol { theirs: Option, ours: u64 }, + /// 서로 같은 사용자의 devup-mcp 임을 증명하지 못했다. + AuthenticationFailed { detail: String }, + /// 중계 비밀값 파일을 읽거나 만들 수 없다. + SecretUnavailable { detail: String }, + /// 포트를 잡을 수 없는데 그 포트에서 듣는 쪽도 없다. + BindFailed { detail: String }, } -/// 브리지 서버. 포트를 잡지 못하면 열지 않으며, 그 경우 호출자는 원격 경로만 쓴다. +impl BridgeIssue { + /// status 가 싣는 짧은 이름. + pub fn code(&self) -> &'static str { + match self { + Self::LegacyHost => "legacy-host", + Self::ForeignProgram { .. } => "foreign-program", + Self::IncompatibleProtocol { .. } => "incompatible-protocol", + Self::AuthenticationFailed { .. } => "authentication-failed", + Self::SecretUnavailable { .. } => "secret-unavailable", + Self::BindFailed { .. } => "bind-failed", + } + } +} + +/// 브리지 서버. +/// +/// 포트를 잡으면 호스트가 되고, 잡지 못하면 그 포트를 쥔 devup-mcp 를 통해 읽는다. +/// 어느 쪽이든 [`BridgeFigmaClient`] 는 같은 방식으로 쓴다. pub struct BridgeServer { state: BridgeState, port: u16, @@ -536,31 +982,35 @@ pub struct BridgeServer { impl BridgeServer { /// 포트를 잡아 서버를 띄운다. /// - /// 이미 다른 devup-mcp 가 같은 포트를 쓰고 있으면 `None` 을 준다. 한 대의 - /// 기기에서 MCP 클라이언트를 여러 개 띄우는 것은 정상이므로, 이는 오류가 - /// 아니라 "이 프로세스는 브리지를 쓰지 않는다"는 뜻이다. + /// 이미 다른 프로세스가 같은 포트를 쓰고 있으면 그쪽을 통해 읽는다. 한 기기에서 + /// MCP 클라이언트를 여러 개 띄우는 것은 정상이고, 플러그인이 붙을 수 있는 포트는 + /// 하나뿐이다. 그 포트를 쥔 쪽이 떠나면 이 프로세스가 이어받을 수 있다. /// /// 서버가 만들어지는 자리가 동기 함수라 bind 도 동기로 한다 — 듣기 소켓을 /// 여는 것은 어차피 기다리지 않는 일이고, 기다리는 부분만 따로 띄운다. pub fn start(port: u16) -> Option { - let listener = std::net::TcpListener::bind(("127.0.0.1", port)).ok()?; - listener.set_nonblocking(true).ok()?; - // 0 을 주면 커널이 빈 포트를 고른다. 실제로 잡힌 번호를 알아야 붙을 수 있다. - let bound = listener.local_addr().ok()?.port(); + Self::start_with(port, BridgeOptions::default()) + } + + pub fn start_with(port: u16, options: BridgeOptions) -> Option { // 런타임 밖에서 만들어졌다면 붙일 곳이 없다. 그때도 원격 경로는 멀쩡하다. + // bind 보다 먼저 본다 — 쓰지도 못할 포트를 잠깐이라도 쥐면 안 된다. let runtime = tokio::runtime::Handle::try_current().ok()?; - - let state = BridgeState::default(); - let app = Router::new() - .route("/plugin", any(plugin_socket)) - .with_state(state.clone()); - runtime.spawn(async move { - let Ok(listener) = TcpListener::from_std(listener) else { - return; - }; - let _ = axum::serve(listener, app).await; - }); - Some(Self { state, port: bound }) + let state = BridgeState::new(options); + match std::net::TcpListener::bind(("127.0.0.1", port)) { + Ok(listener) => { + // 0 을 주면 커널이 빈 포트를 고른다. 실제로 잡힌 번호를 알아야 붙을 수 있다. + let bound = listener.local_addr().ok()?.port(); + state.serve(listener, None).ok()?; + Some(Self { state, port: bound }) + } + // 0 은 빈 포트를 달라는 뜻이라, 누가 쥐고 있을 포트가 없다. + Err(_) if port == 0 => None, + Err(_) => { + runtime.spawn(relay::supervise(state.clone(), port)); + Some(Self { state, port }) + } + } } /// 환경 설정을 읽어 띄운다. @@ -569,23 +1019,36 @@ impl BridgeServer { /// 없으면 기본 포트로 켠다 — 플러그인이 안 떠 있으면 어차피 원격으로 가므로 /// 켜 두는 쪽이 손해가 없다. pub fn from_env() -> Option { + Self::from_env_with(BridgeOptions::default()) + } + + pub fn from_env_with(options: BridgeOptions) -> Option { let setting = std::env::var("DEVUP_FIGMA_BRIDGE_PORT").ok(); let port = match setting.as_deref().map(str::trim) { None | Some("") => DEFAULT_BRIDGE_PORT, Some("off") | Some("0") => return None, Some(value) => value.parse().ok()?, }; - Self::start(port) + Self::start_with(port, options) } pub fn state(&self) -> BridgeState { self.state.clone() } - /// 실제로 잡은 포트. `start(0)` 으로 띄웠을 때 어디에 붙어야 하는지 알려 준다. + /// 실제로 잡은 포트, 또는 이 프로세스가 통해 읽는 포트. `start(0)` 으로 띄웠을 때 + /// 어디에 붙어야 하는지 알려 준다. pub fn port(&self) -> u16 { self.port } + + /// 브리지를 내려놓는다 — 프로세스가 끝날 때처럼 포트와 모든 연결을 닫는다. + /// + /// 서버를 버리는 것만으로는 닫지 않는다. 운영에서는 프로세스가 끝날 때까지 + /// 살아 있어야 하고, 이것은 한 프로세스 안에서 호스트가 떠나는 일을 재현한다. + pub fn shutdown(self) { + self.state.link.shutdown.send_replace(true); + } } /// 이 요청을 맡을 수 있는지 **부르기 전에** 답하는 상류. @@ -605,16 +1068,35 @@ pub trait PreferredUpstream: FigmaUpstream { /// 브리지 경로의 실측 상태. `devup_figma_auth doctor` 의 `paths.bridge` 가 된다. /// -/// 이 값이 있다는 것 자체가 "이 프로세스가 브리지를 열었다"는 뜻이다. 포트를 -/// 잡지 못했거나 `DEVUP_FIGMA_BRIDGE_PORT=off` 면 브리지 상류가 아예 만들어지지 -/// 않으므로 `None` 이 된다. +/// 이 값이 있다는 것 자체가 "이 프로세스가 브리지를 쓰려 한다"는 뜻이다. +/// `DEVUP_FIGMA_BRIDGE_PORT=off` 면 브리지 상류가 아예 만들어지지 않으므로 `None` +/// 이 된다. 포트를 잡지 못한 것은 이제 `None` 이 아니다 — 그 포트를 쥔 쪽을 통해 +/// 읽거나([`BridgeRole::Relay`]), 왜 그럴 수 없는지를 말한다. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BridgePathSnapshot { - /// 실제로 잡은 포트. 플러그인 manifest 의 `allowedDomains` 와 같아야 붙는다. + /// 플러그인이 붙는 포트 — 이 프로세스가 잡았든 다른 프로세스가 잡았든. + /// 플러그인 manifest 의 `allowedDomains` 와 같아야 붙는다. pub port: Option, - /// 지금 붙어 있는 플러그인, 붙은 순서대로. 파일 키를 보고하지 못한 - /// 플러그인은 혼자 붙어 있을 때만 다른 키의 읽기를 받는다. + /// 지금 붙어 있는 플러그인, 붙은 순서대로. 포트를 쥔 프로세스 기준이라 어느 + /// 프로세스에서 보든 같다. 파일 키를 보고하지 못한 플러그인은 혼자 붙어 있을 + /// 때만 다른 키의 읽기를 받는다. pub attached_files: Vec, + pub role: BridgeRole, + /// 포트를 쥔 devup-mcp. 호스트면 이 프로세스 자신이다. 모르면 `None` 이다 — + /// 예전 devup-mcp 는 자신을 밝히지 않는다. + pub host: Option, + /// [`BridgeRole::Unavailable`] 의 이유. + pub issue: Option, + /// 방금 떠난 호스트. 포트가 넘어가는 중이거나, 넘어갔는데 그때 붙어 있던 + /// 플러그인이 아직 다시 붙지 않았다. + pub handover_from: Option, +} + +impl BridgePathSnapshot { + /// 지금 이 경로로 읽기를 보낼 수 있는지 — 플러그인이 보이고, 그것에 닿는 길이 있다. + pub fn available(&self) -> bool { + matches!(self.role, BridgeRole::Host | BridgeRole::Relay) && !self.attached_files.is_empty() + } } /// 플러그인을 통해 Figma 를 읽는 `FigmaUpstream`. @@ -675,10 +1157,7 @@ impl FigmaUpstream for BridgeFigmaClient { } async fn bridge_path_snapshot(&self) -> Option { - Some(BridgePathSnapshot { - port: self.port, - attached_files: self.state.attached_files().await, - }) + Some(self.state.path_snapshot(self.port).await) } } diff --git a/crates/devup-mcp-figma/src/bridge/relay.rs b/crates/devup-mcp-figma/src/bridge/relay.rs new file mode 100644 index 00000000..d446a5ac --- /dev/null +++ b/crates/devup-mcp-figma/src/bridge/relay.rs @@ -0,0 +1,809 @@ +//! 포트를 잡지 못한 devup-mcp 가 잡은 devup-mcp 를 통해 플러그인을 쓰는 길. +//! +//! # 왜 이렇게 하나 +//! +//! 플러그인은 바꿀 수 없다. 이미 설치된 플러그인은 manifest 의 `allowedDomains` +//! 에 적힌 `ws://localhost:1993` 하나에만 붙고, Figma 는 그 목록을 실행 중에 바꾸지 +//! 못한다. 붙으면 `hello` 로 파일·페이지·선택을 알리고, 선택이 바뀔 때마다 +//! `context` 를 보내고, 받은 `devup-job` 을 하나씩 순서대로 실행해 `devup-result` +//! 로 답한다. 소켓이 닫히면 2초 뒤 같은 주소로 다시 붙는다(`plugin/src/ui.ts`). +//! +//! 그래서 포트를 잡은 프로세스(호스트)가 플러그인을 받고, 나머지는 호스트에 붙어 +//! 읽기를 맡긴다(중계). 호스트가 떠나면 남은 프로세스 가운데 하나가 포트를 +//! 이어받고, 플러그인은 제 재시도로 새 호스트에 붙는다. +//! +//! 버린 대안: +//! +//! - **상주 프로세스(데몬)가 포트를 쥔다.** 누가 띄우고 누가 끄는지가 남는다. +//! 마지막 세션이 끝나도 데몬이 남으면 고아가 되고, 클라이언트가 자기 프로세스 +//! 트리를 정리하면(Windows job object 등) 그 세션이 띄운 데몬도 함께 죽어 다른 +//! 세션들이 한꺼번에 끊긴다. 여기서는 호스트도 그저 한 세션의 devup-mcp 라서, +//! 떠나면 남은 쪽이 이어받는다. 누구도 자기 수명보다 오래 살지 않는다. +//! - **프로세스마다 다른 포트를 쓴다.** 플러그인이 붙을 수 있는 포트는 하나다. +//! - **중계를 다른 포트에 연다.** 호스트를 찾을 곳이 따로 필요해진다. 플러그인이 +//! 붙는 그 포트에 문을 하나 더 여는 편이, 이어받기 뒤에도 찾을 곳이 바뀌지 않는다. +//! +//! # 규약 +//! +//! 중계는 호스트의 `/relay` 에 WebSocket 으로 붙는다. 호스트가 먼저 +//! `relay-hello {protocol, nonce, host}` 로 자신을 밝히고, 중계가 +//! `relay-auth {protocol, nonce, proof, peer}` 로 답하고, 호스트가 +//! `relay-welcome {proof, files}` 로 받아들인다. 증명은 [`super::secret`] 의 HMAC +//! 이다 — 양쪽이 서로의 증명을 확인해야 이어진다. 판이 다르면(`protocol`) 어느 +//! 쪽이든 `relay-refused` 로 거절한다. 틀린 답을 내느니 잇지 않는다. +//! +//! 이어진 뒤에는 중계가 `relay-job {id, fileKey, script, params}` 를 보내고 호스트가 +//! `relay-result {id, data | error, served}` 로 답한다. `id` 는 중계가 매기고 그 +//! 연결 안에서만 뜻이 있다. 플러그인이 보는 `requestId` 는 호스트가 따로 매기므로 +//! 여러 프로세스의 번호가 섞이지 않고, 답은 요청이 온 연결로만 간다. 플러그인이 +//! 붙거나 떠나거나 선택이 바뀌면 호스트가 `relay-files` 로 모두에게 같은 목록을 +//! 보낸다. +//! +//! 중계가 끊기면 호스트는 그 중계의 읽기를 거둔다. 플러그인에는 취소를 보낼 길이 +//! 없어(플러그인은 바꾸지 않는다) 이미 넘긴 작업은 끝까지 돌지만, 그 답은 버려진다. +//! 호스트가 끊기면 중계의 읽기는 기다리지 않고 곧장 실패하고, 중계는 곧바로 포트를 +//! 잡아 본다. OS 가 포트를 한 소켓에만 주므로 둘이 동시에 호스트가 되지 않는다 — +//! 진 쪽은 이긴 쪽에 중계로 붙는다. +//! +//! # 누가 쥐었는지 +//! +//! 포트를 잡지 못했을 때 그 포트에서 듣는 쪽은 셋 중 하나다. +//! +//! - `/relay` 가 `relay-hello` 로 답하면 이 기능을 아는 devup-mcp 다. +//! - `/relay` 는 404 인데 `/plugin` 이 WebSocket 을 받으면 이 기능 이전의 +//! devup-mcp 다. 고칠 수는 없으므로 그 사실과 할 일(그 클라이언트를 재시작하거나 +//! 갱신한다)을 말하고, 포트가 풀리면 이어받는다. `hello` 를 보내지 않으므로 그 +//! 프로세스에 플러그인으로 등록되지 않는다. +//! - 그 밖은 devup-mcp 가 아닌 프로그램이다. +//! +//! 어느 쪽이든 답을 기다리는 데 한도가 있다. 대답하지 않는 상대도 몇 초 안에 +//! 판정되고, 그 뒤로는 주기적으로 다시 확인한다. + +use std::{ + collections::HashMap, + io, + sync::{ + Arc, Mutex as StdMutex, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; + +use axum::{ + extract::{ + State, + ws::{Message as ServerMessage, WebSocket, WebSocketUpgrade}, + }, + http::{HeaderMap, StatusCode, header::ORIGIN}, + response::{IntoResponse, Response}, +}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::{ + net::TcpStream, + sync::{mpsc, oneshot}, + task::JoinSet, + time::timeout, +}; +use tokio_tungstenite::{ + WebSocketStream, client_async, + tungstenite::{self, Message, client::IntoClientRequest}, +}; + +use super::{ + AttachedFile, BridgeIssue, BridgePeer, BridgeServed, BridgeState, Connected, JOB_TIMEOUT, + LinkState, PluginContext, Waiting, attached_file, + secret::{self, RelaySecret, Side}, + shut_down, unavailable, +}; +use crate::errors::{DevupError, ErrorCode}; + +/// 중계 규약의 판. 호스트와 중계는 같은 판일 때만 이어진다. +pub(super) const PROTOCOL: u64 = 1; + +/// 포트를 쥔 쪽에 닿고 핸드셰이크를 마치기까지 한 단계마다 기다리는 한도. +const PROBE_TIMEOUT: Duration = Duration::from_secs(3); + +/// 중계로 붙은 쪽이 증명을 내기까지 호스트가 기다리는 한도. 말없이 붙어 있는 +/// 연결은 이 뒤에 끊는다. +const AUTH_TIMEOUT: Duration = Duration::from_secs(5); + +/// 브리지를 쓸 수 없는 동안 포트를 다시 확인하는 간격. +const RETRY_INTERVAL: Duration = Duration::from_secs(2); + +/// 호스트가 떠난 직후에는 이 동안 촘촘히 다시 시도하고, 실패를 보고하지 않는다. +/// 떠나는 프로세스의 소켓이 모두 닫히기까지의 짧은 틈을 "다른 프로그램"으로 +/// 오판하지 않기 위해서다. +const TAKEOVER_WINDOW: Duration = Duration::from_secs(3); +const QUICK_RETRY: Duration = Duration::from_millis(50); + +/// 포트를 잡을 수 없는데 아무도 듣지 않는 상태를 몇 번까지 경합으로 볼지. +const VACANT_RETRIES: u32 = 10; + +/// 호스트의 90초 제한이 먼저 끝나 그 이유가 전해지도록 중계는 조금 더 기다린다. +const RELAY_SLACK: Duration = Duration::from_secs(5); + +type ClientSocket = WebSocketStream; + +/// 호스트가 중계에 보내는 플러그인 하나. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireFile { + connection: u64, + #[serde(default)] + file_key: Option, + #[serde(default)] + file_name: Option, + #[serde(flatten)] + context: PluginContext, +} + +fn wire_files(plugins: &HashMap) -> Vec { + let mut ids: Vec = plugins.keys().copied().collect(); + ids.sort_unstable(); + ids.into_iter() + .map(|id| { + let plugin = &plugins[&id]; + WireFile { + connection: id, + file_key: plugin.reported_key(), + file_name: plugin.file_name.clone(), + context: plugin.context.clone(), + } + }) + .collect() +} + +pub(super) fn files_message(plugins: &HashMap) -> String { + json!({ "kind": "relay-files", "files": wire_files(plugins) }).to_string() +} + +fn files_from(message: &Value) -> Vec<(u64, AttachedFile)> { + message + .get("files") + .cloned() + .and_then(|files| serde_json::from_value::>(files).ok()) + .unwrap_or_default() + .into_iter() + .map(|file| { + ( + file.connection, + attached_file(file.connection, file.file_key, file.file_name, file.context), + ) + }) + .collect() +} + +fn kind(message: &Value) -> Option<&str> { + message.get("kind").and_then(Value::as_str) +} + +fn text_field<'a>(message: &'a Value, field: &str) -> Option<&'a str> { + message.get(field).and_then(Value::as_str) +} + +// --------------------------------------------------------------------------- +// 호스트 쪽 +// --------------------------------------------------------------------------- + +/// 중계 문. +/// +/// 브라우저는 WebSocket 핸드셰이크에 늘 `Origin` 을 싣고, 페이지는 그 헤더를 지울 수 +/// 없다. devup-mcp 는 싣지 않는다. 그래서 `Origin` 이 있으면 업그레이드 전에 +/// 거절한다 — 로컬 웹 페이지가 MCP 클라이언트인 척 열린 문서를 읽는 길을 막는다. +pub(super) async fn relay_socket( + State(state): State, + headers: HeaderMap, + upgrade: WebSocketUpgrade, +) -> Response { + if headers.contains_key(ORIGIN) { + return StatusCode::FORBIDDEN.into_response(); + } + upgrade.on_upgrade(move |socket| serve_relay(state, socket)) +} + +async fn next_server_json(socket: &mut WebSocket) -> Option { + while let Some(message) = socket.recv().await { + match message.ok()? { + ServerMessage::Text(text) => return serde_json::from_str(&text).ok(), + ServerMessage::Close(_) => return None, + _ => {} + } + } + None +} + +async fn send_server(socket: &mut WebSocket, message: &Value) -> bool { + socket + .send(ServerMessage::Text(message.to_string().into())) + .await + .is_ok() +} + +/// 중계로 들일지. 들이면 상대의 nonce 와 이쪽의 증명을, 아니면 거절 사유를 준다. +/// +/// 사유는 상대에게 가는 짧은 낱말이다. 비밀값이나 증명은 싣지 않는다. +async fn admit( + state: &BridgeState, + host_nonce: &str, + answer: &Value, +) -> Result { + if kind(answer) != Some("relay-auth") { + return Err("auth"); + } + if answer.get("protocol").and_then(Value::as_u64) != Some(PROTOCOL) { + return Err("protocol"); + } + let (Some(relay_nonce), Some(proof)) = + (text_field(answer, "nonce"), text_field(answer, "proof")) + else { + return Err("auth"); + }; + let Some(path) = state.link.secret_path.as_deref() else { + return Err("secret"); + }; + let Ok(secret) = secret::load_or_create(path).await else { + return Err("secret"); + }; + if !secret.verifies(Side::Relay, host_nonce, relay_nonce, proof) { + return Err("auth"); + } + Ok(secret.proof(Side::Host, host_nonce, relay_nonce)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RelayJob { + id: u64, + file_key: String, + script: String, + #[serde(default)] + params: Value, +} + +async fn serve_relay(state: BridgeState, mut socket: WebSocket) { + let mut shutdown = state.link.shutdown_signal(); + let nonce = secret::nonce(); + let hello = json!({ + "kind": "relay-hello", + "protocol": PROTOCOL, + "nonce": nonce, + "host": state.link.me, + }); + if !send_server(&mut socket, &hello).await { + return; + } + let answer = tokio::select! { + answer = timeout(AUTH_TIMEOUT, next_server_json(&mut socket)) => answer.ok().flatten(), + () = shut_down(&mut shutdown) => return, + }; + // 말이 없거나 알아볼 수 없는 말을 한 상대는 그냥 끊는다. + let Some(answer) = answer else { return }; + let proof = match admit(&state, &nonce, &answer).await { + Ok(proof) => proof, + Err(reason) => { + let refused = + json!({ "kind": "relay-refused", "reason": reason, "protocol": PROTOCOL }); + let _ = send_server(&mut socket, &refused).await; + let _ = socket.send(ServerMessage::Close(None)).await; + return; + } + }; + + let (outbox, mut outbox_rx) = mpsc::unbounded_channel::(); + let relay = state.counter.fetch_add(1, Ordering::Relaxed); + { + // 환영과 등록을 한 잠금 안에서 한다. 그 사이에 플러그인이 바뀌어도 이 중계는 + // 바뀐 목록을 환영 뒤에 받는다. + let mut inner = state.inner.lock().await; + let welcome = + json!({ "kind": "relay-welcome", "proof": proof, "files": wire_files(&inner.plugins) }); + let _ = outbox.send(welcome.to_string()); + inner.relays.insert(relay, outbox.clone()); + } + + let mut reads = JoinSet::new(); + loop { + tokio::select! { + outgoing = outbox_rx.recv() => { + let Some(text) = outgoing else { break }; + if socket.send(ServerMessage::Text(text.into())).await.is_err() { + break; + } + } + incoming = socket.recv() => { + let Some(Ok(message)) = incoming else { break }; + let ServerMessage::Text(text) = message else { continue }; + let Ok(message) = serde_json::from_str::(&text) else { continue }; + if kind(&message) != Some("relay-job") { + continue; + } + let Ok(job) = serde_json::from_value::(message) else { continue }; + let (state, outbox) = (state.clone(), outbox.clone()); + reads.spawn(async move { + let answer = match state.dispatch_local(&job.file_key, &job.script, job.params).await { + Ok((data, served)) => json!({ + "kind": "relay-result", "id": job.id, "data": data, "served": served, + }), + Err(error) => json!({ + "kind": "relay-result", "id": job.id, "error": error.message, + }), + }; + let _ = outbox.send(answer.to_string()); + }); + } + Some(_) = reads.join_next(), if !reads.is_empty() => {} + () = shut_down(&mut shutdown) => break, + } + } + state.inner.lock().await.relays.remove(&relay); + // 요청한 프로세스가 떠났다. 그 프로세스의 읽기를 모두 거둔다 — 취소된 읽기의 + // 대기표는 그 Drop 에서 걷힌다. + reads.abort_all(); +} + +// --------------------------------------------------------------------------- +// 중계 쪽 +// --------------------------------------------------------------------------- + +enum Answer { + Data(Value, BridgeServed), + Failed(String), +} + +#[derive(Deserialize)] +struct RelayResult { + id: u64, + #[serde(default)] + data: Option, + #[serde(default)] + error: Option, + #[serde(default)] + served: Option, +} + +/// 이 프로세스가 통해 읽는 호스트 하나와의 연결. +pub(crate) struct RelayConnection { + pub(crate) host: BridgePeer, + outbox: mpsc::UnboundedSender, + pending: StdMutex>>, + next: AtomicU64, +} + +impl RelayConnection { + fn gone(&self) -> DevupError { + unavailable(format!( + "the devup-mcp holding the Devup Bridge port (pid {}) went away mid-read. Another \ + process takes the port over and the plugin reconnects within seconds; repeat the call.", + self.host.pid + )) + } + + pub(crate) async fn dispatch( + &self, + file_key: &str, + script: &str, + params: Value, + ) -> Result<(Value, BridgeServed), DevupError> { + let id = self.next.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.pending + .lock() + .expect("the pending table is never poisoned") + .insert(id, tx); + let _waiting = Waiting { + pending: &self.pending, + key: id, + }; + let job = json!({ + "kind": "relay-job", "id": id, "fileKey": file_key, "script": script, "params": params, + }); + if self.outbox.send(job.to_string()).is_err() { + return Err(self.gone()); + } + match timeout(JOB_TIMEOUT + RELAY_SLACK, rx).await { + Ok(Ok(Answer::Data(data, served))) => Ok((data, served)), + // 호스트가 붙인 문구를 그대로 올린다. 스크립트가 던진 DEVUP_* 코드도 + // 그 안에 있고, 위쪽 분기는 그 문자열로 판정한다. + Ok(Ok(Answer::Failed(message))) => Err(DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + message, + false, + )), + Ok(Err(_)) => Err(self.gone()), + Err(_) => Err(unavailable( + "the Devup Bridge plugin did not answer in time", + )), + } + } + + fn answer(&self, result: RelayResult) { + let Some(waiting) = self + .pending + .lock() + .expect("the pending table is never poisoned") + .remove(&result.id) + else { + return; + }; + let answer = match (result.data, result.error) { + (_, Some(error)) => Answer::Failed(error), + (Some(data), None) => Answer::Data(data, result.served.unwrap_or_default()), + (None, None) => Answer::Failed("bridge returned neither data nor error".to_owned()), + }; + let _ = waiting.send(answer); + } +} + +struct Joined { + connection: Arc, + files: Vec<(u64, AttachedFile)>, + socket: ClientSocket, + outbox: mpsc::UnboundedReceiver, +} + +enum Probe { + Joined(Box), + /// 아무도 듣고 있지 않다 — 포트를 쥔 쪽이 막 떠났다. + Vacant, + /// 누군가 쥐었는데 그를 통해 읽을 수 없다. + Refused { + issue: BridgeIssue, + holder: Option, + }, +} + +fn foreign(detail: impl Into) -> Probe { + Probe::Refused { + issue: BridgeIssue::ForeignProgram { + detail: detail.into(), + }, + holder: None, + } +} + +enum Unopened { + Vacant, + NotFound, + Other(String), +} + +/// 포트에서 듣는 쪽의 `path` 에 WebSocket 을 연다. +async fn open(port: u16, path: &str) -> Result { + let seconds = PROBE_TIMEOUT.as_secs(); + let stream = match timeout(PROBE_TIMEOUT, TcpStream::connect(("127.0.0.1", port))).await { + Ok(Ok(stream)) => stream, + Ok(Err(error)) if error.kind() == io::ErrorKind::ConnectionRefused => { + return Err(Unopened::Vacant); + } + Ok(Err(error)) => { + return Err(Unopened::Other(format!( + "connecting to it failed ({error})" + ))); + } + Err(_) => { + return Err(Unopened::Other(format!( + "it did not accept a connection within {seconds}s" + ))); + } + }; + let request = format!("ws://127.0.0.1:{port}{path}") + .into_client_request() + .map_err(|error| Unopened::Other(error.to_string()))?; + match timeout(PROBE_TIMEOUT, client_async(request, stream)).await { + Ok(Ok((socket, _))) => Ok(socket), + Ok(Err(tungstenite::Error::Http(response))) if response.status().as_u16() == 404 => { + Err(Unopened::NotFound) + } + Ok(Err(tungstenite::Error::Http(response))) => Err(Unopened::Other(format!( + "it answered a WebSocket handshake on {path} with HTTP {}", + response.status().as_u16() + ))), + Ok(Err(error)) => Err(Unopened::Other(format!( + "it does not speak WebSocket on {path} ({error})" + ))), + Err(_) => Err(Unopened::Other(format!( + "it did not answer a WebSocket handshake within {seconds}s" + ))), + } +} + +async fn next_client_json(socket: &mut ClientSocket) -> Option { + while let Some(message) = socket.next().await { + match message.ok()? { + Message::Text(text) => return serde_json::from_str(&text).ok(), + Message::Close(_) => return None, + _ => {} + } + } + None +} + +async fn send_client(socket: &mut ClientSocket, message: &Value) -> bool { + socket + .send(Message::Text(message.to_string().into())) + .await + .is_ok() +} + +/// `/relay` 가 없는 쪽이 예전 devup-mcp 인지. `/plugin` 이 WebSocket 을 받으면 그렇다. +/// +/// `hello` 를 보내지 않으니 그 프로세스에 플러그인으로 등록되지 않는다. 곧장 닫는다. +async fn legacy_or_foreign(port: u16) -> Probe { + match open(port, "/plugin").await { + Ok(mut socket) => { + let _ = socket.close(None).await; + Probe::Refused { + issue: BridgeIssue::LegacyHost, + holder: None, + } + } + Err(Unopened::Vacant) => Probe::Vacant, + Err(_) => { + foreign("it answers HTTP on the port but has neither of devup-mcp's bridge endpoints") + } + } +} + +/// 포트를 쥔 쪽에 중계로 붙는다. +async fn join(state: &BridgeState, port: u16) -> Probe { + let mut socket = match open(port, "/relay").await { + Ok(socket) => socket, + Err(Unopened::Vacant) => return Probe::Vacant, + Err(Unopened::NotFound) => return legacy_or_foreign(port).await, + Err(Unopened::Other(detail)) => return foreign(detail), + }; + let Some(hello) = timeout(PROBE_TIMEOUT, next_client_json(&mut socket)) + .await + .ok() + .flatten() + else { + return foreign("it accepted a relay connection but never introduced itself"); + }; + if kind(&hello) != Some("relay-hello") { + return foreign("it answered the relay handshake with something else"); + } + let holder = hello + .get("host") + .cloned() + .and_then(|host| serde_json::from_value::(host).ok()); + let theirs = hello.get("protocol").and_then(Value::as_u64); + if theirs != Some(PROTOCOL) { + let refused = + json!({ "kind": "relay-refused", "reason": "protocol", "protocol": PROTOCOL }); + let _ = send_client(&mut socket, &refused).await; + let _ = socket.close(None).await; + return Probe::Refused { + issue: BridgeIssue::IncompatibleProtocol { + theirs, + ours: PROTOCOL, + }, + holder, + }; + } + let Some(host_nonce) = text_field(&hello, "nonce").map(str::to_owned) else { + return foreign("its relay greeting carried no challenge"); + }; + let secret = match load_secret(state).await { + Ok(secret) => secret, + Err(detail) => { + return Probe::Refused { + issue: BridgeIssue::SecretUnavailable { detail }, + holder, + }; + } + }; + let nonce = secret::nonce(); + let auth = json!({ + "kind": "relay-auth", + "protocol": PROTOCOL, + "nonce": nonce, + "proof": secret.proof(Side::Relay, &host_nonce, &nonce), + "peer": state.link.me, + }); + if !send_client(&mut socket, &auth).await { + return foreign("it closed the relay connection during the handshake"); + } + let Some(reply) = timeout(PROBE_TIMEOUT, next_client_json(&mut socket)) + .await + .ok() + .flatten() + else { + return Probe::Refused { + issue: BridgeIssue::AuthenticationFailed { + detail: "it did not answer this process's proof".to_owned(), + }, + holder, + }; + }; + match kind(&reply) { + Some("relay-welcome") => { + let proven = text_field(&reply, "proof") + .is_some_and(|proof| secret.verifies(Side::Host, &host_nonce, &nonce, proof)); + if !proven { + let _ = socket.close(None).await; + return Probe::Refused { + issue: BridgeIssue::AuthenticationFailed { + detail: "it could not prove it holds this user's relay secret".to_owned(), + }, + holder, + }; + } + let Some(host) = holder else { + return foreign("it did not say which process it is"); + }; + let (outbox, outbox_rx) = mpsc::unbounded_channel(); + Probe::Joined(Box::new(Joined { + connection: Arc::new(RelayConnection { + host, + outbox, + pending: StdMutex::default(), + next: AtomicU64::new(1), + }), + files: files_from(&reply), + socket, + outbox: outbox_rx, + })) + } + Some("relay-refused") => { + let issue = match text_field(&reply, "reason") { + Some("protocol") => BridgeIssue::IncompatibleProtocol { + theirs: reply.get("protocol").and_then(Value::as_u64), + ours: PROTOCOL, + }, + Some("secret") => BridgeIssue::AuthenticationFailed { + detail: "it could not read its own relay secret".to_owned(), + }, + _ => BridgeIssue::AuthenticationFailed { + detail: "it did not accept this process's proof, so the two do not share \ + this user's relay secret" + .to_owned(), + }, + }; + Probe::Refused { issue, holder } + } + _ => foreign("it answered the relay handshake with something else"), + } +} + +async fn load_secret(state: &BridgeState) -> Result { + let Some(path) = state.link.secret_path.as_deref() else { + return Err( + "there is no per-user directory to keep it in (USERPROFILE or HOME is not set)" + .to_owned(), + ); + }; + secret::load_or_create(path) + .await + .map_err(|error| format!("{}: {error}", path.display())) +} + +/// 호스트와의 연결을 끝날 때까지 돌린다. 끝나면 이 프로세스는 다시 포트를 찾는다. +/// +/// 끝날 때 플러그인이 보였는지를 돌려준다. 보였다면 그 플러그인은 곧 새 호스트에 +/// 다시 붙는다. +async fn drive(state: &BridgeState, joined: Joined, expecting: Option) -> bool { + let Joined { + connection, + files, + mut socket, + mut outbox, + } = joined; + state.connected.store(files.len(), Ordering::Relaxed); + state.link.set(LinkState::Relay { + connection: connection.clone(), + files, + expecting, + since: Instant::now(), + }); + let mut shutdown = state.link.shutdown_signal(); + loop { + tokio::select! { + outgoing = outbox.recv() => { + let Some(text) = outgoing else { break }; + if socket.send(Message::Text(text.into())).await.is_err() { + break; + } + } + incoming = socket.next() => { + let Some(Ok(message)) = incoming else { break }; + let text = match message { + Message::Text(text) => text, + Message::Close(_) => break, + _ => continue, + }; + let Ok(message) = serde_json::from_str::(&text) else { continue }; + match kind(&message) { + Some("relay-result") => { + if let Ok(result) = serde_json::from_value::(message) { + connection.answer(result); + } + } + Some("relay-files") => { + let files = files_from(&message); + state.connected.store(files.len(), Ordering::Relaxed); + state.link.state.send_modify(|link| { + if let LinkState::Relay { files: mirror, .. } = link { + *mirror = files; + } + }); + } + _ => {} + } + } + () = shut_down(&mut shutdown) => break, + } + } + let had_plugins = state.connected.swap(0, Ordering::Relaxed) > 0; + state.link.set(LinkState::Connecting { + after: Some(connection.host.clone()), + }); + // 기다리던 읽기는 모두 "호스트가 떠났다"로 곧장 끝난다. 90초를 기다리지 않는다. + connection + .pending + .lock() + .expect("the pending table is never poisoned") + .clear(); + had_plugins +} + +/// 포트를 잡지 못한 프로세스의 일: 포트를 쥔 쪽을 통해 읽고, 그가 떠나면 이어받는다. +pub(super) async fn supervise(state: BridgeState, port: u16) { + let mut shutdown = state.link.shutdown_signal(); + // 방금 떠난 호스트에 플러그인이 붙어 있었으면, 그 플러그인은 곧 다시 붙는다. + let mut expecting: Option = None; + let mut takeover_until: Option = None; + let mut vacant = 0_u32; + loop { + if state.link.is_shut_down() { + return; + } + // 포트가 풀렸으면 잡는다. OS 가 한 소켓에만 주므로 경합에서 둘이 함께 이기지 않는다. + let bind_error = match std::net::TcpListener::bind(("127.0.0.1", port)) { + Ok(listener) => match state.serve(listener, expecting.clone()) { + Ok(()) => return, + Err(error) => error, + }, + Err(error) => error, + }; + let probe = tokio::select! { + probe = join(&state, port) => probe, + () = shut_down(&mut shutdown) => return, + }; + let taking_over = takeover_until.is_some_and(|until| Instant::now() < until); + let pause = match probe { + Probe::Joined(joined) => { + vacant = 0; + let host = joined.connection.host.clone(); + let had_plugins = drive(&state, *joined, expecting.take()).await; + expecting = had_plugins.then_some(host); + takeover_until = Some(Instant::now() + TAKEOVER_WINDOW); + continue; + } + Probe::Vacant if taking_over || vacant < VACANT_RETRIES => { + vacant += 1; + QUICK_RETRY + } + Probe::Vacant => { + state.link.set(LinkState::Unavailable { + issue: BridgeIssue::BindFailed { + detail: bind_error.to_string(), + }, + holder: None, + }); + RETRY_INTERVAL + } + Probe::Refused { .. } if taking_over => QUICK_RETRY, + Probe::Refused { issue, holder } => { + vacant = 0; + state.link.set(LinkState::Unavailable { issue, holder }); + RETRY_INTERVAL + } + }; + tokio::select! { + () = tokio::time::sleep(pause) => {} + () = shut_down(&mut shutdown) => return, + } + } +} diff --git a/crates/devup-mcp-figma/src/bridge/secret.rs b/crates/devup-mcp-figma/src/bridge/secret.rs new file mode 100644 index 00000000..75e00fdb --- /dev/null +++ b/crates/devup-mcp-figma/src/bridge/secret.rs @@ -0,0 +1,348 @@ +//! 중계 연결을 여는 비밀값. +//! +//! 중계 문(`/relay`)은 포트를 잡은 devup-mcp 가 여는 것이라, 같은 기기의 아무 +//! 프로그램이나 두드릴 수 있다. 그 문으로 들어오면 열린 Figma 문서를 읽을 수 +//! 있으므로, 같은 사용자의 devup-mcp 만 들여야 한다. 그 사용자만 읽을 수 있는 +//! 파일에 32바이트 비밀값을 두고, 양쪽이 그것을 안다는 것을 HMAC 으로 증명한다. +//! +//! 비밀값 자체는 연결로 오가지 않는다. 포트를 먼저 잡은 쪽이 devup-mcp 가 아닐 +//! 수도 있어서(아무 프로그램이나 1993 을 잡을 수 있다), 비밀값을 보내면 그 쪽이 +//! 가져간다. 그래서 양쪽이 서로의 nonce 에 대한 증명만 주고받는다. +//! +//! 비밀값은 붙잡아 두지 않고 쓸 때마다 파일에서 읽는다. 두 프로세스가 동시에 +//! 처음 만들거나 망가진 파일을 다시 쓰더라도, 모두가 곧 같은 파일을 보게 된다. + +use std::{ + fs::OpenOptions, + io::{self, Write}, + path::{Path, PathBuf}, + time::Duration, +}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, KeyInit, Mac}; +use rand::Rng; +use sha2::Sha256; + +const SECRET_BYTES: usize = 32; +const FILE_NAME: &str = "bridge-relay.key"; + +/// 다른 프로세스가 막 만든 파일을 아직 다 쓰지 못했을 수 있다. 그만큼만 기다린다. +const READ_ATTEMPTS: usize = 50; +const READ_PAUSE: Duration = Duration::from_millis(20); + +/// 비밀값. 로그·status·오류 어디에도 실리지 않도록 `Debug` 도 내용을 말하지 않는다. +pub(crate) struct RelaySecret([u8; SECRET_BYTES]); + +impl std::fmt::Debug for RelaySecret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("RelaySecret()") + } +} + +/// 증명을 내는 쪽. 방향이 증명에 들어가지 않으면 호스트의 증명을 되돌려 보내 +/// 중계인 척할 수 있다. +#[derive(Debug, Clone, Copy)] +pub(crate) enum Side { + Host, + Relay, +} + +impl RelaySecret { + fn generate() -> Self { + let mut bytes = [0_u8; SECRET_BYTES]; + rand::rng().fill_bytes(&mut bytes); + Self(bytes) + } + + fn encoded(&self) -> String { + let mut text: String = self.0.iter().map(|byte| format!("{byte:02x}")).collect(); + text.push('\n'); + text + } + + fn decode(bytes: &[u8]) -> Option { + let text = std::str::from_utf8(bytes).ok()?.trim(); + if text.len() != SECRET_BYTES * 2 { + return None; + } + let mut secret = [0_u8; SECRET_BYTES]; + for (index, byte) in secret.iter_mut().enumerate() { + *byte = u8::from_str_radix(text.get(index * 2..index * 2 + 2)?, 16).ok()?; + } + Some(Self(secret)) + } + + fn mac(&self, side: Side, host_nonce: &str, relay_nonce: &str) -> Hmac { + let mut mac = + Hmac::::new_from_slice(&self.0).expect("HMAC accepts a key of any length"); + let side: &[u8] = match side { + Side::Host => b"host", + Side::Relay => b"relay", + }; + // 길이를 앞에 붙여 이어 붙인다. 경계가 없으면 nonce 를 옮겨 적어 같은 + // 입력을 만들 수 있다. + for part in [ + b"devup-mcp bridge relay v1".as_slice(), + side, + host_nonce.as_bytes(), + relay_nonce.as_bytes(), + ] { + mac.update(&(part.len() as u64).to_be_bytes()); + mac.update(part); + } + mac + } + + /// `side` 가 두 nonce 에 대해 내는 증명. + pub(crate) fn proof(&self, side: Side, host_nonce: &str, relay_nonce: &str) -> String { + URL_SAFE_NO_PAD.encode( + self.mac(side, host_nonce, relay_nonce) + .finalize() + .into_bytes(), + ) + } + + /// 받은 증명이 맞는지. 비교는 상수 시간이다. + pub(crate) fn verifies( + &self, + side: Side, + host_nonce: &str, + relay_nonce: &str, + proof: &str, + ) -> bool { + let Ok(tag) = URL_SAFE_NO_PAD.decode(proof) else { + return false; + }; + self.mac(side, host_nonce, relay_nonce) + .verify_slice(&tag) + .is_ok() + } +} + +/// 한 번 쓰고 버리는 값. 증명이 매 연결마다 달라져, 엿들은 증명을 다시 쓸 수 없다. +pub(crate) fn nonce() -> String { + let mut bytes = [0_u8; SECRET_BYTES]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// 비밀값 파일의 기본 자리. 그 사용자만 쓰는 디렉터리 아래다. +/// +/// 같은 사용자의 devup-mcp 는 어느 클라이언트가 띄웠든 같은 자리를 봐야 한다. +/// 클라이언트마다 넘기는 환경 변수가 달라서, 누구에게나 있는 값 하나만 쓴다 — +/// Windows 는 `USERPROFILE`(Git Bash 가 넣는 `HOME` 은 셸마다 다르다), 그 밖은 +/// `HOME`. `XDG_STATE_HOME` 처럼 어떤 클라이언트는 넘기고 어떤 클라이언트는 +/// 지우는 값을 따르면, 같은 사용자의 두 프로세스가 서로를 알아보지 못한다. +/// +/// Windows 에서는 따로 권한을 좁히지 않는다. `%USERPROFILE%\AppData\Local` 아래의 +/// 파일은 사용자 프로필의 ACL(그 사용자, SYSTEM, Administrators)을 물려받는다. +pub(crate) fn default_path() -> Option { + #[cfg(windows)] + let base = std::env::var_os("USERPROFILE") + .filter(|home| !home.is_empty()) + .map(|home| PathBuf::from(home).join("AppData").join("Local")) + .or_else(|| { + std::env::var_os("LOCALAPPDATA") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + }); + #[cfg(target_os = "macos")] + let base = home().map(|home| home.join("Library").join("Application Support")); + #[cfg(all(not(windows), not(target_os = "macos")))] + let base = home().map(|home| home.join(".local").join("state")); + base.map(|base| base.join("devup-mcp").join(FILE_NAME)) +} + +#[cfg(not(windows))] +fn home() -> Option { + std::env::var_os("HOME") + .filter(|home| !home.is_empty()) + .map(PathBuf::from) +} + +enum Stored { + Missing, + /// 다른 프로세스가 아직 쓰는 중이거나, 망가졌다. + Unreadable, + /// 다른 사용자가 읽을 수 있게 열려 있었다. 이미 새어 나갔다고 본다. + Exposed, + Secret(RelaySecret), +} + +fn stored(path: &Path) -> io::Result { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Stored::Missing), + Err(error) => return Err(error), + }; + if !private(path)? { + return Ok(Stored::Exposed); + } + Ok(RelaySecret::decode(&bytes).map_or(Stored::Unreadable, Stored::Secret)) +} + +/// 비밀값을 읽는다. 없으면 만든다. +pub(crate) async fn load_or_create(path: &Path) -> io::Result { + for _ in 0..READ_ATTEMPTS { + match stored(path)? { + Stored::Secret(secret) => return Ok(secret), + Stored::Missing => match create(path) { + Ok(secret) => return Ok(secret), + // 다른 프로세스가 먼저 만들었다. 그쪽 것을 읽는다. + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + }, + Stored::Exposed => return replace(path), + Stored::Unreadable => tokio::time::sleep(READ_PAUSE).await, + } + } + // 1초가 지나도 읽을 수 없으면 쓰다 만 파일이 아니라 망가진 파일이다. + replace(path) +} + +fn create(path: &Path) -> io::Result { + if let Some(directory) = path.parent() { + create_private_directory(directory)?; + } + let secret = RelaySecret::generate(); + let mut file = private_file().write(true).create_new(true).open(path)?; + file.write_all(secret.encoded().as_bytes())?; + file.sync_all()?; + Ok(secret) +} + +/// 새 값을 옆에 다 쓴 뒤 이름을 바꿔 넣는다. 읽는 쪽은 옛 값이나 새 값 하나만 본다. +fn replace(path: &Path) -> io::Result { + if let Some(directory) = path.parent() { + create_private_directory(directory)?; + } + let secret = RelaySecret::generate(); + let staged = path.with_extension(format!("key.{}.tmp", std::process::id())); + let written = (|| { + let mut file = private_file() + .write(true) + .create(true) + .truncate(true) + .open(&staged)?; + file.write_all(secret.encoded().as_bytes())?; + file.sync_all()?; + std::fs::rename(&staged, path) + })(); + if written.is_err() { + let _ = std::fs::remove_file(&staged); + } + written.map(|()| secret) +} + +#[cfg(unix)] +fn private_file() -> OpenOptions { + use std::os::unix::fs::OpenOptionsExt; + let mut options = OpenOptions::new(); + options.mode(0o600); + options +} + +#[cfg(not(unix))] +fn private_file() -> OpenOptions { + OpenOptions::new() +} + +#[cfg(unix)] +fn create_private_directory(directory: &Path) -> io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(directory) +} + +#[cfg(not(unix))] +fn create_private_directory(directory: &Path) -> io::Result<()> { + std::fs::create_dir_all(directory) +} + +/// 그룹이나 다른 사용자가 읽을 수 없는지. +#[cfg(unix)] +fn private(path: &Path) -> io::Result { + use std::os::unix::fs::PermissionsExt; + Ok(std::fs::metadata(path)?.permissions().mode() & 0o077 == 0) +} + +#[cfg(not(unix))] +fn private(_path: &Path) -> io::Result { + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch() -> PathBuf { + std::env::temp_dir() + .join(format!( + "devup-relay-secret-{}-{:016x}", + std::process::id(), + rand::random::() + )) + .join(FILE_NAME) + } + + #[tokio::test] + async fn processes_that_race_to_create_it_end_up_with_one_secret() { + let path = scratch(); + let (first, second) = tokio::join!(load_or_create(&path), load_or_create(&path)); + let (first, second) = (first.unwrap(), second.unwrap()); + assert_eq!(first.0, second.0); + assert_eq!(load_or_create(&path).await.unwrap().0, first.0); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[tokio::test] + async fn a_damaged_file_is_replaced_rather_than_trusted() { + let path = scratch(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"not a secret").unwrap(); + let secret = load_or_create(&path).await.unwrap(); + assert_eq!(load_or_create(&path).await.unwrap().0, secret.0); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[cfg(unix)] + #[tokio::test] + async fn it_is_readable_by_its_owner_only_and_rotated_when_it_was_not() { + use std::os::unix::fs::PermissionsExt; + let path = scratch(); + let secret = load_or_create(&path).await.unwrap(); + let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode(); + assert_eq!(mode(&path) & 0o777, 0o600); + assert_eq!(mode(path.parent().unwrap()) & 0o777, 0o700); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + let rotated = load_or_create(&path).await.unwrap(); + assert_ne!( + rotated.0, secret.0, + "a secret others could read is not reused" + ); + assert_eq!(mode(&path) & 0o777, 0o600); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn a_proof_holds_only_for_its_side_and_nonces() { + let secret = RelaySecret::generate(); + let proof = secret.proof(Side::Relay, "h", "r"); + assert!(secret.verifies(Side::Relay, "h", "r", &proof)); + assert!(!secret.verifies(Side::Host, "h", "r", &proof)); + assert!(!secret.verifies(Side::Relay, "h2", "r", &proof)); + assert!(!RelaySecret::generate().verifies(Side::Relay, "h", "r", &proof)); + assert!(!secret.verifies(Side::Relay, "h", "r", "not base64 !")); + } + + #[test] + fn the_secret_never_prints() { + let secret = RelaySecret::generate(); + let hex = secret.encoded(); + assert!(!format!("{secret:?}").contains(hex.trim())); + } +} diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index b5a89a46..89205cd5 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -23,8 +23,9 @@ pub use collector::{ }; pub use bridge::{ - AttachedFile, BridgeFigmaClient, BridgeJob, BridgePathSnapshot, BridgeServed, BridgeServer, - BridgeState, DEFAULT_BRIDGE_PORT, FallbackUpstream, NodeRef, PluginContext, PreferredUpstream, + AttachedFile, BridgeFigmaClient, BridgeIssue, BridgeJob, BridgeOptions, BridgePathSnapshot, + BridgePeer, BridgeRole, 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_relay.rs b/crates/devup-mcp-figma/tests/bridge_relay.rs new file mode 100644 index 00000000..fd5fb6de --- /dev/null +++ b/crates/devup-mcp-figma/tests/bridge_relay.rs @@ -0,0 +1,529 @@ +//! 한 기기에 devup-mcp 가 여럿 떠 있어도 브리지를 함께 쓰는지 진짜 소켓으로 본다. +//! +//! 플러그인이 붙을 수 있는 포트는 하나다 — manifest 의 `allowedDomains` 가 +//! `ws://localhost:1993` 하나뿐이고, Figma 는 그 목록을 실행 중에 바꾸지 못한다. +//! 그래서 포트를 잡은 프로세스(호스트)가 플러그인을 받고, 잡지 못한 프로세스는 +//! 호스트를 통해 읽는다. 여기서는 임의 포트 하나에 서버 여럿과 가짜 플러그인 +//! 하나를 세운다. 1993 은 건드리지 않는다. + +use std::{ + collections::HashSet, + time::{Duration, Instant}, +}; + +use devup_mcp_figma::{ + BridgeFigmaClient, BridgeRole, BridgeServer, FigmaUpstream, PreferredUpstream, ReadToolCall, +}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::net::TcpStream; +use tokio_tungstenite::{ + MaybeTlsStream, WebSocketStream, connect_async, + tungstenite::{self, Message, client::IntoClientRequest, http::HeaderValue}, +}; + +const FILE_KEY: &str = "FileKey123"; + +/// 넉넉하게 잡는다. 느린 CI 러너에서도 흔들리지 않아야 하고, 이 안에 끝나지 +/// 않으면 기다림이 아니라 결함이다. +const DEADLINE: Duration = Duration::from_secs(30); + +type Socket = WebSocketStream>; + +/// 조건이 설 때까지 기다린다. 시간을 짐작해 재우지 않고, 관찰한 상태로 판정한다. +async fn eventually(what: &str, mut check: F) +where + F: FnMut() -> Fut, + Fut: Future, +{ + let deadline = Instant::now() + DEADLINE; + while Instant::now() < deadline { + if check().await { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("{what} did not happen within {DEADLINE:?}"); +} + +/// 포트를 잡지 못한 devup-mcp. 예전에는 여기서 `None` 이었고, 그 프로세스는 +/// 끝까지 브리지를 쓰지 못했다. +fn second_process_on(port: u16) -> BridgeServer { + BridgeServer::start(port).expect( + "a devup-mcp that finds the bridge port held must still get a bridge, through the \ + process that holds it", + ) +} + +/// 플러그인 흉내: 호스트에 붙어 자기 파일을 알린다. +async fn plugin(port: u16) -> Socket { + let (mut socket, _) = connect_async(format!("ws://127.0.0.1:{port}/plugin")) + .await + .expect("the holder accepts a plugin"); + socket + .send(Message::Text( + json!({ + "kind": "hello", "fileKey": FILE_KEY, "fileName": "Landing", + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [{ "id": "1:2", "name": "Hero", "type": "FRAME" }], + "selectionCount": 1, + }) + .to_string() + .into(), + )) + .await + .expect("hello is sent"); + socket +} + +async fn sees_the_plugin(server: &BridgeServer) { + let state = server.state(); + eventually("the plugin attached to the holder to show up here", || { + let state = state.clone(); + async move { state.connected_files().await == vec![FILE_KEY.to_owned()] } + }) + .await; +} + +async fn next_text(socket: &mut Socket) -> Value { + loop { + let message = tokio::time::timeout(DEADLINE, socket.next()) + .await + .expect("a message arrives in time") + .expect("the socket stays open") + .expect("the socket reads cleanly"); + if let Message::Text(text) = message { + return serde_json::from_str(&text).expect("messages are JSON"); + } + } +} + +async fn answer(plugin: &mut Socket, 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"); +} + +/// 상대가 연결을 닫을 때까지 읽는다. 닫지 않으면 실패다. +async fn assert_closed(socket: &mut Socket, why: &str) { + let closed = tokio::time::timeout(DEADLINE, async { + loop { + match socket.next().await { + None | Some(Err(_)) | Some(Ok(Message::Close(_))) => return, + Some(Ok(_)) => {} + } + } + }) + .await; + assert!(closed.is_ok(), "{why}"); +} + +/// 받아들일 수 없는 핸드셰이크의 HTTP 상태. 업그레이드되면 `None`. +async fn refused_status(request: tungstenite::handshake::client::Request) -> Option { + match connect_async(request).await { + Err(tungstenite::Error::Http(response)) => Some(response.status().as_u16()), + Err(error) => panic!("the holder answered with something other than HTTP: {error}"), + Ok(_) => None, + } +} + +/// 이 라운드가 고치는 결함 그 자체다. 포트를 잡지 못한 프로세스가 플러그인이 +/// 붙은 호스트를 통해 읽기를 끝내고, 그 답은 프로세스가 하나일 때와 같은 +/// 모양이다 — 디코더는 `content[].text` 안의 JSON 을 찾는다. +#[tokio::test] +async fn a_process_that_found_the_port_held_reads_through_the_holder() { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + let port = host.port(); + let relay = second_process_on(port); + let mut plugin = plugin(port).await; + sees_the_plugin(&relay).await; + + let client = BridgeFigmaClient::new(relay.state()).with_port(port); + assert!( + client + .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + ); + assert!(client.serves_without_credentials(FILE_KEY).await); + + let reading = tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + }); + let job = next_text(&mut plugin).await; + assert_eq!(job["kind"], "devup-job"); + assert_eq!(job["script"], "fastSnapshot"); + assert_eq!(job["params"]["nodeId"], "1:2"); + let payload = json!({ "fileKey": FILE_KEY, "nodes": [{ "id": "1:2" }] }); + answer(&mut plugin, &job, payload.clone()).await; + + let result = reading + .await + .expect("the read task finishes") + .expect("the read is served through the holder"); + let text = result.raw["content"][0]["text"] + .as_str() + .expect("the payload rides in content[].text, as it does with one process"); + assert_eq!(serde_json::from_str::(text).unwrap(), payload); + assert_eq!(result.raw["isError"], false); + let served = &result.raw["_meta"]["devup/bridge"]; + assert_eq!(served["port"], port); + assert_eq!(served["fileKey"], FILE_KEY); + assert_eq!(served["fileName"], "Landing"); + assert_eq!(served["pageName"], "Page 1"); +} + +/// 요청 번호는 프로세스마다 따로 센다. 여러 프로세스의 읽기가 한 호스트로 +/// 모여도 플러그인이 보는 번호는 겹치지 않아야 하고, 답은 물은 프로세스에게만 +/// 가야 한다. 도착 순서의 반대로 답해, 순서로 짝을 맞추는 구현이면 드러나게 한다. +#[tokio::test] +async fn reads_from_several_processes_at_once_each_get_their_own_answer() { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + let port = host.port(); + let first = second_process_on(port); + let second = second_process_on(port); + let mut plugin = plugin(port).await; + for server in [&host, &first, &second] { + sees_the_plugin(server).await; + } + + let mut readers = Vec::new(); + for (server, node) in [(&host, "1:1"), (&first, "2:2"), (&second, "3:3")] { + let client = BridgeFigmaClient::new(server.state()).with_port(port); + readers.push(( + node, + tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, node)) + .await + }), + )); + } + + let mut jobs = Vec::new(); + for _ in 0..readers.len() { + jobs.push(next_text(&mut plugin).await); + } + let ids: HashSet = jobs + .iter() + .map(|job| job["requestId"].as_str().expect("a request id").to_owned()) + .collect(); + assert_eq!( + ids.len(), + 3, + "every read reaches the plugin under its own id" + ); + + jobs.reverse(); + for job in &jobs { + let node = job["params"]["nodeId"].clone(); + answer( + &mut plugin, + job, + json!({ "fileKey": FILE_KEY, "nodes": [{ "id": node }] }), + ) + .await; + } + for (node, reader) in readers { + let result = reader + .await + .expect("the read task finishes") + .expect("every process gets an answer"); + let text = result.raw["content"][0]["text"].as_str().unwrap(); + let payload: Value = serde_json::from_str(text).unwrap(); + assert_eq!( + payload["nodes"][0]["id"], node, + "an answer went to a process that did not ask for it" + ); + } +} + +/// 중계 문은 같은 사용자의 devup-mcp 에게만 열린다. +/// +/// 브라우저 페이지는 `ws://localhost` 에 붙을 수 있고, 그 요청에는 늘 `Origin` +/// 이 붙는다. 로컬의 다른 프로그램은 `Origin` 없이 붙을 수 있지만 비밀값을 모르니 +/// 증명을 내지 못한다. 어느 쪽도 열린 Figma 문서를 읽는 통로가 되면 안 된다. +#[tokio::test] +async fn the_relay_door_turns_away_browsers_and_unproven_callers() { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + let port = host.port(); + let mut plugin = plugin(port).await; + sees_the_plugin(&host).await; + let relay_url = format!("ws://127.0.0.1:{port}/relay"); + + let mut from_a_page = relay_url.as_str().into_client_request().unwrap(); + from_a_page + .headers_mut() + .insert("Origin", HeaderValue::from_static("https://example.com")); + assert_eq!( + refused_status(from_a_page).await, + Some(403), + "a relay handshake carrying Origin comes from a browser page and is refused before \ + the upgrade" + ); + + // Skips the proof and asks for a read straight away. + let (mut stranger, _) = connect_async(relay_url.as_str()) + .await + .expect("a native client reaches the handshake"); + let hello = next_text(&mut stranger).await; + assert_eq!(hello["kind"], "relay-hello"); + assert!(hello["protocol"].is_u64(), "{hello}"); + stranger + .send(Message::Text( + json!({ + "kind": "relay-job", "id": 1, "fileKey": FILE_KEY, + "script": "fastSnapshot", "params": { "nodeId": "1:2" }, + }) + .to_string() + .into(), + )) + .await + .unwrap(); + assert_eq!(next_text(&mut stranger).await["kind"], "relay-refused"); + assert_closed(&mut stranger, "an unauthenticated relay is disconnected").await; + + // Offers a proof it could not have made. + let (mut forger, _) = connect_async(relay_url.as_str()).await.unwrap(); + let hello = next_text(&mut forger).await; + forger + .send(Message::Text( + json!({ + "kind": "relay-auth", "protocol": hello["protocol"], + "nonce": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "proof": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "peer": { "pid": 1, "version": "0.0.0", "buildId": null }, + }) + .to_string() + .into(), + )) + .await + .unwrap(); + let refused = next_text(&mut forger).await; + assert_eq!(refused["kind"], "relay-refused"); + assert_eq!(refused["reason"], "auth"); + assert_closed(&mut forger, "a relay with a wrong proof is disconnected").await; + + // Says nothing at all: it is not kept waiting on the door. + let (mut silent, _) = connect_async(relay_url.as_str()).await.unwrap(); + let _hello = next_text(&mut silent).await; + assert_closed( + &mut silent, + "a relay that never proves itself is disconnected", + ) + .await; + + // None of them got a read through. A forwarded job would have been queued + // on this socket long before now, so a short look is enough to see none. + assert!( + tokio::time::timeout(Duration::from_millis(300), plugin.next()) + .await + .is_err(), + "no job may reach the plugin from an unauthenticated relay" + ); +} + +/// 이어받기와 인계가 이중 호스트를 만들지 않는다는 근거: 잡힌 포트는 누구도 +/// 다시 bind 하지 못한다. std 는 Windows 에서 `SO_REUSEADDR` 를 켜지 않고 +/// (켜면 잡힌 포트를 가로챌 수 있다), Unix 의 `SO_REUSEADDR` 는 듣고 있는 +/// 소켓과 같은 주소를 허락하지 않는다. 세 OS 의 CI 가 이 테스트로 그것을 잰다. +#[tokio::test] +async fn a_held_port_is_never_bound_twice() { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + assert!( + std::net::TcpListener::bind(("127.0.0.1", host.port())).is_err(), + "a second listener on the held bridge port would be a second host" + ); +} + +/// `plugin/src/ui.ts` 처럼 소켓이 닫히면 다시 붙는 플러그인. 받은 작업마다 물은 +/// 노드를 담아 곧장 답한다. 테스트가 빨리 끝나도록 재시도 간격만 줄였다. +fn reconnecting_plugin(port: u16) { + tokio::spawn(async move { + loop { + if let Ok((mut socket, _)) = + connect_async(format!("ws://127.0.0.1:{port}/plugin")).await + { + let hello = json!({ + "kind": "hello", "fileKey": FILE_KEY, "fileName": "Landing", + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [], "selectionCount": 0, + }); + if socket + .send(Message::Text(hello.to_string().into())) + .await + .is_ok() + { + while let Some(Ok(message)) = socket.next().await { + let Message::Text(text) = message else { + continue; + }; + let job: Value = serde_json::from_str(&text).expect("a job is JSON"); + let data = json!({ + "fileKey": FILE_KEY, "nodes": [{ "id": job["params"]["nodeId"] }] + }); + let result = json!({ + "kind": "devup-result", "requestId": job["requestId"], "data": data + }); + if socket + .send(Message::Text(result.to_string().into())) + .await + .is_err() + { + break; + } + } + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); +} + +async fn role_of(client: &BridgeFigmaClient) -> (BridgeRole, bool) { + let snapshot = client + .bridge_path_snapshot() + .await + .expect("a bridge client always reports its path"); + (snapshot.role, snapshot.available()) +} + +/// 클라이언트 세션이 끝나면 호스트는 사라진다. 흔한 일이다. 남은 프로세스 가운데 +/// 정확히 하나가 포트를 이어받고, 다른 하나는 그를 통해 읽으며, 플러그인이 다시 +/// 붙은 뒤에는 둘 다 읽는다. 사람이 무엇을 죽이거나 다시 띄울 필요가 없다. +#[tokio::test] +async fn when_the_holder_goes_away_exactly_one_remaining_process_takes_the_port_over() { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + let port = host.port(); + let first = second_process_on(port); + let second = second_process_on(port); + reconnecting_plugin(port); + for server in [&host, &first, &second] { + sees_the_plugin(server).await; + } + + host.shutdown(); + + let clients = + [&first, &second].map(|server| BridgeFigmaClient::new(server.state()).with_port(port)); + eventually( + "one remaining process to hold the port and the other to read through it, both \ + seeing the plugin again", + || { + let clients = &clients; + async move { + let roles = [role_of(&clients[0]).await, role_of(&clients[1]).await]; + let hosts = roles.iter().filter(|(role, _)| *role == BridgeRole::Host); + let relays = roles.iter().filter(|(role, _)| *role == BridgeRole::Relay); + hosts.count() == 1 + && relays.count() == 1 + && roles.iter().all(|(_, available)| *available) + } + }, + ) + .await; + assert!( + std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), + "the port is held again" + ); + for (client, node) in clients.iter().zip(["4:4", "5:5"]) { + let result = client + .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, node)) + .await + .expect("reads resume through whichever process holds the port now"); + let text = result.raw["content"][0]["text"].as_str().unwrap(); + let payload: Value = serde_json::from_str(text).unwrap(); + assert_eq!(payload["nodes"][0]["id"], node); + } +} + +/// 호스트가 떠날 때 진행 중이던 읽기는 90초를 기다리지 않고 곧장 실패한다. 이유를 +/// 말하고, 다시 부르면 된다는 것도 말한다. +#[tokio::test] +async fn a_read_in_flight_when_the_holder_leaves_fails_at_once() { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + let port = host.port(); + let relay = second_process_on(port); + let mut plugin = plugin(port).await; + sees_the_plugin(&relay).await; + + let client = BridgeFigmaClient::new(relay.state()).with_port(port); + let reading = tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + }); + let _job = next_text(&mut plugin).await; + host.shutdown(); + + let error = tokio::time::timeout(DEADLINE, reading) + .await + .expect("the read ends without waiting out its 90 seconds") + .expect("the read task finishes") + .expect_err("the process that held the port is gone"); + assert!(error.message.contains("went away"), "{}", error.message); +} + +/// 읽기를 맡긴 프로세스가 사라지면 호스트는 그 읽기를 거둔다. 플러그인에는 취소를 +/// 보낼 길이 없어 작업은 끝까지 돌지만, 늦게 온 답은 누구에게도 가지 않고 대기표도 +/// 남지 않는다. +#[tokio::test] +async fn a_read_is_cleared_from_the_holder_when_the_process_that_asked_goes_away() { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + let port = host.port(); + let relay = second_process_on(port); + let mut plugin = plugin(port).await; + sees_the_plugin(&relay).await; + + let client = BridgeFigmaClient::new(relay.state()).with_port(port); + let reading = tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + }); + let job = next_text(&mut plugin).await; + let state = host.state(); + assert_eq!(state.pending_reads(), 1, "the holder waits for the plugin"); + + relay.shutdown(); + let error = tokio::time::timeout(DEADLINE, reading) + .await + .expect("the read ends promptly in the process that is leaving") + .expect("the read task finishes"); + assert!(error.is_err()); + eventually("the holder to drop the read nobody waits for", || { + let state = state.clone(); + async move { state.pending_reads() == 0 } + }) + .await; + + answer( + &mut plugin, + &job, + json!({ "fileKey": FILE_KEY, "nodes": [] }), + ) + .await; + // The late answer reached the holder and was dropped there; the plugin is + // still served normally afterwards. + let client = BridgeFigmaClient::new(host.state()).with_port(port); + let reading = tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "2:2")) + .await + }); + let job = next_text(&mut plugin).await; + answer( + &mut plugin, + &job, + json!({ "fileKey": FILE_KEY, "nodes": [{ "id": "2:2" }] }), + ) + .await; + reading.await.unwrap().expect("the holder keeps serving"); + assert_eq!(state.pending_reads(), 0); +} diff --git a/crates/devup-mcp/Cargo.toml b/crates/devup-mcp/Cargo.toml index 1a419373..54b00180 100644 --- a/crates/devup-mcp/Cargo.toml +++ b/crates/devup-mcp/Cargo.toml @@ -45,5 +45,5 @@ dunce.workspace = true 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" +tokio-tungstenite.workspace = true +futures-util.workspace = true diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index 7977911f..28ca3de8 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -36,8 +36,8 @@ //! Naming it as a path sent agents to a dead end, so it is named nowhere. use devup_mcp_figma::{ - AttachedFile, AuthStatus, BridgePathSnapshot, ClientCredentialSource, DEFAULT_CLIENT_NAME, - DirectPathSnapshot, TokenState, + AttachedFile, AuthStatus, BridgeIssue, BridgePathSnapshot, BridgePeer, BridgeRole, + ClientCredentialSource, DEFAULT_CLIENT_NAME, DirectPathSnapshot, TokenState, }; use serde::Serialize; use serde_json::{Value, json}; @@ -70,15 +70,25 @@ pub fn connection_report( direct: &DirectPathSnapshot, bridge: Option<&BridgePathSnapshot>, ) -> Value { - let attached = bridge.map_or(&[][..], |bridge| bridge.attached_files.as_slice()); + // Usable means a read can be sent now: a plugin is visible and there is a + // way to it - this process holds the port, or reads through the one that + // does. A relay whose host went away sees no plugin until it reconnects. + let usable = bridge.filter(|bridge| bridge.available()); + let attached = usable.map_or(&[][..], |bridge| bridge.attached_files.as_slice()); let direct_available = status == AuthStatus::Connected; - let active_path = if !attached.is_empty() { + let active_path = if usable.is_some() { Some(ActivePath::Bridge) } else if direct_available { Some(ActivePath::Direct) } else { None }; + let bridge_blocked = bridge.is_some_and(|bridge| { + matches!( + bridge.role, + BridgeRole::Connecting | BridgeRole::Unavailable + ) + }); json!({ "connected": active_path.is_some(), "status": if active_path.is_some() { "connected" } else { "disconnected" }, @@ -86,7 +96,7 @@ pub fn connection_report( "preferredPath": "bridge", "paths": { "bridge": bridge_path(bridge), - "direct": direct_path(status, direct, !attached.is_empty()), + "direct": direct_path(status, direct, usable.is_some()), }, "nextAction": match active_path { Some(ActivePath::Bridge) => bridge_next_action(attached), @@ -94,10 +104,14 @@ pub fn connection_report( "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.", + "note": if bridge_blocked { + "Only the metered direct path is open, so an export needs the frame's Figma link. The Devup Bridge cannot be used from this process right now; paths.bridge.reason says why and what would change it." + } else { + "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(), + bridge, false, &json!({ "tool": "devup_figma_export", "arguments": { "outputs": ["tsx"] } }), ), @@ -172,17 +186,23 @@ pub(super) fn several_plugins(attached: &[AttachedFile], retry: &Value) -> Value /// 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 { +/// +/// The bridge option is whatever would open the bridge for *this* process. +/// Running the plugin is that only while a way to the plugin exists - this +/// process holds the port, or reads through the one that does. While the port +/// is changing hands it is waiting a moment, and while something else holds +/// the port it is the repair that names that holder. +pub(super) fn ways_to_open_a_path( + bridge: Option<&BridgePathSnapshot>, + 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, - })); + if let Some(bridge) = bridge { + options.push(bridge_option(bridge, retry)); } options.push(if direct_available { let mut call = with_url; @@ -197,15 +217,117 @@ pub(super) fn ways_to_open_a_path(listening: bool, direct_available: bool, retry }) }); 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." + "how": match bridge.map(|bridge| bridge.role) { + Some(BridgeRole::Host | BridgeRole::Relay) => { + "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." + } + Some(BridgeRole::Connecting) => { + "The Devup Bridge port is changing hands (see paths.bridge.reason): call status again in a few seconds, or use the metered direct path with the frame's Figma link." + } + Some(BridgeRole::Unavailable) => { + "This devup-mcp cannot use the Devup Bridge right now (see paths.bridge.reason). The first option is what would change that; the metered direct path needs the frame's Figma link." + } + None => { + "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, }) } +const RUN_THE_PLUGIN: &str = "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."; + +/// How long the plugin waits before it connects again after its socket closed +/// (`RETRY_MS` in plugin/src/ui.ts), said where a reader waits on it. +const PLUGIN_REATTACH: &str = "about 2 seconds"; + +fn status_again() -> Value { + json!({ "tool": "devup_figma_auth", "arguments": { "action": "status" } }) +} + +/// What would open the bridge for this process. +fn bridge_option(bridge: &BridgePathSnapshot, retry: &Value) -> Value { + let port = port_text(bridge); + match bridge.role { + BridgeRole::Host | BridgeRole::Relay if bridge.handover_from.is_some() => json!({ + "path": "bridge", + "action": format!("Wait a moment: the bridge port just changed hands, and the plugin window that was attached reconnects on its own within {PLUGIN_REATTACH}. If no plugin appears, run Devup Bridge again on the file."), + "then": status_again(), + }), + BridgeRole::Host | BridgeRole::Relay => json!({ + "path": "bridge", + "action": RUN_THE_PLUGIN, + "then": retry, + }), + BridgeRole::Connecting => json!({ + "path": "bridge", + "action": format!("Wait a few seconds: the devup-mcp that held port {port} went away, this process is taking the port over or reconnecting, and the plugin reconnects on its own within {PLUGIN_REATTACH} of the new holder appearing."), + "then": status_again(), + }), + BridgeRole::Unavailable => json!({ + "path": "bridge", + "action": repair(bridge), + "then": status_again(), + }), + } +} + +fn port_text(bridge: &BridgePathSnapshot) -> String { + bridge + .port + .map_or_else(|| "".to_owned(), |port| port.to_string()) +} + +/// How to find what holds the port, on each platform. +fn find_the_holder(port: &str) -> String { + format!( + "find it with `netstat -ano | findstr :{port}` on Windows or `lsof -nP -iTCP:{port} -sTCP:LISTEN` on macOS and Linux" + ) +} + +/// Names a devup-mcp the way a person finds it in a process list. +fn describe_peer(peer: &BridgePeer) -> String { + match &peer.build_id { + Some(build) => format!("pid {}, version {}, build {build}", peer.pid, peer.version), + None => format!("pid {}, version {}", peer.pid, peer.version), + } +} + +fn holder_text(bridge: &BridgePathSnapshot) -> String { + bridge + .host + .as_ref() + .map_or_else(String::new, |host| format!(" ({})", describe_peer(host))) +} + +/// The one step that would let this process use the bridge again. +fn repair(bridge: &BridgePathSnapshot) -> String { + let port = port_text(bridge); + let holder = holder_text(bridge); + match &bridge.issue { + Some(BridgeIssue::LegacyHost) => format!( + "The devup-mcp holding port {port} predates bridge sharing, so this process cannot read through it: restart (or update) the MCP client session that started it - {}. Once it exits, this process takes the port over by itself and the plugin reconnects to it.", + find_the_holder(&port) + ), + Some(BridgeIssue::ForeignProgram { .. }) => format!( + "Stop the program holding port {port} - {}. The plugin's port is fixed by its manifest; once the port is free this process takes it over by itself.", + find_the_holder(&port) + ), + Some(BridgeIssue::IncompatibleProtocol { .. }) => format!( + "The devup-mcp holding port {port}{holder} and this one are different releases that cannot relay to each other: restart the MCP client session whose devup-mcp is older so both run the same release." + ), + Some(BridgeIssue::AuthenticationFailed { .. }) => format!( + "Run every devup-mcp on this machine as the same user with the same home directory (they prove themselves to each other with a per-user secret kept there), or restart the MCP client session holding port {port}{holder}." + ), + Some(BridgeIssue::SecretUnavailable { .. }) => format!( + "Make the per-user relay secret readable and writable for this user (see paths.bridge.reason), or restart the MCP client session holding port {port}{holder} so this process can take the port over." + ), + Some(BridgeIssue::BindFailed { .. }) | None => format!( + "Free port {port}, or change it in all three places the plugin's README names (the manifest's allowedDomains, src/code.ts and DEVUP_FIGMA_BRIDGE_PORT)." + ), + } +} + /// What `credentialSource` counts, said in the response rather than only in /// the README. /// @@ -289,41 +411,135 @@ fn direct_path(status: AuthStatus, direct: &DirectPathSnapshot, bridge_available /// listener never bound. Naming only the metered path made the metered path /// the only answer. /// -/// Three states, and they are genuinely different repairs. `listening: false` -/// means this process opened no bridge at all — the port was taken or it was -/// switched off — and no amount of running the plugin will help until that is -/// fixed. `listening: true` with nothing attached means the door is open and -/// nobody walked through: run the plugin on the file. Files attached is the -/// working state, and it names them, because a plugin open on the wrong file -/// looks identical from the outside. +/// The states are genuinely different repairs, so each says its own. +/// +/// Only one devup-mcp on a machine can hold the port the plugin knows, and +/// several run at once as a matter of course - one per MCP client session. +/// The one holding it is the `host`; the others are `relay`s that read through +/// it, and they see the same `attachedFiles`. A `relay` whose host just left +/// is `connecting` while it takes the port over or finds whoever did. And +/// `unavailable` names what holds the port when that cannot be read through: +/// a devup-mcp from before sharing, another program, or a devup-mcp that +/// cannot prove it runs for the same user - each with the step that would +/// change it, rather than the generic "not listening" that left a session with +/// no remedy but killing another session's process. +/// +/// `listening` keeps its meaning: this process holds the port. `available` is +/// the test for sending a read now: a plugin is visible and there is a way to +/// it. The bridge needs no credential of any kind, so there is nothing else +/// for it to be waiting on. fn bridge_path(bridge: Option<&BridgePathSnapshot>) -> Value { let Some(bridge) = bridge else { return json!({ "available": false, + "role": "off", "listening": false, "port": null, + "host": null, "attachedFiles": [], - "reason": "This process is not listening for the bridge plugin. Either DEVUP_FIGMA_BRIDGE_PORT is off/0, or the port was already taken — another devup-mcp on this machine holds it, which is normal when several MCP clients run at once. Only that process can serve the bridge; this one can use the metered direct path only.", + "reason": "This process is not using the bridge plugin: DEVUP_FIGMA_BRIDGE_PORT is off or 0, or is not a port number. Unset it, or set it to the port in the plugin's manifest, to use the bridge; this process can use the metered direct path only.", }); }; - let attached = !bridge.attached_files.is_empty(); - json!({ - // Attached is the whole test. The bridge needs no credential of any - // kind, so there is nothing else for it to be waiting on. - "available": attached, - "listening": true, + let mut path = json!({ + "available": bridge.available(), + "role": role_name(bridge.role), + "listening": bridge.role == BridgeRole::Host, "port": bridge.port, + "host": bridge.host.as_ref().map(|host| peer(host, bridge.role == BridgeRole::Host)), "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. 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 { + "attachedFilesNote": "The files the attached plugins have open, with the page in view and what is selected on it - as the devup-mcp holding the bridge port sees them, so every devup-mcp on this machine shows the same list. 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": bridge_reason(bridge), + }); + if let Some(issue) = &bridge.issue { + path["issue"] = json!(issue.code()); + } + if let Some(previous) = &bridge.handover_from { + path["handoverFrom"] = peer(previous, false); + } + path +} + +fn role_name(role: BridgeRole) -> &'static str { + match role { + BridgeRole::Host => "host", + BridgeRole::Relay => "relay", + BridgeRole::Connecting => "connecting", + BridgeRole::Unavailable => "unavailable", + } +} + +fn peer(peer: &BridgePeer, this_process: bool) -> Value { + json!({ + "pid": peer.pid, + "version": peer.version, + "buildId": peer.build_id, + "thisProcess": this_process, + }) +} + +const SERVED_WITH_ONE_PLUGIN: &str = "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."; + +fn bridge_reason(bridge: &BridgePathSnapshot) -> String { + let port = port_text(bridge); + let holder = holder_text(bridge); + let attached = !bridge.attached_files.is_empty(); + let left = bridge + .handover_from + .as_ref() + .map(|previous| format!(" (pid {})", previous.pid)) + .unwrap_or_default(); + match (bridge.role, &bridge.issue) { + (BridgeRole::Host, _) if attached => format!( + "A plugin is attached to this process, which holds the bridge port {port}. Reads for the files listed in attachedFiles are served through it, spending no Figma allowance and needing no login; other devup-mcp processes on this machine read through this one. {SERVED_WITH_ONE_PLUGIN}" + ), + (BridgeRole::Relay, _) if attached => format!( + "Another devup-mcp on this machine{holder} holds the bridge port {port} and the plugins attach to it; this process reads through it. Reads for the files listed in attachedFiles spend no Figma allowance and need no login. {SERVED_WITH_ONE_PLUGIN}" + ), + (BridgeRole::Host, _) if bridge.handover_from.is_some() => format!( + "This process took the bridge port {port} over moments ago from the devup-mcp that held it{left}, which exited. The plugin window that was attached reconnects on its own within {PLUGIN_REATTACH}; call status again shortly. If no plugin appears, run Devup Bridge again on the file." + ), + (BridgeRole::Relay, _) if bridge.handover_from.is_some() => format!( + "The devup-mcp that held the bridge port {port}{left} exited, and another{holder} took the port over; this process now reads through it. The plugin window that was attached reconnects on its own within {PLUGIN_REATTACH}; call status again shortly. If no plugin appears, run Devup Bridge again on the file." + ), + (BridgeRole::Host, _) => format!( + "The bridge is listening on 127.0.0.1:{port} 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." + ), + (BridgeRole::Relay, _) => format!( + "Another devup-mcp on this machine{holder} holds the bridge port {port}, and this process reads through it, but no plugin is attached there, 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); it attaches to that process, and every devup-mcp on this machine can then read through it." + ), + (BridgeRole::Connecting, _) if bridge.handover_from.is_some() => format!( + "The devup-mcp that held the bridge port {port}{left} went away. This process is taking the port over, or reconnecting to whichever process did; the plugin reconnects on its own within {PLUGIN_REATTACH} of the new holder appearing. Nothing can be read through the bridge until then - call status again in a few seconds." + ), + (BridgeRole::Connecting, _) => format!( + "This process is still finding out which devup-mcp holds the bridge port {port}. Call status again in a moment." + ), + (BridgeRole::Unavailable, Some(BridgeIssue::LegacyHost)) => format!( + "Port {port} is held by a devup-mcp built before the bridge could be shared: it serves the plugin on /plugin but has no relay endpoint, so only that process can use the plugin and this one cannot read through it. It cannot say which process it is; {}. To use the bridge here, restart (or update) the MCP client session that started it. This process keeps checking, and takes the port over by itself once that process exits.", + find_the_holder(&port) + ), + (BridgeRole::Unavailable, Some(BridgeIssue::ForeignProgram { detail })) => format!( + "Port {port} is held by a program that is not a devup-mcp ({detail}), so the plugin cannot reach any devup-mcp on this machine. Stop that program - {}. This process keeps checking, and takes the port over by itself once it is free.", + find_the_holder(&port) + ), + (BridgeRole::Unavailable, Some(BridgeIssue::IncompatibleProtocol { theirs, ours })) => { 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.", - bridge.port.map_or_else(|| "".to_owned(), |port| port.to_string()), + "Port {port} is held by a devup-mcp{holder} that speaks bridge relay protocol {}, and this one speaks {ours}. Rather than risk a wrong answer, this process does not read through it. Restart the MCP client session whose devup-mcp is older so both run the same release.", + theirs.map_or_else(|| "".to_owned(), |version| version.to_string()) ) - }, - }) + } + (BridgeRole::Unavailable, Some(BridgeIssue::AuthenticationFailed { detail })) => format!( + "Port {port} is held by a process{holder} that could not be verified as this user's devup-mcp ({detail}). Relaying needs both processes to read the same per-user secret file, so a devup-mcp run as another user or with another home directory cannot share the bridge, and this process does not read through it." + ), + (BridgeRole::Unavailable, Some(BridgeIssue::SecretUnavailable { detail })) => format!( + "Another process{holder} holds the bridge port {port}, but this process could not read or create the per-user relay secret it proves itself with ({detail}), so it cannot read through it." + ), + (BridgeRole::Unavailable, Some(BridgeIssue::BindFailed { detail })) => format!( + "This process cannot open the bridge port {port} ({detail}), and nothing is listening on it." + ), + (BridgeRole::Unavailable, None) => { + format!("This process cannot use the bridge port {port} right now.") + } + } } /// One attached plugin as the caller reads it. The key a read is routed by @@ -504,10 +720,7 @@ mod tests { let idle = doctor_report( AuthStatus::Disconnected, absent_direct_snapshot(), - Some(BridgePathSnapshot { - port: Some(1993), - attached_files: vec![], - }), + Some(bridge_with(vec![])), ) .await; assert_eq!(idle["paths"]["bridge"]["listening"], true); @@ -560,13 +773,98 @@ mod tests { } } + fn this_process() -> BridgePeer { + BridgePeer { + pid: 4242, + version: "0.12.0".to_owned(), + build_id: Some("abc1234".to_owned()), + } + } + + /// This process holds the port. fn bridge_with(attached_files: Vec) -> BridgePathSnapshot { BridgePathSnapshot { port: Some(1993), attached_files, + role: BridgeRole::Host, + host: Some(this_process()), + issue: None, + handover_from: None, } } + /// The states a process that did not get the port has to report as they + /// are: reading through the holder, finding out who took over, or unable + /// to use the bridge - and none of them may be answered with "log in" + /// ahead of the step that repairs the bridge. + #[test] + fn a_process_without_the_port_says_what_it_is_doing_about_it() { + let holder = BridgePeer { + pid: 32536, + ..this_process() + }; + let relay = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&BridgePathSnapshot { + role: BridgeRole::Relay, + host: Some(holder.clone()), + ..bridge_with(vec![attached(None, Some(1))]) + }), + ); + let bridge = &relay["paths"]["bridge"]; + assert_eq!(relay["activePath"], "bridge"); + assert_eq!(bridge["role"], "relay"); + assert_eq!(bridge["available"], true); + assert_eq!(bridge["listening"], false); + assert_eq!(bridge["host"]["pid"], 32536); + assert_eq!(bridge["host"]["buildId"], "abc1234"); + assert_eq!(bridge["host"]["thisProcess"], false); + assert!(!relay.to_string().contains("disconnected"), "{relay}"); + assert!(relay["nextAction"]["arguments"].get("url").is_none()); + + let handing_over = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&BridgePathSnapshot { + role: BridgeRole::Connecting, + host: None, + handover_from: Some(holder.clone()), + ..bridge_with(vec![]) + }), + ); + let bridge = &handing_over["paths"]["bridge"]; + assert_eq!(bridge["role"], "connecting"); + assert_eq!(bridge["available"], false); + assert_eq!(bridge["handoverFrom"]["pid"], 32536); + assert!(bridge["reason"].as_str().unwrap().contains("32536")); + let options = handing_over["nextAction"]["options"].as_array().unwrap(); + assert_eq!(options[0]["path"], "bridge"); + assert_eq!(options[0]["then"]["arguments"]["action"], "status"); + assert_eq!(options[1]["path"], "direct"); + + let legacy = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&BridgePathSnapshot { + role: BridgeRole::Unavailable, + host: None, + issue: Some(BridgeIssue::LegacyHost), + ..bridge_with(vec![]) + }), + ); + let bridge = &legacy["paths"]["bridge"]; + assert_eq!(bridge["issue"], "legacy-host"); + assert!(bridge["host"].is_null()); + assert!(bridge["reason"].as_str().unwrap().contains("restart")); + let options = legacy["nextAction"]["options"].as_array().unwrap(); + assert!( + options[0]["action"].as_str().unwrap().contains(":1993"), + "the repair says how to find the holder: {}", + options[0] + ); + } + /// 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. diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index d47d9ba3..2be8416a 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -42,11 +42,11 @@ use serde_json::{Value, json}; use devup_mcp_devup_ui::theme::ThemeScope; use devup_mcp_figma::{ - 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, + AuthStatus, BRIDGE_CURRENT_KEY, BridgeFigmaClient, BridgeOptions, BridgeRole, 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, @@ -275,10 +275,15 @@ impl Services { ); } let remote = RemoteFigmaClient::new(oauth.clone()); - // A script read goes to the bridge when one is listening. With no - // plugin attached every call falls straight through to the remote + // A script read goes to the bridge when a plugin is reachable - through + // this process's own port, or through the devup-mcp that holds it. With + // no plugin attached every call falls straight through to the remote // path, so opening the door costs nothing when nobody walks through. - let upstream: Arc = match BridgeServer::from_env() { + let bridge = BridgeServer::from_env_with(BridgeOptions { + build_id: Some(crate::build_id().to_owned()), + secret_path: None, + }); + let upstream: Arc = match bridge { Some(bridge) => Arc::new(FallbackUpstream::new( BridgeFigmaClient::new(bridge.state()).with_port(bridge.port()), remote, @@ -762,13 +767,25 @@ impl DevupServer { json!({ "stage": "target-resolution", "bridge": { - "listening": bridge.is_some(), + "listening": bridge + .as_ref() + .is_some_and(|bridge| bridge.role == BridgeRole::Host), + "role": bridge.as_ref().map(|bridge| match bridge.role { + BridgeRole::Host => "host", + BridgeRole::Relay => "relay", + BridgeRole::Connecting => "connecting", + BridgeRole::Unavailable => "unavailable", + }), + "issue": bridge + .as_ref() + .and_then(|bridge| bridge.issue.as_ref()) + .map(|issue| issue.code()), "port": bridge.as_ref().and_then(|bridge| bridge.port), "attachedFiles": [], }, "directAvailable": direct_available, "nextAction": diagnostics::ways_to_open_a_path( - bridge.is_some(), + bridge.as_ref(), direct_available, retry, ), @@ -1207,7 +1224,7 @@ impl DevupServer { .upstream .bridge_path_snapshot() .await - .is_some_and(|bridge| !bridge.attached_files.is_empty()); + .is_some_and(|bridge| bridge.available()); 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 { diff --git a/crates/devup-mcp/tests/bridge_handover.rs b/crates/devup-mcp/tests/bridge_handover.rs new file mode 100644 index 00000000..56616bb5 --- /dev/null +++ b/crates/devup-mcp/tests/bridge_handover.rs @@ -0,0 +1,387 @@ +//! Real devup-mcp processes sharing one bridge port, and passing it on. +//! +//! Three stdio servers built from this target run on a port picked for the +//! test - never 1993, which belongs to whatever sessions this machine runs - +//! with their home directory pointed at a scratch one, so whatever they keep +//! per user is the test's own. A stand-in plugin attaches the way the real one +//! does: after its socket closes it waits two seconds and connects again +//! (`RETRY_MS` in plugin/src/ui.ts). +//! +//! Only url-less reads are made. They go to the attached plugin or nowhere, so +//! nothing here can reach the metered direct path or need a credential store. + +use std::{ + path::{Path, PathBuf}, + process::Stdio, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + process::{Child, ChildStdin, ChildStdout, Command}, + time::timeout, +}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +/// Generous on purpose: a slow runner must not decide the outcome. +const DEADLINE: Duration = Duration::from_secs(30); + +/// The real plugin's reconnect interval (plugin/src/ui.ts `RETRY_MS`). +const PLUGIN_RETRY: Duration = Duration::from_secs(2); + +struct Server { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, +} + +impl Server { + async fn spawn(port: u16, home: &Path) -> anyhow::Result { + let mut child = Command::new(env!("CARGO_BIN_EXE_devup-mcp")) + .current_dir(home) + .env("DEVUP_FIGMA_BRIDGE_PORT", port.to_string()) + .env("HOME", home) + .env("USERPROFILE", home) + .env("DEVUP_MCP_NO_UPDATE_CHECK", "1") + .env("DEVUP_MCP_SKILLS_OFFLINE", "1") + // Every read is paced to Figma's metered allowance, bridge reads + // included, which would make this test wait out a minute between + // exports. It measures relaying, not pacing. + .env("DEVUP_FIGMA_CALLS_PER_MINUTE", "1000") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn()?; + let stdin = child.stdin.take().expect("piped stdin"); + let stdout = BufReader::new(child.stdout.take().expect("piped stdout")); + let mut server = Self { + child, + stdin, + stdout, + next_id: 1, + }; + server + .request( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": "devup-mcp-bridge-handover", "version": "1" } + }), + ) + .await?; + server + .send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })) + .await?; + Ok(server) + } + + async fn send(&mut self, value: Value) -> anyhow::Result<()> { + self.stdin.write_all(value.to_string().as_bytes()).await?; + self.stdin.write_all(b"\n").await?; + self.stdin.flush().await?; + Ok(()) + } + + async fn request(&mut self, method: &str, params: Value) -> anyhow::Result { + let id = self.next_id; + self.next_id += 1; + self.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) + .await?; + loop { + let mut line = String::new(); + timeout(DEADLINE, self.stdout.read_line(&mut line)).await??; + anyhow::ensure!(!line.is_empty(), "the server closed before answering {id}"); + let value: Value = serde_json::from_str(&line)?; + if value["id"] == id { + return Ok(value); + } + } + } + + /// Calls a tool and returns `(isError, structuredContent)`, following an + /// export job to completion as a client does. + async fn tool(&mut self, name: &str, arguments: Value) -> anyhow::Result<(bool, Value)> { + let mut response = self + .request( + "tools/call", + json!({ "name": name, "arguments": arguments }), + ) + .await?; + for _ in 0..500 { + let content = &response["result"]["structuredContent"]; + let Some(job) = content["exportJob"]["jobId"] + .as_str() + .filter(|_| content["exportJob"]["state"] == "running") + .map(str::to_owned) + else { + break; + }; + response = self + .request( + "tools/call", + json!({ "name": "devup_figma_export", "arguments": { "jobId": job } }), + ) + .await?; + } + let result = &response["result"]; + Ok(( + result["isError"] == true, + result["structuredContent"].clone(), + )) + } + + /// A url-less search, answered only if this process reads the attached + /// plugin - through its own port or through the process holding it. + async fn searches_through_the_bridge(&mut self) -> anyhow::Result { + let (failed, output) = self + .tool("devup_figma_search", json!({ "query": "syntheticframe" })) + .await?; + Ok(!failed + && output["source"]["kind"] == "bridge" + && output["matches"][0]["nodeId"] == "1:2") + } + + async fn exports_through_the_bridge(&mut self) -> anyhow::Result { + let (failed, output) = self + .tool("devup_figma_export", json!({ "outputs": ["tsx"] })) + .await?; + anyhow::ensure!(!failed, "{output}"); + Ok(output) + } +} + +fn scratch_home() -> PathBuf { + let home = std::env::temp_dir().join(format!( + "devup-bridge-handover-{}-{:016x}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(&home).expect("a scratch home"); + home +} + +/// A port nothing listens on right now, and never the plugin's own. +fn free_port() -> u16 { + loop { + let port = std::net::TcpListener::bind(("127.0.0.1", 0)) + .and_then(|listener| listener.local_addr()) + .expect("an ephemeral port") + .port(); + if port != 1993 { + return port; + } + } +} + +async fn listening(port: u16) -> bool { + tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_ok() +} + +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": {} + }) +} + +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"), + } +} + +/// The plugin as plugin/src/ui.ts behaves: connect, say hello, answer jobs, +/// and after the socket closes wait `RETRY_MS` before connecting again. Every +/// time a connection opens is recorded. +fn stand_in_plugin(port: u16) -> Arc>> { + let attached = Arc::new(Mutex::new(Vec::new())); + let log = attached.clone(); + tokio::spawn(async move { + loop { + if let Ok((mut socket, _)) = + connect_async(format!("ws://localhost:{port}/plugin")).await + { + log.lock().unwrap().push(Instant::now()); + let hello = json!({ + "kind": "hello", "fileKey": null, "fileName": "Landing", + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [{ "id": "1:2", "name": "Synthetic Frame", "type": "FRAME" }], + "selectionCount": 1, + }); + if socket + .send(Message::Text(hello.to_string().into())) + .await + .is_ok() + { + while let Some(Ok(message)) = socket.next().await { + let Message::Text(text) = message else { + continue; + }; + let Ok(job) = serde_json::from_str::(&text) else { + continue; + }; + 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 + }), + }; + if socket + .send(Message::Text(answer.to_string().into())) + .await + .is_err() + { + break; + } + } + } + } + tokio::time::sleep(PLUGIN_RETRY).await; + } + }); + attached +} + +/// How long until the port accepts connections again. +async fn until_listening(port: u16) -> anyhow::Result { + let started = Instant::now(); + while started.elapsed() < DEADLINE { + if listening(port).await { + return Ok(started.elapsed()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + anyhow::bail!("nothing listened on port {port} within {DEADLINE:?}") +} + +/// How long until every one of `servers` answers a url-less read through the +/// bridge. +async fn until_all_read_through_the_bridge( + what: &str, + servers: &mut [&mut Server], +) -> anyhow::Result { + let started = Instant::now(); + 'retry: while started.elapsed() < DEADLINE { + for server in servers.iter_mut() { + if !server.searches_through_the_bridge().await? { + tokio::time::sleep(Duration::from_millis(50)).await; + continue 'retry; + } + } + return Ok(started.elapsed()); + } + anyhow::bail!("{what} did not read through the bridge within {DEADLINE:?}") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn every_process_reads_through_the_bridge_and_the_port_passes_on_when_its_holder_exits() +-> anyhow::Result<()> { + let home = scratch_home(); + let port = free_port(); + + let mut first = Server::spawn(port, &home).await?; + until_listening(port).await?; + let mut second = Server::spawn(port, &home).await?; + let mut third = Server::spawn(port, &home).await?; + let plugin = stand_in_plugin(port); + + for (name, server) in [ + ("the port's holder", &mut first), + ("the second process", &mut second), + ("the third process", &mut third), + ] { + until_all_read_through_the_bridge(name, &mut [&mut *server]).await?; + let output = server.exports_through_the_bridge().await?; + assert_eq!(output["source"]["kind"], "bridge", "{name}: {output}"); + assert_eq!(output["source"]["bridgePort"], port, "{name}: {output}"); + assert!( + output["tsx"] + .as_str() + .is_some_and(|tsx| tsx.contains("SyntheticFrame")), + "{name}: {output}" + ); + } + + // The session that held the port ends. + let attached_before = plugin.lock().unwrap().len(); + let ended = Instant::now(); + first.child.kill().await?; + + let rebound = until_listening(port).await?; + let read_again = until_all_read_through_the_bridge( + "the two remaining processes", + &mut [&mut second, &mut third], + ) + .await?; + let reattached = plugin + .lock() + .unwrap() + .get(attached_before) + .map(|at| at.duration_since(ended)) + .expect("the plugin attached again"); + + // One of the two holds the port now; the other reads through it. The OS + // lets only one of them bind it, and both read. + assert!( + std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), + "the port is held again" + ); + for server in [&mut second, &mut third] { + let output = server.exports_through_the_bridge().await?; + assert_eq!(output["source"]["kind"], "bridge", "{output}"); + } + + eprintln!( + "bridge handover measured: port bound again {rebound:?} after its holder was killed; \ + the stand-in plugin (retrying every {PLUGIN_RETRY:?}) re-attached after {reattached:?}; \ + both remaining processes read through the bridge after {:?}", + rebound + read_again + ); + assert!( + rebound + read_again < DEADLINE, + "the handover must finish within {DEADLINE:?}" + ); + + drop((second, third)); + let _ = std::fs::remove_dir_all(&home); + Ok(()) +} diff --git a/crates/devup-mcp/tests/bridge_relay.rs b/crates/devup-mcp/tests/bridge_relay.rs new file mode 100644 index 00000000..59bd2381 --- /dev/null +++ b/crates/devup-mcp/tests/bridge_relay.rs @@ -0,0 +1,455 @@ +//! Several devup-mcp processes on one machine share the Devup Bridge plugin, +//! and each one's `status` says what it is doing about it. +//! +//! Measured on one machine: eight devup-mcp processes, one of which held the +//! bridge port. The plugin attached to that one, and a session that needed +//! Figma - a different process - reported `listening: false` and could only +//! read through the metered direct path. Its only remedy was to kill another +//! session's process. +//! +//! These drive the tool surface with the bridge on a port picked for the test +//! and a plugin standing in for Figma. Port 1993 is never touched. + +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; + +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; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message}; + +/// Generous, so a slow CI runner does not decide the outcome. Anything that +/// takes longer is a defect, not a wait. +const DEADLINE: Duration = Duration::from_secs(30); + +/// How long `status` may take to answer about a port someone else holds. +/// Well above the probe's own limits, well below "waits indefinitely". +const STATUS_LIMIT: Duration = Duration::from_secs(15); + +struct Auth; + +#[async_trait] +impl DevupAuth for Auth { + async fn status(&self) -> Result { + Ok(AuthStatus::Disconnected) + } + + async fn login(&self) -> Result { + Ok(AuthStatus::Connected) + } + + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } +} + +/// The metered path. Nothing here should reach it. +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, + )) + } +} + +/// Wired the way production wires a process: its bridge in front of the +/// metered path. +fn upstream(bridge: &BridgeServer, remote_calls: &Arc) -> Arc { + Arc::new(FallbackUpstream::new( + BridgeFigmaClient::new(bridge.state()).with_port(bridge.port()), + Remote(remote_calls.clone()), + )) +} + +/// A devup-mcp that found the port held. It used to get no bridge at all. +fn second_process_on(port: u16) -> BridgeServer { + BridgeServer::start(port).expect( + "a devup-mcp that finds the bridge port held must still get a bridge, through the \ + process that holds it", + ) +} + +async fn eventually(what: &str, mut check: F) +where + F: FnMut() -> Fut, + Fut: Future, +{ + let deadline = Instant::now() + DEADLINE; + while Instant::now() < deadline { + if check().await { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("{what} did not happen within {DEADLINE:?}"); +} + +async fn sees_one_plugin(bridge: &BridgeServer) { + let state = bridge.state(); + eventually("the plugin to show up in this process", || { + let state = state.clone(); + async move { state.attached_files().await.len() == 1 } + }) + .await; +} + +type Socket = WebSocketStream>; + +/// The Dev Mode case: a plugin that cannot report its file key, with one +/// frame selected. +fn keyless_hello() -> Value { + json!({ + "kind": "hello", + "fileKey": null, + "fileName": "Landing", + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [{ "id": "1:2", "name": "Synthetic Frame", "type": "FRAME" }], + "selectionCount": 1, + }) +} + +async fn attach(port: u16) -> Socket { + let (mut socket, _) = connect_async(format!("ws://127.0.0.1:{port}/plugin")) + .await + .expect("the holder accepts a plugin"); + socket + .send(Message::Text(keyless_hello().to_string().into())) + .await + .expect("hello is sent"); + socket +} + +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 return for a one-frame file whose key it could +/// not report. +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. +fn serve(mut socket: Socket) { + tokio::spawn(async move { + while let Some(Ok(message)) = socket.next().await { + let Message::Text(text) = message else { + continue; + }; + 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 }) + } + }; + if socket + .send(Message::Text(answer.to_string().into())) + .await + .is_err() + { + break; + } + } + }); +} + +/// Calls one tool on a server over `upstream` and returns its structured +/// answer, error or not, polling an export job to completion as a client does. +async fn call( + upstream: Arc, + tool: &str, + arguments: Value, +) -> anyhow::Result<(bool, Value)> { + let server = DevupServer::new(Services::new(Arc::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(), + )) +} + +async fn status(bridge: &BridgeServer) -> anyhow::Result { + let remote_calls = Arc::new(AtomicUsize::new(0)); + let (failed, status) = call( + upstream(bridge, &remote_calls), + "devup_figma_auth", + json!({ "action": "status" }), + ) + .await?; + assert!(!failed, "{status}"); + Ok(status) +} + +/// Whether the answer shows the key the bridge routes a keyless plugin by. +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("//")) +} + +/// Every process sees the same plugin, is connected through it, and says +/// which part it plays: the one holding the port, and the ones reading +/// through it - naming the holder. +#[tokio::test] +async fn every_process_reports_the_same_plugin_and_who_holds_the_port() -> anyhow::Result<()> { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + let port = host.port(); + let relay = second_process_on(port); + serve(attach(port).await); + sees_one_plugin(&host).await; + sees_one_plugin(&relay).await; + + let on_host = status(&host).await?; + let on_relay = status(&relay).await?; + for status in [&on_host, &on_relay] { + assert_eq!(status["connected"], true, "{status}"); + assert_eq!(status["activePath"], "bridge", "{status}"); + assert_eq!(status["paths"]["bridge"]["available"], true, "{status}"); + assert_eq!(status["paths"]["bridge"]["port"], port, "{status}"); + // #76: with the plugin reachable, nothing sends anyone to log in. + assert!(!status.to_string().contains("disconnected"), "{status}"); + assert_eq!(status["nextAction"]["tool"], "devup_figma_export"); + assert!(status["nextAction"]["arguments"].get("url").is_none()); + assert!(!shows_a_routing_key(status), "{status}"); + } + assert_eq!( + on_host["paths"]["bridge"]["attachedFiles"], on_relay["paths"]["bridge"]["attachedFiles"], + "every process shows the plugin the same way" + ); + + let bridge = &on_host["paths"]["bridge"]; + assert_eq!(bridge["role"], "host", "{bridge}"); + assert_eq!(bridge["listening"], true); + + let bridge = &on_relay["paths"]["bridge"]; + assert_eq!(bridge["role"], "relay", "{bridge}"); + assert_eq!( + bridge["listening"], false, + "this process does not hold the port" + ); + assert_eq!(bridge["host"]["pid"], std::process::id(), "{bridge}"); + assert_eq!(bridge["host"]["version"], env!("CARGO_PKG_VERSION")); + Ok(()) +} + +/// The acceptance case from the process that did not get the port: no url, +/// no token, and the export still reads the frame selected in Figma through +/// the bridge - spending nothing on the metered path. +#[tokio::test] +async fn an_export_without_url_on_a_relay_reads_the_selection_through_the_host() +-> anyhow::Result<()> { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + let port = host.port(); + let relay = second_process_on(port); + serve(attach(port).await); + sees_one_plugin(&relay).await; + + let remote_calls = Arc::new(AtomicUsize::new(0)); + let (failed, output) = call( + upstream(&relay, &remote_calls), + "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", "{source}"); + assert_eq!(source["bridgePort"], port); + assert!(source["fileKey"].is_null(), "{source}"); + assert_eq!(source["fileName"], "Landing"); + assert_eq!(source["nodeId"], "1:2"); + assert!(!shows_a_routing_key(&output), "{output}"); + assert_eq!(remote_calls.load(Ordering::SeqCst), 0); + drop(host); + Ok(()) +} + +/// What a devup-mcp built before the bridge could be shared answers on the +/// port: `/plugin`, and nothing else. +async fn legacy_devup_mcp() -> u16 { + use axum::{extract::ws::WebSocketUpgrade, routing::any}; + + let app = axum::Router::new().route( + "/plugin", + any(|upgrade: WebSocketUpgrade| async move { + upgrade.on_upgrade(|mut socket| async move { + while let Some(Ok(_)) = socket.recv().await {} + }) + }), + ); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("an ephemeral port is free"); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await }); + port +} + +async fn status_of_a_process_on(port: u16) -> anyhow::Result { + let relay = second_process_on(port); + let started = Instant::now(); + let status = status(&relay).await?; + assert!( + started.elapsed() < STATUS_LIMIT, + "status took {:?}: it must not wait on the port's holder indefinitely", + started.elapsed() + ); + Ok(status) +} + +/// An older devup-mcp holds the port. It cannot be fixed from here, so the +/// answer is to say so - which process kind, why this one cannot use the +/// plugin, and what would change that - instead of the generic "not +/// listening" that left a session guessing. +#[tokio::test] +async fn a_devup_mcp_from_before_sharing_is_named_with_its_repair() -> anyhow::Result<()> { + let port = legacy_devup_mcp().await; + let status = status_of_a_process_on(port).await?; + + let bridge = &status["paths"]["bridge"]; + assert_eq!(bridge["available"], false, "{bridge}"); + assert_eq!(bridge["role"], "unavailable", "{bridge}"); + assert_eq!(bridge["issue"], "legacy-host", "{bridge}"); + assert!( + bridge["host"].is_null(), + "its identity cannot be known: {bridge}" + ); + let reason = bridge["reason"].as_str().expect("a reason"); + assert!(reason.contains(&port.to_string()), "{reason}"); + assert!(reason.contains("restart"), "{reason}"); + + let options = status["nextAction"]["options"] + .as_array() + .expect("ways forward"); + assert_eq!(options[0]["path"], "bridge", "{status}"); + assert!( + options[0]["action"] + .as_str() + .is_some_and(|action| action.contains("restart")), + "{status}" + ); + assert_eq!(options[1]["path"], "direct", "{status}"); + Ok(()) +} + +/// Something that is not devup-mcp holds the port - an unrelated web server, +/// or a program that accepts and never answers. Neither is mistaken for a +/// devup-mcp, and neither makes `status` wait. +#[tokio::test] +async fn another_program_on_the_port_is_told_apart_from_devup_mcp() -> anyhow::Result<()> { + let web = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?; + let web_port = web.local_addr()?.port(); + tokio::spawn(async move { axum::serve(web, axum::Router::new()).await }); + + let silent = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?; + let silent_port = silent.local_addr()?.port(); + tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((socket, _)) = silent.accept().await { + held.push(socket); + } + }); + + for port in [web_port, silent_port] { + let status = status_of_a_process_on(port).await?; + let bridge = &status["paths"]["bridge"]; + assert_eq!(bridge["available"], false, "{bridge}"); + assert_eq!(bridge["role"], "unavailable", "{bridge}"); + assert_eq!(bridge["issue"], "foreign-program", "{bridge}"); + let reason = bridge["reason"].as_str().expect("a reason"); + assert!(reason.contains("not a devup-mcp"), "{reason}"); + } + Ok(()) +} diff --git a/crates/devup-mcp/tests/cli.rs b/crates/devup-mcp/tests/cli.rs index 13e06cb1..f0c7f8a8 100644 --- a/crates/devup-mcp/tests/cli.rs +++ b/crates/devup-mcp/tests/cli.rs @@ -120,6 +120,8 @@ fn version_build_id_reports_the_repository_dirty_state() { fn self_check_is_local_safe_json() -> anyhow::Result<()> { let output = Command::new(env!("CARGO_BIN_EXE_devup-mcp")) .arg("--self-check") + // Port 1993 belongs to whatever sessions this machine runs. + .env("DEVUP_FIGMA_BRIDGE_PORT", "off") .output() .expect("run devup-mcp --self-check"); diff --git a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs index edb66f96..baf29a85 100644 --- a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs +++ b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs @@ -106,6 +106,8 @@ struct RawStdioClient { impl RawStdioClient { fn spawn() -> anyhow::Result { let mut child = Command::new(env!("CARGO_BIN_EXE_devup-mcp")) + // Port 1993 belongs to whatever sessions this machine runs. + .env("DEVUP_FIGMA_BRIDGE_PORT", "off") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) diff --git a/crates/devup-mcp/tests/stdio_smoke.rs b/crates/devup-mcp/tests/stdio_smoke.rs index 666f7a2e..1e4fdadd 100644 --- a/crates/devup-mcp/tests/stdio_smoke.rs +++ b/crates/devup-mcp/tests/stdio_smoke.rs @@ -29,6 +29,9 @@ async fn send(stdin: &mut tokio::process::ChildStdin, value: Value) -> anyhow::R #[tokio::test] async fn fresh_binary_initializes_lists_tools_and_reports_auth_status() -> anyhow::Result<()> { let mut child = Command::new(env!("CARGO_BIN_EXE_devup-mcp")) + // Port 1993 belongs to whatever sessions this machine runs; a test + // binary must neither take it over nor read through its holder. + .env("DEVUP_FIGMA_BRIDGE_PORT", "off") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -107,6 +110,7 @@ async fn fresh_binary_initializes_lists_tools_and_reports_auth_status() -> anyho #[tokio::test] async fn r7_local_binary_errors_all_carry_identity() -> anyhow::Result<()> { let mut child = Command::new(env!("CARGO_BIN_EXE_devup-mcp")) + .env("DEVUP_FIGMA_BRIDGE_PORT", "off") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) diff --git a/plugin/README.md b/plugin/README.md index 16c8fe64..c9a66cdd 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -87,8 +87,30 @@ devup-mcp 쪽은 아무 설정도 필요 없습니다. 플러그인이 붙어 문서를 바꾸는 호출을 포함하지 않습니다. 데이터는 같은 기기의 devup-mcp 로만 나갑니다(`127.0.0.1`). -**한 기기에서 devup-mcp 를 여러 개 띄우면** 먼저 뜬 쪽이 포트를 잡고, 나머지는 -브리지 없이 공식 MCP 로 동작합니다. 오류가 아니라 정상 동작입니다. +**한 기기에서 devup-mcp 를 여러 개 띄우면** 모두가 같은 플러그인을 씁니다. +MCP 클라이언트나 세션마다 devup-mcp 가 하나씩 뜨는 것은 정상입니다. 플러그인이 +붙을 수 있는 포트는 manifest 에 적힌 하나뿐이라 먼저 뜬 쪽(호스트)이 포트를 잡고 +플러그인을 받으며, 나머지는 호스트를 통해 읽습니다(중계). 플러그인 창은 하나면 +됩니다. `devup_figma_auth { "action": "status" }` 의 `paths.bridge.role` 이 이 +프로세스가 `host` 인지 `relay` 인지, `host` 가 포트를 쥔 프로세스(pid·버전·빌드)를 +알려 주며, `attachedFiles` 는 어느 프로세스에서 보든 같습니다. + +호스트를 띄운 세션이 끝나면 남은 devup-mcp 가운데 하나가 곧바로 포트를 이어받고, +플러그인은 2초마다 다시 붙기를 시도하므로 그 새 호스트에 저절로 붙습니다. 사람이 +프로세스를 죽이거나 세션을 다시 띄울 필요가 없습니다. 이어받는 동안 status 는 +`role: "connecting"` 으로 그 상태를 그대로 보고합니다. + +중계는 같은 사용자의 devup-mcp 끼리만 이어집니다. 두 프로세스는 그 사용자만 읽을 +수 있는 파일(Windows `%USERPROFILE%\AppData\Local\devup-mcp\bridge-relay.key`, +macOS `~/Library/Application Support/devup-mcp/`, Linux `~/.local/state/devup-mcp/`) +의 비밀값으로 서로를 증명하고, 브라우저 페이지(`Origin` 이 붙은 요청)는 중계에 +붙을 수 없습니다. + +이 기능 **이전의 devup-mcp 가 포트를 잡고 있으면** 그 프로세스만 플러그인을 씁니다. +나중에 뜬 새 devup-mcp 는 그 사실을 알아보고 status 의 `paths.bridge` 에 +`issue: "legacy-host"` 와 할 일 — 그 devup-mcp 를 띄운 클라이언트를 재시작하거나 +갱신하기 — 을 적으며, 그 프로세스가 끝나면 스스로 포트를 이어받습니다. devup-mcp +가 아닌 프로그램이 포트를 잡은 경우는 `issue: "foreign-program"` 으로 따로 알립니다. **`ws://127.0.0.1` 은 쓸 수 없습니다.** Figma 는 `allowedDomains` 에 그 주소를 적으면 "유효한 URL 이 아니다"라며 **매니페스트 자체를 거부**해 플러그인이 실행되지 않습니다. From 405c1c05e0a0f1fa777456e38f380e196e1febed Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 27 Sep 2026 01:23:23 +0900 Subject: [PATCH 2/2] feat(bridge): keep a collection alive across a handover and close the sharing gaps - A read in flight when the port's holder leaves is sent again once the plugin is back, so the collection finishes; a read whose plugin window closed fails at once instead of after 90 s. - The plugin names its window with a sessionId, so a keyless (Dev Mode) plugin keeps its routing key across reconnects, and it honours devup-cancel: a read nobody waits for is dropped from its queue, or its answer withheld if already running. Older builds ignore both. - A holder that does not identify itself (a devup-mcp from before sharing, another program) is named by pid, name and executable path from the OS, after status has answered. - The Unix relay secret lives in /tmp/devup-mcp-/, chosen by user id rather than HOME, which clients set differently; the directory must be the user's own and 0700. - /plugin admits only the plugin's null origin and figma.com. - Reads the bridge serves are not held to the metered pace. - --self-check and in-process servers no longer open the bridge. - The changepack is keyed to crates/devup-mcp/Cargo.toml, which the release job tags and builds from. --- .../changepack_log_MgCZ-FfadImg96jdhk8Hq.json | 8 +- .github/workflows/ci.yml | 2 +- README.md | 10 +- crates/devup-mcp-figma/src/bridge.rs | 381 +++++++++++++----- crates/devup-mcp-figma/src/bridge/holder.rs | 263 ++++++++++++ crates/devup-mcp-figma/src/bridge/relay.rs | 163 ++++++-- crates/devup-mcp-figma/src/bridge/secret.rs | 178 ++++++-- crates/devup-mcp-figma/src/lib.rs | 2 +- crates/devup-mcp-figma/src/upstream.rs | 9 + crates/devup-mcp-figma/tests/bridge_relay.rs | 228 +++++++++-- crates/devup-mcp/src/lib.rs | 11 +- crates/devup-mcp/src/server/diagnostics.rs | 75 +++- crates/devup-mcp/src/server/mod.rs | 55 ++- crates/devup-mcp/tests/bridge_handover.rs | 277 +++++++++---- crates/devup-mcp/tests/bridge_relay.rs | 133 +++++- plugin/README.md | 28 +- plugin/dist/code.js | 2 +- plugin/dist/ui.html | 2 +- plugin/src/code.ts | 35 ++ plugin/src/ui.ts | 14 + plugin/tests/withdraw.test.mjs | 84 ++++ 21 files changed, 1627 insertions(+), 333 deletions(-) create mode 100644 crates/devup-mcp-figma/src/bridge/holder.rs create mode 100644 plugin/tests/withdraw.test.mjs diff --git a/.changepacks/changepack_log_MgCZ-FfadImg96jdhk8Hq.json b/.changepacks/changepack_log_MgCZ-FfadImg96jdhk8Hq.json index 4aa44261..f8ebf27d 100644 --- a/.changepacks/changepack_log_MgCZ-FfadImg96jdhk8Hq.json +++ b/.changepacks/changepack_log_MgCZ-FfadImg96jdhk8Hq.json @@ -1,7 +1,7 @@ { "changes": { - "Cargo.toml": "Minor" + "crates/devup-mcp/Cargo.toml": "Minor" }, - "note": "Several devup-mcp processes on one machine now share the Devup Bridge plugin. Only one can hold the port the plugin's manifest allows (ws://localhost:1993), and one devup-mcp per MCP client session is normal, so every process but the first used to report listening: false and could read Figma only through the metered direct path - with a plugin attached and serving, the only remedy was to kill another session's process. The process holding the port is now the host and the others relay through its new /relay endpoint: reads, results and the attached-file list travel over an authenticated WebSocket, request ids are assigned by the host so answers reach only the process that asked, and attachedFiles and the selection read the same in every process. When the host exits, a remaining process takes the port over at once (the OS gives it to exactly one) and the plugin re-attaches on its own two-second retry; reads in flight fail at once instead of after 90 s, and a host drops the reads of a relay that went away. Relaying is limited to the same user's devup-mcp: both sides prove knowledge of a secret kept in a user-only file with HMAC over fresh nonces, the secret never crosses the wire, relay handshakes carrying a browser Origin are refused before the upgrade, and nothing listens beyond 127.0.0.1. The handshake carries a protocol version and an incompatible peer is refused rather than trusted. devup_figma_auth status/doctor report paths.bridge.role (host, relay, connecting, unavailable), the holder's pid/version/buildId, handoverFrom while the port changes hands, and for an unusable port an issue - legacy-host for a devup-mcp from before sharing, foreign-program, incompatible-protocol, authentication-failed - with the step that fixes it, within seconds rather than waiting. available is true only when a read can be sent now, and a relay with the plugin reachable never suggests logging in. DEVUP_FIGMA_BRIDGE_PORT keeps its meaning and a single process behaves and answers as before.", - "date": "2026-09-26T13:22:28.913335Z" -} \ No newline at end of file + "note": "Several devup-mcp processes on one machine now share the Devup Bridge plugin. Only one can hold the port the plugin's manifest allows (ws://localhost:1993), and one devup-mcp per MCP client session is normal, so every process but the first used to report listening: false and could read Figma only through the metered direct path - with a plugin attached and serving, the only remedy was to kill another session's process. The process holding the port is now the host and the others relay through its new /relay endpoint: reads, results and the attached-file list travel over an authenticated WebSocket, request ids are assigned by the host so answers reach only the process that asked, and attachedFiles and the selection read the same in every process. When the host exits, a remaining process takes the port over at once (the OS gives it to exactly one) and the plugin re-attaches on its own two-second retry. A read in flight at that moment is sent again once the plugin is back - reads do not change the document - so a collection under way finishes (measured: 2.0 s after its holder was killed) instead of failing; it fails only if the plugin is not back within 10 s, and a read whose plugin window closed fails at once instead of after 90 s. The plugin now names its window with a sessionId, so a plugin that cannot report its file key (Dev Mode) keeps its routing key across reconnects and handovers, and it understands devup-cancel: a read nobody waits for any more - the process that asked went away, or it timed out - is dropped from the plugin's queue, or its answer withheld if it was already running. Older plugin builds ignore both and keep working. Relaying is limited to the same user's devup-mcp: both sides prove knowledge of a secret with HMAC over fresh nonces, and the secret never crosses the wire. It is kept in %USERPROFILE%\\AppData\\Local\\devup-mcp\\bridge-relay.key on Windows and in /tmp/devup-mcp-/bridge-relay.key on macOS and Linux - chosen by user id rather than HOME, which MCP clients set differently, so two sessions of one user always find each other; that directory must belong to this user with mode 0700, is closed and its secret replaced if it was open, and is refused if it belongs to someone else or is a link. Browser pages cannot pose as either side: /relay refuses any Origin, and /plugin admits only the Figma plugin's null origin and figma.com. Nothing listens beyond 127.0.0.1, and the relay handshake carries a protocol version so an incompatible peer is refused rather than trusted. devup_figma_auth status/doctor report paths.bridge.role (host, relay, connecting, unavailable), the holder's pid/version/buildId, handoverFrom while the port changes hands, and for an unusable port an issue - legacy-host, foreign-program, incompatible-protocol, authentication-failed - with the step that fixes it, within seconds rather than waiting. A holder that does not say who it is - a devup-mcp from before sharing, or another program - is named by pid, process name and executable path as the operating system reports them, added after the first answer so status never waits on the lookup. available is true only when a read can be sent now, and a relay with the plugin reachable never suggests logging in. Reads the bridge serves are no longer held to the metered pace (DEVUP_FIGMA_CALLS_PER_MINUTE, eight a minute by default), which made a second export on the same server wait most of a minute for an allowance it was not spending. --self-check and a server built in-process for tests no longer open the bridge. DEVUP_FIGMA_BRIDGE_PORT keeps its meaning and a single process behaves and answers as before.", + "date": "2026-09-26T16:22:15.990869Z" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 249cb654..65e6727e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,7 +199,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo install cargo-insta --version 1.48.0 --locked - run: cargo fmt --all -- --check - - run: node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs + - run: node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs plugin/tests/withdraw.test.mjs # `plugin/dist/` is committed so a user can import the plugin into Figma # without running a build. That only stays true if the committed bundle # is the one this source produces, so rebuild it and refuse a difference. diff --git a/README.md b/README.md index 05916863..3e150368 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ stdio pipe를 보유한 상태이므로 host의 MCP 연결을 재시작하거나 ```bash cargo fmt --all -- --check -node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs +node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs plugin/tests/withdraw.test.mjs cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-features cargo test -p devup-mcp --test stdio_smoke @@ -251,6 +251,8 @@ stdio MCP를 지원하는 클라이언트에 다음과 같이 등록합니다. `issue`는 다음 중 하나입니다 — `legacy-host`(이 기능 이전의 devup-mcp가 포트를 쥐었습니다. 그 클라이언트를 재시작·갱신하면 이 프로세스가 스스로 이어받습니다), `foreign-program`(devup-mcp가 아닌 프로그램), `incompatible-protocol`(중계 규약의 판이 다른 devup-mcp — 틀린 답을 내느니 잇지 않습니다), `authentication-failed`(같은 사용자의 devup-mcp임을 증명하지 못함), `secret-unavailable`, `bind-failed`. 어느 경우든 `status`는 몇 초 안에 답하고 기다리지 않습니다. +`legacy-host`와 `foreign-program`은 포트를 쥔 쪽이 자신을 밝히지 않으므로, 운영체제에 그 포트에서 듣는 프로세스를 물어 **pid·이름·실행 파일 경로**를 붙입니다 — 예전 devup-mcp면 `host`에(버전은 알 수 없어 `null`), 다른 프로그램이면 `holder`에 싣고 `reason`에도 적습니다. 실행 파일 경로를 보면 어느 클라이언트가 설치한 것인지 드러납니다. 묻는 데는 외부 명령(Windows `netstat`·`tasklist`·PowerShell, macOS `lsof`·`ps`, Linux `/proc`)이 들어서, `status`는 그것을 기다리지 않고 먼저 답한 뒤 알게 되는 대로 덧붙입니다. 포트를 쥔 프로그램을 이 사용자 권한으로 실행해 보지는 않습니다. + **`available: true`는 지금 실제로 읽기를 보낼 수 있을 때만입니다** — 플러그인이 보이고, 그것에 닿는 길(이 프로세스의 포트, 또는 포트를 쥔 프로세스와의 연결)이 있을 때. 이 상태면 로그인 없이 그 파일의 수집이 그대로 되고, 중계 프로세스에서도 로그인하라는 안내를 하지 않습니다. `paths.direct`의 세 필드는 **서로 다른 것**을 말하므로 함께 읽어야 합니다. @@ -385,7 +387,7 @@ devup-mcp가 Figma에 붙는 경로는 **둘**이고, 대등하지 않습니다. | **브리지** (`bridge`) | 필요 없음 | **쓰지 않음** | **기본.** 데스크톱 앱에서 플러그인을 띄워 두면 그쪽으로 읽습니다 | | 직접 (`direct`) | `devup_figma_auth { action: "login" }` | 씁니다 | 브리지가 못 하는 읽기와, 플러그인을 띄울 수 없는 환경(CI 등) | -**브리지를 먼저 쓰십시오.** direct는 Figma가 사용량을 세는 경로이고, 화면 하나가 여러 번의 읽기를 쓰므로 한도가 금방 바닥납니다. +**브리지를 먼저 쓰십시오.** direct는 Figma가 사용량을 세는 경로이고, 화면 하나가 여러 번의 읽기를 쓰므로 한도가 금방 바닥납니다. 그래서 direct로 가는 읽기는 한도에 맞춘 속도(`DEVUP_FIGMA_CALLS_PER_MINUTE`, 기본 분당 8회)로 늦춰 보내지만, 브리지로 가는 읽기는 늦추지 않습니다. 예전에는 브리지 읽기까지 같은 속도로 묶여, 같은 서버의 두 번째 export가 쓰지도 않는 한도를 1분 가까이 기다렸습니다. **플러그인이 이 파일을 맡고 있으면 로그인을 요구하지 않습니다.** 예전에는 수집을 시작하기 전에 토큰부터 확인해서, 한도를 아끼려고 플러그인을 띄운 사람에게 "먼저 한도 쓰는 경로를 여세요"라고 거절했습니다. 지금은 브리지를 먼저 보고, 이 파일을 맡은 플러그인이 없을 때만 로그인을 요구합니다. 수집 도중 브리지가 못 하는 읽기가 있으면 **그 읽기가** 자기 이유로 거절하므로, 무엇이 왜 막혔는지가 그대로 드러납니다. @@ -470,7 +472,9 @@ Figma에서 대상 파일을 열고 `Devup Bridge`를 실행하면 창이 하나 #### 알아 두어야 할 것 - **기본으로 켜져 있습니다.** devup-mcp는 시작할 때 `127.0.0.1:1993`에 대기합니다. 그 포트를 다른 devup-mcp가 이미 쥐고 있으면 그 프로세스를 통해 같은 플러그인을 읽고, 그 프로세스가 끝나면 남은 devup-mcp 가운데 하나가 포트를 이어받습니다(플러그인은 2초 안에 새 호스트에 다시 붙습니다). 끄려면 `DEVUP_FIGMA_BRIDGE_PORT=off`. -- **여러 devup-mcp가 나눠 쓰는 것은 같은 사용자의 것끼리입니다.** 중계는 그 사용자만 읽을 수 있는 파일(Windows `%USERPROFILE%\AppData\Local\devup-mcp\bridge-relay.key`, macOS `~/Library/Application Support/devup-mcp/`, Linux `~/.local/state/devup-mcp/`)의 비밀값으로 서로를 증명해야 이어지고, 비밀값 자체는 연결로 오가지 않습니다. 브라우저 페이지처럼 `Origin`이 붙은 요청은 중계 문에서 거절됩니다. +- **포트가 넘어가도 수집은 이어집니다.** 그때 진행 중이던 읽기는 실패로 끝나지 않고, 플러그인이 새 호스트에 다시 붙는 대로 다시 보냅니다(읽기는 문서를 바꾸지 않아 두 번 돌아도 해가 없습니다). 플러그인이 10초 안에 돌아오지 않으면 그때 실패합니다. 요청한 쪽이 떠나거나 시간이 다 된 읽기는 플러그인에 취소를 보내, 차례를 기다리던 작업은 돌리지 않습니다. +- **여러 devup-mcp가 나눠 쓰는 것은 같은 사용자의 것끼리입니다.** 중계는 그 사용자만 읽을 수 있는 비밀값으로 서로를 증명해야 이어지고, 비밀값 자체는 연결로 오가지 않습니다. 비밀값은 Windows `%USERPROFILE%\AppData\Local\devup-mcp\bridge-relay.key`, macOS·Linux `/tmp/devup-mcp-/bridge-relay.key`에 있습니다. Unix에서 환경 변수(`HOME`)가 아니라 사용자 번호로 자리를 정하는 것은 MCP 클라이언트마다 서버에 넘기는 `HOME`이 달라(샌드박스로 바꿔 띄우는 클라이언트도 있습니다) 같은 사용자의 프로세스가 서로를 알아보지 못했기 때문입니다. `/tmp`는 누구나 쓰는 곳이라, 그 디렉터리가 이 사용자의 것이고 다른 사용자가 들어올 수 없는지 쓸 때마다 확인합니다 — 열려 있었으면 닫고 비밀값을 새로 만들며, 다른 사용자의 것이거나 링크면 쓰지 않습니다(그때는 `secret-unavailable`). 재부팅하면 지워지지만 그때는 그 비밀값을 알던 프로세스도 모두 끝난 뒤입니다. +- **브라우저 페이지는 붙지 못합니다.** 중계 문은 `Origin`이 붙은 요청을 모두 거절하고, 플러그인 문(`/plugin`)은 Figma 플러그인 창(`Origin: null`)과 `figma.com`만 받습니다. 흔한 웹 페이지가 플러그인인 척 읽기를 가로채지 못하게 하려는 것인데, 샌드박스 iframe이나 `file://` 페이지도 `null`을 실으므로 그런 페이지까지 막지는 못합니다. - **읽기 전용입니다.** 플러그인이 실행하는 스크립트는 **빌드 시점에 플러그인 안에 박혀 있고**, devup-mcp는 그중 어느 것을 실행할지 **이름만** 보냅니다. 소켓으로 코드가 오가지 않으며 Figma 문서를 바꾸는 호출은 존재하지 않습니다. - **이 기기에서만 됩니다.** 대기 주소는 `127.0.0.1`이라 다른 기기에서는 붙을 수 없고, 원격 CI에서는 브리지가 없으니 자동으로 원격 경로를 씁니다. - **파일은 한 번에 하나입니다.** 플러그인이 자기 파일 키를 보고하지 못하는 경우가 있어(Dev Mode 등), 키 없는 플러그인은 **혼자 붙어 있을 때만** 읽기를 받습니다. 두 개 이상이면 어느 파일인지 알 수 없으므로 원격 경로로 넘어갑니다. diff --git a/crates/devup-mcp-figma/src/bridge.rs b/crates/devup-mcp-figma/src/bridge.rs index 8be3f9ca..553bd452 100644 --- a/crates/devup-mcp-figma/src/bridge.rs +++ b/crates/devup-mcp-figma/src/bridge.rs @@ -17,9 +17,12 @@ //! 을 찾도록 쓰여 있으므로, 여기서 모양을 바꾸면 두 경로가 조용히 갈라진다. 중계를 //! 거친 답도 이 모양이다 — 감싸는 일은 요청한 프로세스가 한다. +mod holder; mod relay; mod secret; +pub use holder::PortOwner; + use std::{ collections::HashMap, hash::Hash, @@ -39,7 +42,8 @@ use axum::{ State, ws::{Message, WebSocket, WebSocketUpgrade}, }, - response::Response, + http::{HeaderMap, StatusCode, header::ORIGIN}, + response::{IntoResponse, Response}, routing::any, }; use serde::{Deserialize, Serialize}; @@ -192,6 +196,8 @@ struct Connected { outbox: mpsc::UnboundedSender, /// 플러그인이 보고한 파일 키. 보고하지 못했으면 빈 문자열이다. file_key: String, + /// 읽기가 이 플러그인에 닿으려면 부를 이름. [`routing_key`] 가 정한다. + routing_key: String, file_name: Option, context: PluginContext, } @@ -201,13 +207,13 @@ impl Connected { (!self.file_key.is_empty()).then(|| self.file_key.clone()) } - fn attached(&self, id: u64) -> AttachedFile { - attached_file( - id, - self.reported_key(), - self.file_name.clone(), - self.context.clone(), - ) + fn attached(&self) -> AttachedFile { + AttachedFile { + target_key: self.routing_key.clone(), + file_key: self.reported_key(), + file_name: self.file_name.clone(), + context: self.context.clone(), + } } fn served(&self) -> BridgeServed { @@ -225,22 +231,69 @@ impl Connected { } } -/// 붙어 있는 플러그인 하나를, 어느 프로세스에서 보든 같은 모양으로. +/// 읽기가 이 플러그인에 닿으려면 부를 이름. /// -/// 호스트는 제 연결에서 만들고, 중계는 호스트가 보낸 목록에서 같은 함수로 만든다. -/// 그래서 `attachedFiles` 가 모든 프로세스에서 같고, 키 없는 플러그인의 라우팅 키도 -/// 호스트가 매긴 연결 번호 그대로다. -fn attached_file( - id: u64, - file_key: Option, - file_name: Option, - context: PluginContext, -) -> AttachedFile { - AttachedFile { - target_key: file_key.clone().unwrap_or_else(|| connection_key(id)), - file_key, - file_name, - context, +/// 파일 키를 보고했으면 그 키다. 보고하지 못했으면(Dev Mode) 브리지 전용 키인데, +/// 플러그인이 창마다 한 번 정해 `hello` 에 싣는 `sessionId` 가 있으면 그것에서 +/// 만든다 — 소켓이 끊겨 다시 붙어도, 포트를 쥔 devup-mcp 가 바뀌어도 같은 창이면 +/// 같은 이름이라, 수집 도중에 연결이 바뀌어도 남은 읽기가 그 창을 다시 찾는다. +/// `sessionId` 를 보내지 않는 예전 빌드는 연결마다 매긴 번호로 부른다. +fn routing_key(file_key: &str, session: Option<&str>, connection: u64) -> String { + if !file_key.is_empty() { + return file_key.to_owned(); + } + match session { + Some(session) => format!("{BRIDGE_KEY_PREFIX}{session}"), + None => connection_key(connection), + } +} + +/// 플러그인이 보낸 `sessionId` 를 이름에 쓸 수 있을 때만 받는다. 브리지 전용 키의 +/// 모양을 지키고(`bridge:current` 같은 예약된 이름과 겹치지 않게) 길이를 묶는다. +fn session_of(hello: &Value) -> Option { + hello + .get("sessionId") + .and_then(Value::as_str) + .filter(|session| { + (16..=64).contains(&session.len()) + && session + .bytes() + .all(|byte| byte.is_ascii_hexdigit() || byte == b'-') + }) + .map(|session| format!("s-{session}")) +} + +/// 플러그인이 답하기를 기다리는 읽기 하나. +struct Pending { + /// 작업을 받은 플러그인의 연결 번호. 그 연결이 끊기면 이 읽기도 끝난다. + plugin: u64, + /// 그 플러그인에 보낼 곳. 기다리는 쪽이 떠나면 여기로 거둬 달라고 알린다. + outbox: mpsc::UnboundedSender, + waiter: oneshot::Sender, +} + +type PendingTable = StdMutex>; + +/// 플러그인의 답을 기다리는 동안 쥐고 있는 대기표. +/// +/// 답이 오면 받는 쪽이 대기표를 이미 걷었으므로 아무 일도 하지 않는다. 답을 받지 +/// 못한 채 버려지면 — 시간이 다 됐거나, 읽기를 맡긴 프로세스가 떠나 호스트가 그 +/// 읽기를 취소했거나 — 대기표를 걷고 플러그인에 `devup-cancel` 을 보낸다. 아직 +/// 차례를 기다리던 작업이면 플러그인은 돌리지 않는다. +struct Awaiting<'a> { + pending: &'a PendingTable, + request_id: String, +} + +impl Drop for Awaiting<'_> { + fn drop(&mut self) { + let Ok(mut pending) = self.pending.lock() else { + return; + }; + if let Some(abandoned) = pending.remove(&self.request_id) { + let cancel = json!({ "kind": "devup-cancel", "requestId": self.request_id }); + let _ = abandoned.outbox.send(cancel.to_string()); + } } } @@ -343,8 +396,8 @@ async fn shut_down(signal: &mut watch::Receiver) { pub struct BridgeState { inner: Arc>, /// requestId → 결과를 기다리는 쪽. 잠금을 쥔 채 기다리지 않으므로 동기 잠금이면 - /// 되고, 그래야 기다리던 쪽이 사라질 때 [`Waiting`] 이 곧바로 걷을 수 있다. - pending: Arc>>>, + /// 되고, 그래야 기다리던 쪽이 사라질 때 [`Awaiting`] 이 곧바로 걷을 수 있다. + pending: Arc, /// 요청 번호와 연결 번호를 함께 매긴다. 둘 다 유일하기만 하면 된다. counter: Arc, /// 이 프로세스에서 보이는 플러그인 수. 배치 크기를 정할 때는 잠금을 기다릴 수 없어 @@ -362,10 +415,6 @@ 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) { @@ -387,36 +436,71 @@ fn describe_file(file_key: &str) -> String { /// 여럿이면 어느 파일을 보고 있는지 알 수 없고, 엉뚱한 파일을 읽어 주는 것보다 /// 원격으로 넘기는 편이 낫다. /// -/// 연결 키는 그 연결 하나만 가리킨다. 몇 개가 붙어 있든 모호하지 않고, 그 연결이 -/// 끊기면 아무도 맡지 않는다 — 다른 파일의 플러그인이 대신 답하면 안 된다. +/// 브리지 전용 키는 그 창 하나만 가리킨다. 몇 개가 붙어 있든 모호하지 않고, 그 +/// 창이 끊기면 아무도 맡지 않는다 — 다른 파일의 플러그인이 대신 답하면 안 된다. +/// 같은 창이 다시 붙으면(같은 `sessionId`) 나중 연결이 맡는다. /// -/// `plugins` 는 (연결 번호, 보고한 파일 키 — 없으면 빈 문자열) 이다. 호스트는 제 -/// 연결로, 중계는 호스트가 보낸 목록으로 같은 규칙을 쓴다. -fn resolve<'a>(plugins: impl IntoIterator, file_key: &str) -> Option { - let plugins: Vec<(u64, &str)> = plugins.into_iter().collect(); +/// `plugins` 는 (연결 번호, 부를 이름, 보고한 파일 키 — 없으면 빈 문자열) 이다. +/// 호스트는 제 연결로, 중계는 호스트가 보낸 목록으로 같은 규칙을 쓴다. +fn resolve<'a>( + plugins: impl IntoIterator, + file_key: &str, +) -> Option { + let plugins: Vec<(u64, &str, &str)> = plugins.into_iter().collect(); if is_bridge_only_key(file_key) { - return connection_id(file_key).filter(|id| plugins.iter().any(|(held, _)| held == id)); + return plugins + .iter() + .filter(|(_, routing, _)| *routing == file_key) + .map(|(id, ..)| *id) + .max(); } let holding = plugins .iter() - .filter(|(_, key)| *key == file_key) - .map(|(id, _)| *id) + .filter(|(_, _, reported)| *reported == file_key) + .map(|(id, ..)| *id) .max(); if holding.is_some() { return holding; } match plugins.as_slice() { - [(id, "")] => Some(*id), + [(id, _, "")] => Some(*id), _ => None, } } -fn keys_of(plugins: &HashMap) -> impl Iterator { +fn keys_of(plugins: &HashMap) -> impl Iterator { plugins .iter() - .map(|(id, plugin)| (*id, plugin.file_key.as_str())) + .map(|(id, plugin)| (*id, plugin.routing_key.as_str(), plugin.file_key.as_str())) +} + +fn keys_in(files: &[(u64, AttachedFile)]) -> impl Iterator { + files.iter().map(|(id, file)| { + ( + *id, + file.target_key.as_str(), + file.file_key.as_deref().unwrap_or_default(), + ) + }) +} + +/// 읽기 한 번이 끝난 모양. 중간에 끊긴 것은 다시 보낼 수 있다 — 읽기는 문서를 +/// 바꾸지 않는다. +pub(crate) enum Failure { + /// 플러그인에 닿는 길이 도중에 끊겼다: 플러그인이 떠났거나, 포트를 쥔 호스트가 + /// 떠났다. 플러그인이 돌아오면 다시 보낸다. + Interrupted(DevupError), + /// 답이 왔거나(스크립트 오류 포함) 더 기다릴 수 없다. 그대로 올린다. + Final(DevupError), } +/// 끊긴 읽기를 다시 보내는 횟수의 한도. +const INTERRUPTIONS: usize = 3; + +/// 끊긴 읽기가 플러그인이 돌아오기를 기다리는 한도. 호스트가 바뀌어도 이 안에 +/// 포트가 넘어가고, 플러그인은 2초마다 다시 붙는다. +const RETURN_WAIT: Duration = Duration::from_secs(10); + /// 중계가 다룰 수 없는 상태에서 읽기가 왔을 때의 거절. fn not_reachable(link: &LinkState) -> DevupError { match link { @@ -542,7 +626,7 @@ impl BridgeState { plugins.sort_unstable_by_key(|(id, _)| **id); plugins .into_iter() - .map(|(id, plugin)| (*id, plugin.attached(*id))) + .map(|(id, plugin)| (*id, plugin.attached())) .collect() } LinkState::Relay { files, .. } => files.clone(), @@ -550,17 +634,35 @@ impl BridgeState { } } + /// 지금 이 파일을 맡을 플러그인이 보이는지. 기다리지 않는다. + async fn sees(&self, file_key: &str) -> bool { + let link = self.link.current(); + let view = self.view(&link).await; + resolve(keys_in(&view), file_key).is_some() + } + /// 이 파일을 열어 둔 플러그인이 있는지. 포트를 쥔 프로세스를 통해 보이는 것도 센다. pub async fn has_plugin(&self, file_key: &str) -> bool { self.settle().await; - let link = self.link.current(); - let view = self.view(&link).await; - resolve( - view.iter() - .map(|(id, file)| (*id, file.file_key.as_deref().unwrap_or_default())), - file_key, - ) - .is_some() + self.sees(file_key).await + } + + /// 끊긴 읽기의 플러그인이 돌아오기를 기다린다. 돌아왔으면 `true`. + async fn plugin_returns(&self, file_key: &str) -> bool { + let until = tokio::time::Instant::now() + RETURN_WAIT; + let mut changes = self.link.state.subscribe(); + loop { + changes.borrow_and_update(); + if self.sees(file_key).await { + return true; + } + if !matches!( + tokio::time::timeout_at(until, changes.changed()).await, + Ok(Ok(())) + ) { + return self.sees(file_key).await; + } + } } /// 붙어 있는 플러그인이 보고한 파일 키. 보고하지 못한 플러그인은 빈 문자열이다. @@ -645,28 +747,50 @@ impl BridgeState { } /// 읽기 하나를 플러그인에 보낸다. 포트를 쥐었으면 곧장, 아니면 쥔 프로세스를 통해. + /// + /// 도중에 길이 끊기면 — 플러그인 창이 다시 붙었거나, 포트를 쥔 호스트가 떠나 + /// 다른 프로세스가 이어받았으면 — 플러그인이 돌아오기를 잠깐 기다렸다가 다시 + /// 보낸다. 읽기는 문서를 바꾸지 않으므로 두 번 돌아도 해가 없고, 호출자는 인계가 + /// 있었는지 모른 채 답을 받는다. 수집 한가운데서 호스트 세션이 끝나도 그 수집은 + /// 이어진다. async fn dispatch( &self, file_key: &str, script: &str, params: Value, ) -> Result<(Value, BridgeServed), DevupError> { - match self.link.current() { - LinkState::Host { .. } => self.dispatch_local(file_key, script, params).await, - LinkState::Relay { connection, .. } => { - connection.dispatch(file_key, script, params).await + let mut interruptions = 0; + loop { + self.settle().await; + let attempt = match self.link.current() { + LinkState::Host { .. } => { + self.dispatch_local(file_key, script, params.clone()).await + } + LinkState::Relay { connection, .. } => { + connection.dispatch(file_key, script, params.clone()).await + } + other => return Err(not_reachable(&other)), + }; + match attempt { + Ok(done) => return Ok(done), + Err(Failure::Final(error)) => return Err(error), + Err(Failure::Interrupted(error)) => { + interruptions += 1; + if interruptions > INTERRUPTIONS || !self.plugin_returns(file_key).await { + return Err(error); + } + } } - other => Err(not_reachable(&other)), } } - /// 이 프로세스에 붙은 플러그인으로 보낸다. 중계로 온 읽기도 여기로 온다. + /// 이 프로세스에 붙은 플러그인으로 한 번 보낸다. async fn dispatch_local( &self, file_key: &str, script: &str, params: Value, - ) -> Result<(Value, BridgeServed), DevupError> { + ) -> Result<(Value, BridgeServed), Failure> { let request_id = self.next_request_id(); let (tx, rx) = oneshot::channel(); @@ -675,10 +799,10 @@ impl BridgeState { let Some((id, plugin)) = resolve(keys_of(&inner.plugins), file_key) .and_then(|id| inner.plugins.get(&id).map(|plugin| (id, plugin))) else { - return Err(unavailable(format!( + return Err(Failure::Final(unavailable(format!( "no Devup Bridge plugin is open for {}", describe_file(file_key) - ))); + )))); }; let job = Job { kind: "devup-job", @@ -686,57 +810,72 @@ impl BridgeState { script, params, }; - let encoded = serde_json::to_string(&job) - .map_err(|error| unavailable(format!("bridge job encode failed: {error}")))?; + let encoded = serde_json::to_string(&job).map_err(|error| { + Failure::Final(unavailable(format!("bridge job encode failed: {error}"))) + })?; // 답이 보내기보다 먼저 올 수는 없지만, 대기표는 보내기 전에 둔다. self.pending .lock() .expect("the pending table is never poisoned") - .insert(request_id.clone(), tx); + .insert( + request_id.clone(), + Pending { + plugin: id, + outbox: plugin.outbox.clone(), + waiter: tx, + }, + ); let sent = plugin.outbox.send(encoded).is_ok(); let served = plugin.served(); if !sent { - self.pending - .lock() - .expect("the pending table is never poisoned") - .remove(&request_id); // 소켓이 막 닫혔다. 등록을 지워 다음 호출이 곧장 폴백하도록 한다. inner.plugins.remove(&id); - self.plugins_changed(&mut inner); - return Err(unavailable(format!( + self.plugin_left(&mut inner, id); + return Err(Failure::Interrupted(unavailable(format!( "the Devup Bridge plugin for {} disconnected", describe_file(file_key) - ))); + )))); } served }; // 성공이든 실패든, 기다리던 쪽이 도중에 사라지든 대기표는 반드시 걷는다. - // 남겨 두면 연결이 오래 살아 있는 동안 계속 쌓인다. - let _waiting = Waiting { + // 답을 받지 못한 채 걷으면 플러그인에도 그 작업을 거두라고 알린다. + let _awaiting = Awaiting { pending: &self.pending, - key: request_id, + request_id, }; - let received = timeout(JOB_TIMEOUT, rx).await; - - match received { + match timeout(JOB_TIMEOUT, rx).await { Ok(Ok(result)) => match (result.data, result.error) { // 스크립트가 던진 DEVUP_* 코드는 그대로 올린다. 원격 경로와 같은 // 문자열이어야 위쪽 분기가 동일하게 동작한다. - (_, Some(message)) => Err(DevupError::new( + (_, Some(message)) => Err(Failure::Final(DevupError::new( ErrorCode::DevupFigmaDirectUnavailable, message, false, - )), + ))), (Some(data), None) => Ok((data, served)), - (None, None) => Err(unavailable("bridge returned neither data nor error")), + (None, None) => Err(Failure::Final(unavailable( + "bridge returned neither data nor error", + ))), }, - // 플러그인 창이 닫혔다. - Ok(Err(_)) => Err(unavailable("the Devup Bridge plugin disconnected mid-read")), - Err(_) => Err(unavailable( + // 작업을 받은 플러그인 창이 끊겼다. + Ok(Err(_)) => Err(Failure::Interrupted(unavailable( + "the Devup Bridge plugin disconnected mid-read", + ))), + Err(_) => Err(Failure::Final(unavailable( "the Devup Bridge plugin did not answer in time", - )), + ))), + } + } + + /// 플러그인 하나가 떠났다(등록은 이미 지웠다). 그 플러그인이 받아 둔 읽기는 더 + /// 답이 오지 않으니 곧장 끝낸다 — 90초를 기다리게 하지 않는다. + fn plugin_left(&self, inner: &mut Inner, id: u64) { + if let Ok(mut pending) = self.pending.lock() { + pending.retain(|_, waiting| waiting.plugin != id); } + self.plugins_changed(inner); } } @@ -808,14 +947,40 @@ fn wrap_as_tool_result(data: &Value, served: &BridgeServed) -> Result, upgrade: WebSocketUpgrade) -> Response { +/// Figma 플러그인의 UI 는 origin 이 `null` 인 iframe 에서 돈다(Figma 개발자 문서 +/// "Making Network Requests": "Plugin iframes have a null origin"). 이 플러그인은 +/// iframe 을 다른 주소로 옮기지 않는다. 혹시 Figma 가 제 origin 을 싣는 판이 있어도 +/// 끊기지 않도록 figma.com 도 들인다 — 다른 페이지는 그 값을 지어낼 수 없다. +/// `Origin` 이 없는 요청은 브라우저가 아닌 프로그램(테스트, 프로브)이다. +/// +/// 한계가 있다: 샌드박스 iframe 이나 `file://` 페이지도 `null` 을 싣는다. 흔한 웹 +/// 페이지는 막지만 그런 페이지까지 막지는 못한다. +fn a_plugin_origin(headers: &HeaderMap) -> bool { + headers.get(ORIGIN).is_none_or(|origin| { + matches!( + origin.as_bytes(), + b"null" | b"https://www.figma.com" | b"https://figma.com" + ) + }) +} + +/// 플러그인이 붙는 문. +async fn plugin_socket( + State(state): State, + headers: HeaderMap, + upgrade: WebSocketUpgrade, +) -> Response { + if !a_plugin_origin(&headers) { + return StatusCode::FORBIDDEN.into_response(); + } upgrade.on_upgrade(move |socket| handle_plugin(state, socket)) } @@ -849,13 +1014,15 @@ async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { // // 필드마다 따로 읽는다. 페이지나 선택이 어긋난 모양으로 와도 // 파일 키까지 잃으면 안 된다. + let file_key = value + .get("fileKey") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); let plugin = Connected { outbox: outbox.clone(), - file_key: value - .get("fileKey") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), + routing_key: routing_key(&file_key, session_of(&value).as_deref(), id), + file_key, file_name: value .get("fileName") .and_then(Value::as_str) @@ -891,7 +1058,7 @@ async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { .remove(&result.request_id); // 받는 쪽이 이미 타임아웃했거나 떠났으면 버린다. if let Some(waiting) = waiting { - let _ = waiting.send(result); + let _ = waiting.waiter.send(result); } } _ => {} @@ -902,7 +1069,7 @@ async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { let mut inner = state.inner.lock().await; if inner.plugins.remove(&id).is_some() { - state.plugins_changed(&mut inner); + state.plugin_left(&mut inner, id); } } @@ -943,9 +1110,13 @@ pub enum BridgeRole { #[derive(Debug, Clone, PartialEq, Eq)] pub enum BridgeIssue { /// 중계를 모르는 예전 devup-mcp 가 포트를 쥐었다. `/plugin` 만 있고 `/relay` 가 없다. - LegacyHost, - /// devup-mcp 가 아닌 프로그램이 포트를 쥐었다. - ForeignProgram { detail: String }, + /// 자신을 밝히지 않으므로 `owner` 는 OS 에 물어 안 것이다. + LegacyHost { owner: Option }, + /// devup-mcp 가 아닌 프로그램이 포트를 쥐었다. `owner` 는 OS 에 물어 안 것이다. + ForeignProgram { + detail: String, + owner: Option, + }, /// 다른 판의 중계 규약을 쓰는 devup-mcp 가 쥐었다. 틀린 답을 내느니 잇지 않는다. IncompatibleProtocol { theirs: Option, ours: u64 }, /// 서로 같은 사용자의 devup-mcp 임을 증명하지 못했다. @@ -960,7 +1131,7 @@ impl BridgeIssue { /// status 가 싣는 짧은 이름. pub fn code(&self) -> &'static str { match self { - Self::LegacyHost => "legacy-host", + Self::LegacyHost { .. } => "legacy-host", Self::ForeignProgram { .. } => "foreign-program", Self::IncompatibleProtocol { .. } => "incompatible-protocol", Self::AuthenticationFailed { .. } => "authentication-failed", @@ -1159,6 +1330,10 @@ impl FigmaUpstream for BridgeFigmaClient { async fn bridge_path_snapshot(&self) -> Option { Some(self.state.path_snapshot(self.port).await) } + + async fn is_metered(&self, _call: &ReadToolCall) -> bool { + false + } } #[async_trait] @@ -1269,6 +1444,12 @@ where async fn bridge_path_snapshot(&self) -> Option { self.preferred.bridge_path_snapshot().await } + + /// 브리지가 맡는 읽기는 한도를 쓰지 않는다. 브리지만 읽을 수 있는 키는 원격으로 + /// 가지 않으므로(거절된다) 역시 세지 않는다. + async fn is_metered(&self, call: &ReadToolCall) -> bool { + !(is_bridge_only_key(call.file_key()) || self.preferred.can_serve(call).await) + } } /// 브리지만 읽을 수 있는 키의 읽기를 브리지가 맡지 못할 때의 거절. diff --git a/crates/devup-mcp-figma/src/bridge/holder.rs b/crates/devup-mcp-figma/src/bridge/holder.rs new file mode 100644 index 00000000..d369f5dd --- /dev/null +++ b/crates/devup-mcp-figma/src/bridge/holder.rs @@ -0,0 +1,263 @@ +//! 포트를 쥔 프로세스를 운영체제에 묻는다. +//! +//! 중계 규약을 모르는 쪽 — 이 기능 이전의 devup-mcp 나 다른 프로그램 — 은 자신을 +//! 밝히지 않는다. 그래도 사람이 찾아 끝낼 수 있어야 하므로, 그 포트에서 듣는 +//! 프로세스를 OS 에 묻는다: Windows 는 `netstat`·`tasklist`·`Get-Process`, macOS 는 +//! `lsof`·`ps`, Linux 는 `/proc`. +//! +//! 묻기만 하고 그 프로세스나 실행 파일은 건드리지 않는다. 예전 devup-mcp 의 버전을 +//! 알려고 그 실행 파일을 `--version` 으로 돌릴 수도 있지만, 포트를 쥔 것이 다른 +//! 사용자의 프로그램일 수도 있어 그것을 이 사용자 권한으로 실행하지 않는다. 버전은 +//! 모르는 채로 두고, pid 와 실행 파일 경로로 어느 세션인지 가리킨다. + +/// 외부 명령 하나에 주는 시간. 넘기면 모르는 것으로 둔다. +#[cfg(any(windows, target_os = "macos"))] +const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// 포트를 쥔 프로세스. OS 가 알려 준 만큼만 채운다. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PortOwner { + pub pid: u32, + /// 프로세스 이름(`devup-mcp.exe`, `node` 등). + pub name: Option, + /// 실행 파일의 전체 경로. 어느 클라이언트가 설치한 것인지 여기서 드러난다. + pub executable: Option, +} + +/// `127.0.0.1:port` 에서 듣는 프로세스. +pub(crate) async fn owner_of(port: u16) -> Option { + let pid = listening_pid(port).await?; + let (name, executable) = describe(pid).await; + Some(PortOwner { + pid, + name, + executable, + }) +} + +/// 명령을 돌려 표준 출력을 받는다. 없거나, 실패하거나, 제시간에 끝나지 않으면 `None`. +#[cfg(any(windows, target_os = "macos"))] +async fn run(program: &str, arguments: &[&str]) -> Option { + let mut command = tokio::process::Command::new(program); + command + .args(arguments) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + let output = tokio::time::timeout(COMMAND_TIMEOUT, command.output()) + .await + .ok()? + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// `netstat -ano` 의 한 줄에서 그 포트를 듣는 소켓의 pid. +/// +/// 상태 칸(`LISTENING`)은 Windows 언어에 따라 번역되고 공백을 품기도 한다(`수신 대기`). +/// 그래서 상태 대신 상대 주소로 가린다 — 듣는 소켓만 상대가 `0.0.0.0:0` 이나 +/// `[::]:0` 이다. pid 는 늘 마지막 칸이다. +#[cfg(any(windows, test))] +fn netstat_listener(line: &str, port: u16) -> Option { + let fields: Vec<&str> = line.split_whitespace().collect(); + let [protocol, local, remote, ..] = fields.as_slice() else { + return None; + }; + let tcp = protocol.eq_ignore_ascii_case("TCP"); + let listening = matches!(*remote, "0.0.0.0:0" | "[::]:0"); + // `127.0.0.1:1993` 이나 `[::]:1993` — 포트는 마지막 `:` 뒤다. + let local_port = local.rsplit_once(':')?.1.parse::().ok()?; + if !(tcp && listening && local_port == port) { + return None; + } + fields.last()?.parse().ok() +} + +/// `tasklist /FO CSV /NH` 의 첫 칸, 따옴표를 벗긴 이미지 이름. +#[cfg(any(windows, test))] +fn tasklist_name(output: &str) -> Option { + let line = output.lines().find(|line| line.starts_with('"'))?; + let name = line.split("\",\"").next()?.trim_start_matches('"'); + (!name.is_empty()).then(|| name.to_owned()) +} + +#[cfg(windows)] +async fn listening_pid(port: u16) -> Option { + let output = run("netstat", &["-ano"]).await?; + output.lines().find_map(|line| netstat_listener(line, port)) +} + +#[cfg(windows)] +async fn describe(pid: u32) -> (Option, Option) { + let filter = format!("PID eq {pid}"); + let script = format!("(Get-Process -Id {pid} -ErrorAction SilentlyContinue).Path"); + let tasklist = ["/FI", filter.as_str(), "/FO", "CSV", "/NH"]; + let powershell = ["-NoProfile", "-NonInteractive", "-Command", script.as_str()]; + // 둘 다 외부 명령이다. 차례로 돌리면 PowerShell 이 뜨는 시간만큼 더 걸린다. + let (name, executable) = + tokio::join!(run("tasklist", &tasklist), run("powershell", &powershell)); + ( + name.and_then(|output| tasklist_name(&output)), + executable + .map(|output| output.trim().to_owned()) + .filter(|path| !path.is_empty()), + ) +} + +#[cfg(target_os = "macos")] +async fn listening_pid(port: u16) -> Option { + let filter = format!("-iTCP:{port}"); + let output = run("lsof", &["-nP", &filter, "-sTCP:LISTEN", "-Fp"]).await?; + output + .lines() + .find_map(|line| line.strip_prefix('p')?.parse().ok()) +} + +#[cfg(target_os = "macos")] +async fn describe(pid: u32) -> (Option, Option) { + // On macOS `comm` is the executable's full path. + let executable = run("ps", &["-o", "comm=", "-p", &pid.to_string()]) + .await + .map(|output| output.trim().to_owned()) + .filter(|path| !path.is_empty()); + let name = executable.as_deref().and_then(|path| { + std::path::Path::new(path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + }); + (name, executable) +} + +/// `/proc/net/tcp{,6}` 의 한 줄에서, 그 포트를 듣는(`0A`) 소켓의 inode. +#[cfg(any(target_os = "linux", test))] +fn proc_net_listener(line: &str, port: u16) -> Option { + let fields: Vec<&str> = line.split_whitespace().collect(); + let local = fields.get(1)?; + let state = fields.get(3)?; + let inode = fields.get(9)?; + let local_port = u16::from_str_radix(local.rsplit_once(':')?.1, 16).ok()?; + (local_port == port && *state == "0A").then_some(())?; + inode.parse().ok().filter(|inode| *inode != 0) +} + +#[cfg(target_os = "linux")] +async fn listening_pid(port: u16) -> Option { + tokio::task::spawn_blocking(move || { + let inode = ["/proc/net/tcp", "/proc/net/tcp6"] + .iter() + .find_map(|table| { + std::fs::read_to_string(table) + .ok()? + .lines() + .skip(1) + .find_map(|line| proc_net_listener(line, port)) + })?; + let socket = format!("socket:[{inode}]"); + // 같은 사용자의 프로세스만 fd 를 읽을 수 있다. 다른 사용자의 것이면 찾지 못한다. + std::fs::read_dir("/proc") + .ok()? + .flatten() + .find_map(|entry| { + let pid: u32 = entry.file_name().to_str()?.parse().ok()?; + std::fs::read_dir(entry.path().join("fd")) + .ok()? + .flatten() + .any(|fd| { + std::fs::read_link(fd.path()) + .is_ok_and(|target| target.as_os_str() == socket.as_str()) + }) + .then_some(pid) + }) + }) + .await + .ok() + .flatten() +} + +#[cfg(target_os = "linux")] +async fn describe(pid: u32) -> (Option, Option) { + let name = std::fs::read_to_string(format!("/proc/{pid}/comm")) + .ok() + .map(|name| name.trim().to_owned()) + .filter(|name| !name.is_empty()); + let executable = std::fs::read_link(format!("/proc/{pid}/exe")) + .ok() + .map(|path| path.to_string_lossy().into_owned()); + (name, executable) +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] +async fn listening_pid(_port: u16) -> Option { + None +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] +async fn describe(_pid: u32) -> (Option, Option) { + (None, None) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The state column is translated, and in Korean it holds a space. + #[test] + fn a_listener_is_found_in_netstat_in_any_language() { + let port = 1993; + for line in [ + " TCP 127.0.0.1:1993 0.0.0.0:0 LISTENING 32536", + " TCP 127.0.0.1:1993 0.0.0.0:0 수신 대기 32536", + " TCP [::]:1993 [::]:0 LISTENING 32536", + ] { + assert_eq!(netstat_listener(line, port), Some(32536), "{line}"); + } + for line in [ + // A connection to the port, not a listener. + " TCP 127.0.0.1:1993 127.0.0.1:51234 ESTABLISHED 32536", + // A listener on another port. + " TCP 127.0.0.1:19930 0.0.0.0:0 LISTENING 777", + " UDP 127.0.0.1:1993 *:* 888", + "Active Connections", + ] { + assert_eq!(netstat_listener(line, port), None, "{line}"); + } + } + + #[test] + fn tasklist_gives_the_image_name() { + assert_eq!( + tasklist_name("\"devup-mcp.exe\",\"32536\",\"Console\",\"1\",\"28,000 K\"\r\n") + .as_deref(), + Some("devup-mcp.exe") + ); + assert_eq!( + tasklist_name("INFO: No tasks are running which match the specified criteria.\r\n"), + None + ); + } + + #[test] + fn a_listener_is_found_in_proc_net_tcp() { + // 127.0.0.1:1993 (0x07C9) listening, inode 424242. + let listening = " 0: 0100007F:07C9 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 424242 1 0000000000000000 100 0 0 10 0"; + let established = " 1: 0100007F:07C9 0100007F:C350 01 00000000:00000000 00:00000000 00000000 1000 0 525252 1 0000000000000000 20 4 30 10 -1"; + assert_eq!(proc_net_listener(listening, 1993), Some(424242)); + assert_eq!(proc_net_listener(established, 1993), None); + assert_eq!(proc_net_listener(listening, 1994), None); + } + + /// The real thing: this test process listens, and the OS names it. + #[tokio::test] + async fn the_process_listening_on_a_port_is_named() { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let owner = owner_of(port) + .await + .expect("the operating system names the listener"); + assert_eq!(owner.pid, std::process::id()); + assert!(owner.name.is_some(), "{owner:?}"); + drop(listener); + } +} diff --git a/crates/devup-mcp-figma/src/bridge/relay.rs b/crates/devup-mcp-figma/src/bridge/relay.rs index d446a5ac..fc28b8e1 100644 --- a/crates/devup-mcp-figma/src/bridge/relay.rs +++ b/crates/devup-mcp-figma/src/bridge/relay.rs @@ -2,11 +2,14 @@ //! //! # 왜 이렇게 하나 //! -//! 플러그인은 바꿀 수 없다. 이미 설치된 플러그인은 manifest 의 `allowedDomains` -//! 에 적힌 `ws://localhost:1993` 하나에만 붙고, Figma 는 그 목록을 실행 중에 바꾸지 -//! 못한다. 붙으면 `hello` 로 파일·페이지·선택을 알리고, 선택이 바뀔 때마다 -//! `context` 를 보내고, 받은 `devup-job` 을 하나씩 순서대로 실행해 `devup-result` -//! 로 답한다. 소켓이 닫히면 2초 뒤 같은 주소로 다시 붙는다(`plugin/src/ui.ts`). +//! 설치된 플러그인에 기댈 수 있는 것은 적다 — 사용자가 다시 불러오기 전까지는 예전 +//! 빌드가 돈다. 어느 빌드든 manifest 의 `allowedDomains` 에 적힌 +//! `ws://localhost:1993` 하나에만 붙고, Figma 는 그 목록을 실행 중에 바꾸지 못한다. +//! 붙으면 `hello` 로 파일·페이지·선택을 알리고, 선택이 바뀔 때마다 `context` 를 +//! 보내고, 받은 `devup-job` 을 하나씩 순서대로 실행해 `devup-result` 로 답한다. +//! 소켓이 닫히면 2초 뒤 같은 주소로 다시 붙는다(`plugin/src/ui.ts`). 새 빌드는 +//! 여기에 창마다 정한 `sessionId` 를 `hello` 에 싣고 `devup-cancel` 을 알아듣는다. +//! 둘 다 없는 예전 빌드도 그대로 동작해야 한다. //! //! 그래서 포트를 잡은 프로세스(호스트)가 플러그인을 받고, 나머지는 호스트에 붙어 //! 읽기를 맡긴다(중계). 호스트가 떠나면 남은 프로세스 가운데 하나가 포트를 @@ -39,11 +42,16 @@ //! 붙거나 떠나거나 선택이 바뀌면 호스트가 `relay-files` 로 모두에게 같은 목록을 //! 보낸다. //! -//! 중계가 끊기면 호스트는 그 중계의 읽기를 거둔다. 플러그인에는 취소를 보낼 길이 -//! 없어(플러그인은 바꾸지 않는다) 이미 넘긴 작업은 끝까지 돌지만, 그 답은 버려진다. -//! 호스트가 끊기면 중계의 읽기는 기다리지 않고 곧장 실패하고, 중계는 곧바로 포트를 -//! 잡아 본다. OS 가 포트를 한 소켓에만 주므로 둘이 동시에 호스트가 되지 않는다 — -//! 진 쪽은 이긴 쪽에 중계로 붙는다. +//! 중계가 끊기면 호스트는 그 중계의 읽기를 거두고 플러그인에 `devup-cancel` 을 +//! 보낸다. 플러그인은 차례를 기다리던 작업이면 돌리지 않고, 이미 돌고 있던 작업이면 +//! 답을 보내지 않는다. 그 말을 모르는 예전 빌드는 끝까지 돌리고, 그 답은 버려진다. +//! +//! 호스트가 끊기면 중계는 곧바로 포트를 잡아 본다. OS 가 포트를 한 소켓에만 주므로 +//! 둘이 동시에 호스트가 되지 않는다 — 진 쪽은 이긴 쪽에 중계로 붙는다. 그때 진행 +//! 중이던 읽기는 실패로 끝나지 않는다. 플러그인이 새 호스트에 다시 붙으면 다시 +//! 보낸다([`BridgeState::dispatch`]) — 읽기는 문서를 바꾸지 않으므로 두 번 돌아도 +//! 해가 없고, 수집을 처음부터 다시 하는 것보다 낫다. 파일 키가 없는 플러그인도 +//! `sessionId` 로 불리므로 다시 붙은 뒤에 같은 창을 찾는다. //! //! # 누가 쥐었는지 //! @@ -58,6 +66,11 @@ //! //! 어느 쪽이든 답을 기다리는 데 한도가 있다. 대답하지 않는 상대도 몇 초 안에 //! 판정되고, 그 뒤로는 주기적으로 다시 확인한다. +//! +//! 뒤의 둘은 자신을 밝히지 않으므로 그 포트에서 듣는 프로세스를 OS 에 물어 pid·이름· +//! 실행 파일을 붙인다([`super::holder`]). 사람이 그것을 보고 어느 세션인지 찾는다. +//! 묻는 데는 외부 명령이 들어서 status 는 그것을 기다리지 않는다 — 먼저 답하고, +//! 알게 되는 대로 덧붙인다. use std::{ collections::HashMap, @@ -92,8 +105,9 @@ use tokio_tungstenite::{ }; use super::{ - AttachedFile, BridgeIssue, BridgePeer, BridgeServed, BridgeState, Connected, JOB_TIMEOUT, - LinkState, PluginContext, Waiting, attached_file, + AttachedFile, BridgeIssue, BridgePeer, BridgeServed, BridgeState, Connected, Failure, + JOB_TIMEOUT, LinkState, PluginContext, Waiting, + holder::{self, PortOwner}, secret::{self, RelaySecret, Side}, shut_down, unavailable, }; @@ -126,11 +140,13 @@ const RELAY_SLACK: Duration = Duration::from_secs(5); type ClientSocket = WebSocketStream; -/// 호스트가 중계에 보내는 플러그인 하나. +/// 호스트가 중계에 보내는 플러그인 하나. 부를 이름(`targetKey`)도 호스트가 정한 +/// 그대로 보낸다 — 그래야 어느 프로세스가 읽기를 보내도 같은 창에 닿는다. #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct WireFile { connection: u64, + target_key: String, #[serde(default)] file_key: Option, #[serde(default)] @@ -147,6 +163,7 @@ fn wire_files(plugins: &HashMap) -> Vec { let plugin = &plugins[&id]; WireFile { connection: id, + target_key: plugin.routing_key.clone(), file_key: plugin.reported_key(), file_name: plugin.file_name.clone(), context: plugin.context.clone(), @@ -169,7 +186,12 @@ fn files_from(message: &Value) -> Vec<(u64, AttachedFile)> { .map(|file| { ( file.connection, - attached_file(file.connection, file.file_key, file.file_name, file.context), + AttachedFile { + target_key: file.target_key, + file_key: file.file_key, + file_name: file.file_name, + context: file.context, + }, ) }) .collect() @@ -322,7 +344,9 @@ async fn serve_relay(state: BridgeState, mut socket: WebSocket) { let Ok(job) = serde_json::from_value::(message) else { continue }; let (state, outbox) = (state.clone(), outbox.clone()); reads.spawn(async move { - let answer = match state.dispatch_local(&job.file_key, &job.script, job.params).await { + // 호스트 자신의 읽기와 같은 길이다. 플러그인이 도중에 다시 붙으면 + // 돌아오기를 기다렸다가 다시 보낸다. + let answer = match state.dispatch(&job.file_key, &job.script, job.params).await { Ok((data, served)) => json!({ "kind": "relay-result", "id": job.id, "data": data, "served": served, }), @@ -372,12 +396,13 @@ pub(crate) struct RelayConnection { } impl RelayConnection { - fn gone(&self) -> DevupError { - unavailable(format!( - "the devup-mcp holding the Devup Bridge port (pid {}) went away mid-read. Another \ - process takes the port over and the plugin reconnects within seconds; repeat the call.", + /// 호스트가 읽기 도중에 떠났다. 이어받은 쪽에 플러그인이 돌아오면 다시 보낸다. + fn gone(&self) -> Failure { + Failure::Interrupted(unavailable(format!( + "the devup-mcp holding the Devup Bridge port (pid {}) went away mid-read, and the \ + plugin did not come back through whichever process took the port over.", self.host.pid - )) + ))) } pub(crate) async fn dispatch( @@ -385,7 +410,7 @@ impl RelayConnection { file_key: &str, script: &str, params: Value, - ) -> Result<(Value, BridgeServed), DevupError> { + ) -> Result<(Value, BridgeServed), Failure> { let id = self.next.fetch_add(1, Ordering::Relaxed); let (tx, rx) = oneshot::channel(); self.pending @@ -405,16 +430,17 @@ impl RelayConnection { match timeout(JOB_TIMEOUT + RELAY_SLACK, rx).await { Ok(Ok(Answer::Data(data, served))) => Ok((data, served)), // 호스트가 붙인 문구를 그대로 올린다. 스크립트가 던진 DEVUP_* 코드도 - // 그 안에 있고, 위쪽 분기는 그 문자열로 판정한다. - Ok(Ok(Answer::Failed(message))) => Err(DevupError::new( + // 그 안에 있고, 위쪽 분기는 그 문자열로 판정한다. 호스트가 이미 다시 + // 보내 볼 만큼 보냈으므로 여기서 또 보내지 않는다. + Ok(Ok(Answer::Failed(message))) => Err(Failure::Final(DevupError::new( ErrorCode::DevupFigmaDirectUnavailable, message, false, - )), + ))), Ok(Err(_)) => Err(self.gone()), - Err(_) => Err(unavailable( + Err(_) => Err(Failure::Final(unavailable( "the Devup Bridge plugin did not answer in time", - )), + ))), } } @@ -458,6 +484,7 @@ fn foreign(detail: impl Into) -> Probe { Probe::Refused { issue: BridgeIssue::ForeignProgram { detail: detail.into(), + owner: None, }, holder: None, } @@ -535,7 +562,7 @@ async fn legacy_or_foreign(port: u16) -> Probe { Ok(mut socket) => { let _ = socket.close(None).await; Probe::Refused { - issue: BridgeIssue::LegacyHost, + issue: BridgeIssue::LegacyHost { owner: None }, holder: None, } } @@ -669,10 +696,12 @@ async fn join(state: &BridgeState, port: u16) -> Probe { async fn load_secret(state: &BridgeState) -> Result { let Some(path) = state.link.secret_path.as_deref() else { - return Err( - "there is no per-user directory to keep it in (USERPROFILE or HOME is not set)" - .to_owned(), - ); + return Err(if cfg!(windows) { + "there is no per-user directory to keep it in (USERPROFILE is not set)" + } else { + "there is no per-user directory to keep it in (/tmp cannot be written)" + } + .to_owned()); }; secret::load_or_create(path) .await @@ -739,7 +768,8 @@ async fn drive(state: &BridgeState, joined: Joined, expecting: Option)>, +} + +impl Owners { + /// 이미 아는 만큼 붙인다. 묻지는 않는다. + fn attach(&self, issue: BridgeIssue) -> BridgeIssue { + let owner = self.known.as_ref().and_then(|(_, owner)| owner.clone()); + match issue { + BridgeIssue::LegacyHost { .. } => BridgeIssue::LegacyHost { owner }, + BridgeIssue::ForeignProgram { detail, .. } => { + BridgeIssue::ForeignProgram { detail, owner } + } + other => other, + } + } + + /// 물을 때가 됐는지 — 자신을 밝히지 않는 쪽이고, 마지막으로 물은 지 오래됐다. + fn due(&self, issue: &BridgeIssue) -> bool { + let silent = matches!( + issue, + BridgeIssue::LegacyHost { .. } | BridgeIssue::ForeignProgram { .. } + ); + silent + && !self + .known + .as_ref() + .is_some_and(|(at, _)| at.elapsed() < OWNER_TTL) + } + + async fn ask(&mut self) { + self.known = Some((Instant::now(), holder::owner_of(self.port).await)); + } + + /// 포트를 쥔 쪽이 바뀌었을 수 있다. 다음에는 다시 묻는다. + fn forget(&mut self) { + self.known = None; + } +} + /// 포트를 잡지 못한 프로세스의 일: 포트를 쥔 쪽을 통해 읽고, 그가 떠나면 이어받는다. pub(super) async fn supervise(state: BridgeState, port: u16) { let mut shutdown = state.link.shutdown_signal(); @@ -755,6 +832,7 @@ pub(super) async fn supervise(state: BridgeState, port: u16) { let mut expecting: Option = None; let mut takeover_until: Option = None; let mut vacant = 0_u32; + let mut owners = Owners { port, known: None }; loop { if state.link.is_shut_down() { return; @@ -775,6 +853,7 @@ pub(super) async fn supervise(state: BridgeState, port: u16) { let pause = match probe { Probe::Joined(joined) => { vacant = 0; + owners.forget(); let host = joined.connection.host.clone(); let had_plugins = drive(&state, *joined, expecting.take()).await; expecting = had_plugins.then_some(host); @@ -783,9 +862,11 @@ pub(super) async fn supervise(state: BridgeState, port: u16) { } Probe::Vacant if taking_over || vacant < VACANT_RETRIES => { vacant += 1; + owners.forget(); QUICK_RETRY } Probe::Vacant => { + owners.forget(); state.link.set(LinkState::Unavailable { issue: BridgeIssue::BindFailed { detail: bind_error.to_string(), @@ -797,7 +878,23 @@ pub(super) async fn supervise(state: BridgeState, port: u16) { Probe::Refused { .. } if taking_over => QUICK_RETRY, Probe::Refused { issue, holder } => { vacant = 0; - state.link.set(LinkState::Unavailable { issue, holder }); + // 아는 만큼 곧장 알린다. 쥔 프로세스를 OS 에 묻는 데는 외부 명령이 + // 들어서(Windows 에서는 PowerShell 이 뜨기까지 몇 초) status 가 그것을 + // 기다리면 안 된다. 이름은 알게 되는 대로 덧붙인다. + state.link.set(LinkState::Unavailable { + issue: owners.attach(issue.clone()), + holder: holder.clone(), + }); + if owners.due(&issue) { + tokio::select! { + () = owners.ask() => {} + () = shut_down(&mut shutdown) => return, + } + state.link.set(LinkState::Unavailable { + issue: owners.attach(issue), + holder, + }); + } RETRY_INTERVAL } }; diff --git a/crates/devup-mcp-figma/src/bridge/secret.rs b/crates/devup-mcp-figma/src/bridge/secret.rs index 75e00fdb..76982810 100644 --- a/crates/devup-mcp-figma/src/bridge/secret.rs +++ b/crates/devup-mcp-figma/src/bridge/secret.rs @@ -127,38 +127,64 @@ pub(crate) fn nonce() -> String { URL_SAFE_NO_PAD.encode(bytes) } -/// 비밀값 파일의 기본 자리. 그 사용자만 쓰는 디렉터리 아래다. +/// 비밀값 파일의 기본 자리. 그 사용자만 들어갈 수 있는 디렉터리 아래다. /// /// 같은 사용자의 devup-mcp 는 어느 클라이언트가 띄웠든 같은 자리를 봐야 한다. -/// 클라이언트마다 넘기는 환경 변수가 달라서, 누구에게나 있는 값 하나만 쓴다 — -/// Windows 는 `USERPROFILE`(Git Bash 가 넣는 `HOME` 은 셸마다 다르다), 그 밖은 -/// `HOME`. `XDG_STATE_HOME` 처럼 어떤 클라이언트는 넘기고 어떤 클라이언트는 -/// 지우는 값을 따르면, 같은 사용자의 두 프로세스가 서로를 알아보지 못한다. +/// 그런데 클라이언트마다 넘기는 환경이 다르다 — `HOME` 을 제 샌드박스로 바꿔 띄우는 +/// 클라이언트가 있고, `XDG_STATE_HOME` 은 넘기는 쪽과 지우는 쪽이 있다. 환경을 +/// 따르면 같은 사용자의 두 프로세스가 서로 다른 비밀값을 읽어 서로를 알아보지 못한다. /// -/// Windows 에서는 따로 권한을 좁히지 않는다. `%USERPROFILE%\AppData\Local` 아래의 -/// 파일은 사용자 프로필의 ACL(그 사용자, SYSTEM, Administrators)을 물려받는다. +/// 그래서 Unix 에서는 환경이 아니라 사용자 번호가 자리를 정한다: +/// `/tmp/devup-mcp-/`. `/tmp` 는 누구나 쓰는 곳이라, 그 디렉터리가 이 사용자의 +/// 것이고 다른 사용자가 들어올 수 없는지를 쓸 때마다 확인한다([`secure_directory`]). +/// 재부팅하면 지워지지만, 그때는 그 비밀값을 알던 프로세스도 모두 끝났다. +/// +/// Windows 는 `USERPROFILE` 이다. 시스템이 사용자마다 정하는 값이라 클라이언트가 +/// 바꾸지 않는다(Git Bash 가 넣는 `HOME` 은 셸마다 다르다). 따로 권한을 좁히지 +/// 않는다 — `%USERPROFILE%\AppData\Local` 아래의 파일은 사용자 프로필의 ACL(그 +/// 사용자, SYSTEM, Administrators)을 물려받는다. pub(crate) fn default_path() -> Option { #[cfg(windows)] - let base = std::env::var_os("USERPROFILE") + let directory = std::env::var_os("USERPROFILE") .filter(|home| !home.is_empty()) .map(|home| PathBuf::from(home).join("AppData").join("Local")) .or_else(|| { std::env::var_os("LOCALAPPDATA") .filter(|path| !path.is_empty()) .map(PathBuf::from) - }); - #[cfg(target_os = "macos")] - let base = home().map(|home| home.join("Library").join("Application Support")); - #[cfg(all(not(windows), not(target_os = "macos")))] - let base = home().map(|home| home.join(".local").join("state")); - base.map(|base| base.join("devup-mcp").join(FILE_NAME)) + }) + .map(|base| base.join("devup-mcp")); + #[cfg(unix)] + let directory = user_id().map(|uid| Path::new("/tmp").join(format!("devup-mcp-{uid}"))); + #[cfg(not(any(windows, unix)))] + let directory: Option = None; + directory.map(|directory| directory.join(FILE_NAME)) } -#[cfg(not(windows))] -fn home() -> Option { - std::env::var_os("HOME") - .filter(|home| !home.is_empty()) - .map(PathBuf::from) +/// 이 프로세스의 사용자 번호. +/// +/// 표준 라이브러리에는 `getuid` 가 없고 libc 의 것은 `unsafe` 로만 부를 수 있어서, +/// 방금 만든 파일의 주인을 읽는다 — 파일은 그것을 만든 프로세스의 사용자 것이 된다. +/// 비밀값을 둘 `/tmp` 에 만들어 본다. 거기에 쓸 수 없으면 어차피 둘 곳이 없다. +#[cfg(unix)] +fn user_id() -> Option { + use std::os::unix::fs::MetadataExt; + static USER_ID: std::sync::OnceLock> = std::sync::OnceLock::new(); + *USER_ID.get_or_init(|| { + let probe = Path::new("/tmp").join(format!( + ".devup-mcp-uid-{}-{:016x}", + std::process::id(), + rand::random::() + )); + let uid = OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe) + .and_then(|file| file.metadata()) + .map(|metadata| metadata.uid()); + let _ = std::fs::remove_file(&probe); + uid.ok() + }) } enum Stored { @@ -184,6 +210,13 @@ fn stored(path: &Path) -> io::Result { /// 비밀값을 읽는다. 없으면 만든다. pub(crate) async fn load_or_create(path: &Path) -> io::Result { + if let Some(directory) = path.parent() { + // 다른 사용자가 들어올 수 있던 디렉터리라면, 거기 있던 비밀값은 이미 새어 + // 나갔다고 본다. + if secure_directory(directory)? { + return replace(path); + } + } for _ in 0..READ_ATTEMPTS { match stored(path)? { Stored::Secret(secret) => return Ok(secret), @@ -202,9 +235,6 @@ pub(crate) async fn load_or_create(path: &Path) -> io::Result { } fn create(path: &Path) -> io::Result { - if let Some(directory) = path.parent() { - create_private_directory(directory)?; - } let secret = RelaySecret::generate(); let mut file = private_file().write(true).create_new(true).open(path)?; file.write_all(secret.encoded().as_bytes())?; @@ -213,18 +243,19 @@ fn create(path: &Path) -> io::Result { } /// 새 값을 옆에 다 쓴 뒤 이름을 바꿔 넣는다. 읽는 쪽은 옛 값이나 새 값 하나만 본다. +/// +/// 옆 파일은 매번 새 이름으로 새로 만든다(`create_new`). 디렉터리가 열려 있던 사이에 +/// 누가 그 이름으로 링크를 심어 두었더라도 따라가 쓰지 않고, 한 프로세스 안에서 둘이 +/// 동시에 바꿔 넣어도 서로의 파일을 덮지 않는다. fn replace(path: &Path) -> io::Result { - if let Some(directory) = path.parent() { - create_private_directory(directory)?; - } let secret = RelaySecret::generate(); - let staged = path.with_extension(format!("key.{}.tmp", std::process::id())); + let staged = path.with_extension(format!( + "key.{}.{:016x}.tmp", + std::process::id(), + rand::random::() + )); let written = (|| { - let mut file = private_file() - .write(true) - .create(true) - .truncate(true) - .open(&staged)?; + let mut file = private_file().write(true).create_new(true).open(&staged)?; file.write_all(secret.encoded().as_bytes())?; file.sync_all()?; std::fs::rename(&staged, path) @@ -248,25 +279,47 @@ fn private_file() -> OpenOptions { OpenOptions::new() } +/// 비밀값을 둘 디렉터리를 만들거나 확인한다. 다른 사용자가 들어올 수 있게 열려 +/// 있었는지를 돌려준다 — 그랬다면 닫고, 거기 있던 비밀값은 버려야 한다. +/// +/// `/tmp` 에서는 다른 사용자가 같은 이름을 먼저 만들어 둘 수 있다. 그 디렉터리나 그 +/// 이름의 심볼릭 링크에 비밀값을 두면 그 사용자가 가져가므로, 이 사용자의 진짜 +/// 디렉터리가 아니면 거절한다. #[cfg(unix)] -fn create_private_directory(directory: &Path) -> io::Result<()> { - use std::os::unix::fs::DirBuilderExt; +fn secure_directory(directory: &Path) -> io::Result { + use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; std::fs::DirBuilder::new() .recursive(true) .mode(0o700) - .create(directory) + .create(directory)?; + let metadata = std::fs::symlink_metadata(directory)?; + if !metadata.is_dir() || Some(metadata.uid()) != user_id() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "{} is not a directory this user owns, so the secret is not kept there", + directory.display() + ), + )); + } + if metadata.permissions().mode() & 0o077 == 0 { + return Ok(false); + } + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?; + Ok(true) } #[cfg(not(unix))] -fn create_private_directory(directory: &Path) -> io::Result<()> { - std::fs::create_dir_all(directory) +fn secure_directory(directory: &Path) -> io::Result { + std::fs::create_dir_all(directory).map(|()| false) } -/// 그룹이나 다른 사용자가 읽을 수 없는지. +/// 이 사용자의 것이고, 그룹이나 다른 사용자가 읽을 수 없는지. #[cfg(unix)] fn private(path: &Path) -> io::Result { - use std::os::unix::fs::PermissionsExt; - Ok(std::fs::metadata(path)?.permissions().mode() & 0o077 == 0) + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let metadata = std::fs::metadata(path)?; + Ok(metadata.permissions().mode() & 0o077 == 0 && Some(metadata.uid()) == user_id()) } #[cfg(not(unix))] @@ -325,7 +378,52 @@ mod tests { "a secret others could read is not reused" ); assert_eq!(mode(&path) & 0o777, 0o600); - let _ = std::fs::remove_dir_all(path.parent().unwrap()); + + // A directory others could enter: they could have read the file, or put + // their own in its place. It is closed and its secret replaced. + let directory = path.parent().unwrap(); + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o755)).unwrap(); + let replaced = load_or_create(&path).await.unwrap(); + assert_ne!( + replaced.0, rotated.0, + "a secret others could reach is not reused" + ); + assert_eq!(mode(directory) & 0o777, 0o700); + assert_eq!(load_or_create(&path).await.unwrap().0, replaced.0); + let _ = std::fs::remove_dir_all(directory); + } + + /// Where the secret lives on Unix follows who the process runs as, not + /// its environment: clients hand their servers different `HOME`s. + #[cfg(unix)] + #[test] + fn the_default_place_is_this_users_own_under_tmp() { + use std::os::unix::fs::MetadataExt; + let mine = scratch(); + std::fs::create_dir_all(mine.parent().unwrap()).unwrap(); + let uid = std::fs::metadata(mine.parent().unwrap()).unwrap().uid(); + assert_eq!( + default_path(), + Some(PathBuf::from(format!("/tmp/devup-mcp-{uid}/{FILE_NAME}"))) + ); + let _ = std::fs::remove_dir_all(mine.parent().unwrap()); + } + + /// A name under `/tmp` that is not this user's directory - here a link to + /// one - is refused rather than used. + #[cfg(unix)] + #[tokio::test] + async fn a_directory_that_is_not_this_users_own_is_refused() { + let real = scratch(); + let real_directory = real.parent().unwrap().to_path_buf(); + std::fs::create_dir_all(&real_directory).unwrap(); + let link = real_directory.with_extension("link"); + std::os::unix::fs::symlink(&real_directory, &link).unwrap(); + let error = load_or_create(&link.join(FILE_NAME)).await.unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); + assert!(!real_directory.join(FILE_NAME).exists()); + let _ = std::fs::remove_file(&link); + let _ = std::fs::remove_dir_all(&real_directory); } #[test] diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index 89205cd5..ef84d156 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -25,7 +25,7 @@ pub use collector::{ pub use bridge::{ AttachedFile, BridgeFigmaClient, BridgeIssue, BridgeJob, BridgeOptions, BridgePathSnapshot, BridgePeer, BridgeRole, BridgeServed, BridgeServer, BridgeState, DEFAULT_BRIDGE_PORT, - FallbackUpstream, NodeRef, PluginContext, PreferredUpstream, + FallbackUpstream, NodeRef, PluginContext, PortOwner, PreferredUpstream, }; pub use credentials::{ ClientCredentialStore, ClientCredentials, CredentialStore, KeyringClientCredentialStore, diff --git a/crates/devup-mcp-figma/src/upstream.rs b/crates/devup-mcp-figma/src/upstream.rs index a5b66b67..76e31225 100644 --- a/crates/devup-mcp-figma/src/upstream.rs +++ b/crates/devup-mcp-figma/src/upstream.rs @@ -847,6 +847,15 @@ pub trait FigmaUpstream: Send + Sync { async fn bridge_path_snapshot(&self) -> Option { None } + + /// 이 읽기가 Figma 가 세는 경로로 가는지 — 분당 한도에 맞춰 늦춰야 하는지. + /// + /// 플러그인이 답하는 읽기는 한도를 쓰지 않는다. 그런데도 모든 읽기를 한도의 + /// 속도(분당 여덟)로 늦췄더니, 같은 서버의 두 번째 export 가 쓰지도 않는 한도를 + /// 1분 가까이 기다렸다. 원격만 아는 상류는 모든 읽기가 그 경로로 간다. + async fn is_metered(&self, _call: &ReadToolCall) -> bool { + true + } } #[derive(Clone)] diff --git a/crates/devup-mcp-figma/tests/bridge_relay.rs b/crates/devup-mcp-figma/tests/bridge_relay.rs index fd5fb6de..9df35e26 100644 --- a/crates/devup-mcp-figma/tests/bridge_relay.rs +++ b/crates/devup-mcp-figma/tests/bridge_relay.rs @@ -342,48 +342,71 @@ async fn a_held_port_is_never_bound_twice() { ); } +fn keyed_hello() -> Value { + json!({ + "kind": "hello", "fileKey": FILE_KEY, "fileName": "Landing", + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [], "selectionCount": 0, + }) +} + /// `plugin/src/ui.ts` 처럼 소켓이 닫히면 다시 붙는 플러그인. 받은 작업마다 물은 /// 노드를 담아 곧장 답한다. 테스트가 빨리 끝나도록 재시도 간격만 줄였다. -fn reconnecting_plugin(port: u16) { +/// +/// `hold_first` 면 처음 붙은 연결에서 받은 첫 작업에는 답하지 않고 붙들고 있다가, +/// 그 사실을 알린다 — 호스트가 떠날 때 진행 중이던 읽기를 흉내 낸다. +fn plugin_saying( + port: u16, + hello: Value, + hold_first: bool, +) -> tokio::sync::oneshot::Receiver { + let (held, holding) = tokio::sync::oneshot::channel(); tokio::spawn(async move { + let mut held = hold_first.then_some(held); loop { if let Ok((mut socket, _)) = connect_async(format!("ws://127.0.0.1:{port}/plugin")).await - { - let hello = json!({ - "kind": "hello", "fileKey": FILE_KEY, "fileName": "Landing", - "currentPage": { "id": "0:1", "name": "Page 1" }, - "selection": [], "selectionCount": 0, - }); - if socket + && socket .send(Message::Text(hello.to_string().into())) .await .is_ok() - { - while let Some(Ok(message)) = socket.next().await { - let Message::Text(text) = message else { - continue; - }; - let job: Value = serde_json::from_str(&text).expect("a job is JSON"); - let data = json!({ - "fileKey": FILE_KEY, "nodes": [{ "id": job["params"]["nodeId"] }] - }); - let result = json!({ - "kind": "devup-result", "requestId": job["requestId"], "data": data - }); - if socket - .send(Message::Text(result.to_string().into())) - .await - .is_err() - { - break; - } + { + while let Some(Ok(message)) = socket.next().await { + let Message::Text(text) = message else { + continue; + }; + let job: Value = serde_json::from_str(&text).expect("a job is JSON"); + if job["kind"] != "devup-job" { + continue; + } + if let Some(held) = held.take() { + let _ = held.send(job); + continue; + } + let data = json!({ + "fileKey": FILE_KEY, "nodes": [{ "id": job["params"]["nodeId"] }] + }); + let result = json!({ + "kind": "devup-result", "requestId": job["requestId"], "data": data + }); + if socket + .send(Message::Text(result.to_string().into())) + .await + .is_err() + { + break; } } } tokio::time::sleep(Duration::from_millis(100)).await; } }); + holding +} + +fn reconnecting_plugin(port: u16) { + // Nothing is held, so there is nothing to wait for. + drop(plugin_saying(port, keyed_hello(), false)); } async fn role_of(client: &BridgeFigmaClient) -> (BridgeRole, bool) { @@ -443,14 +466,16 @@ async fn when_the_holder_goes_away_exactly_one_remaining_process_takes_the_port_ } } -/// 호스트가 떠날 때 진행 중이던 읽기는 90초를 기다리지 않고 곧장 실패한다. 이유를 -/// 말하고, 다시 부르면 된다는 것도 말한다. +/// 호스트가 떠날 때 진행 중이던 읽기는 실패로 끝나지 않는다. 읽기는 문서를 바꾸지 +/// 않으므로 다시 보내도 된다 — 포트를 이어받은 쪽에 플러그인이 다시 붙으면 그리로 +/// 다시 보내고, 호출자는 인계가 있었는지 모른 채 답을 받는다. 수집 한가운데서 +/// 호스트 세션이 끝나도 그 수집은 이어진다. #[tokio::test] -async fn a_read_in_flight_when_the_holder_leaves_fails_at_once() { +async fn a_read_in_flight_when_the_holder_leaves_is_answered_through_the_next_holder() { let host = BridgeServer::start(0).expect("an ephemeral port is free"); let port = host.port(); let relay = second_process_on(port); - let mut plugin = plugin(port).await; + let holding = plugin_saying(port, keyed_hello(), true); sees_the_plugin(&relay).await; let client = BridgeFigmaClient::new(relay.state()).with_port(port); @@ -459,20 +484,146 @@ async fn a_read_in_flight_when_the_holder_leaves_fails_at_once() { .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) .await }); - let _job = next_text(&mut plugin).await; + let held = tokio::time::timeout(DEADLINE, holding) + .await + .expect("the read reaches the plugin") + .expect("the plugin reports the read it holds"); + assert_eq!(held["params"]["nodeId"], "1:2"); + host.shutdown(); + + let result = tokio::time::timeout(DEADLINE, reading) + .await + .expect("the read ends well within its 90 seconds") + .expect("the read task finishes") + .expect("the read is answered through the process that took the port over"); + let text = result.raw["content"][0]["text"].as_str().unwrap(); + let payload: Value = serde_json::from_str(text).unwrap(); + assert_eq!(payload["nodes"][0]["id"], "1:2"); +} + +/// 파일 키를 보고하지 못한 플러그인(Dev Mode)은 연결마다 매긴 키로 불린다. 호스트가 +/// 바뀌면 그 키도 바뀌어, 수집 도중에 호스트가 떠나면 남은 읽기가 갈 곳을 잃었다. +/// 플러그인이 창마다 한 번 정한 `sessionId` 를 보내면, 키가 그것에서 나와 인계를 +/// 건너서도 같다. +#[tokio::test] +async fn a_keyless_plugin_that_names_its_session_keeps_its_key_across_a_handover() { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + let port = host.port(); + let relay = second_process_on(port); + drop(plugin_saying( + port, + json!({ + "kind": "hello", "fileKey": null, "fileName": "Landing", + "sessionId": "5a3c0f6e2d9b41c7a8e1f0b2c3d4e5f6", + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [], "selectionCount": 0, + }), + false, + )); + let state = relay.state(); + eventually("the keyless plugin to show up through the holder", || { + let state = state.clone(); + async move { state.attached_files().await.len() == 1 } + }) + .await; + let before = state.attached_files().await[0].target_key.clone(); + assert!(devup_mcp_figma::is_bridge_only_key(&before), "{before}"); + host.shutdown(); + let client = BridgeFigmaClient::new(relay.state()).with_port(port); + eventually( + "the plugin to re-attach to the process that took over", + || { + let client = &client; + async move { role_of(client).await == (BridgeRole::Host, true) } + }, + ) + .await; + let after = state.attached_files().await[0].target_key.clone(); + assert_eq!( + after, before, + "the key a collection holds survives the handover" + ); + + let result = client + .call_read_tool(ReadToolCall::fast_snapshot(before, "1:2")) + .await + .expect("a read addressed before the handover still arrives"); + let text = result.raw["content"][0]["text"].as_str().unwrap(); + assert_eq!( + serde_json::from_str::(text).unwrap()["nodes"][0]["id"], + "1:2" + ); +} + +/// 플러그인이 읽기 도중 끊겼다가 돌아오지 않으면, 그 읽기는 90초를 기다리지 않고 +/// 곧 실패한다. 돌아올 틈은 준다 — 플러그인은 2초마다 다시 붙는다. +#[tokio::test] +async fn a_read_whose_plugin_leaves_and_does_not_return_fails_well_before_its_timeout() { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + let port = host.port(); + let mut plugin = plugin(port).await; + sees_the_plugin(&host).await; + + let client = BridgeFigmaClient::new(host.state()).with_port(port); + let reading = tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + }); + let _job = next_text(&mut plugin).await; + drop(plugin); let error = tokio::time::timeout(DEADLINE, reading) .await .expect("the read ends without waiting out its 90 seconds") .expect("the read task finishes") - .expect_err("the process that held the port is gone"); - assert!(error.message.contains("went away"), "{}", error.message); + .expect_err("no plugin came back to answer it"); + assert!(error.message.contains("disconnected"), "{}", error.message); +} + +/// 플러그인이 붙는 문은 Figma 플러그인 창의 요청만 받는다. 플러그인 UI 는 origin 이 +/// `null` 인 iframe 에서 돈다(Figma 문서 "Making Network Requests"). 여느 웹 페이지는 +/// 제 origin 을 실어 보내므로, 플러그인인 척 읽기 요청을 받아 가짜 답을 돌려줄 수 없다. +#[tokio::test] +async fn the_plugin_door_admits_the_plugin_and_turns_away_web_pages() { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + let port = host.port(); + let request = |origin: Option<&str>| { + let mut request = format!("ws://127.0.0.1:{port}/plugin") + .into_client_request() + .unwrap(); + if let Some(origin) = origin { + request + .headers_mut() + .insert("Origin", HeaderValue::from_str(origin).unwrap()); + } + request + }; + for page in ["https://example.com", "http://localhost:3000"] { + assert_eq!( + refused_status(request(Some(page))).await, + Some(403), + "a web page at {page} must not pose as the plugin" + ); + } + for plugin in [ + Some("null"), + Some("https://www.figma.com"), + Some("https://figma.com"), + None, + ] { + assert_eq!( + refused_status(request(plugin)).await, + None, + "{plugin:?} is how the plugin (or a native client) arrives" + ); + } } -/// 읽기를 맡긴 프로세스가 사라지면 호스트는 그 읽기를 거둔다. 플러그인에는 취소를 -/// 보낼 길이 없어 작업은 끝까지 돌지만, 늦게 온 답은 누구에게도 가지 않고 대기표도 -/// 남지 않는다. +/// 읽기를 맡긴 프로세스가 사라지면 호스트는 그 읽기를 거두고, 플러그인에도 그 작업을 +/// 거두라고 알린다(`devup-cancel`). 아직 차례를 기다리던 작업이면 플러그인은 돌리지 +/// 않는다. 늦게 온 답은 누구에게도 가지 않고 대기표도 남지 않는다. #[tokio::test] async fn a_read_is_cleared_from_the_holder_when_the_process_that_asked_goes_away() { let host = BridgeServer::start(0).expect("an ephemeral port is free"); @@ -497,6 +648,9 @@ async fn a_read_is_cleared_from_the_holder_when_the_process_that_asked_goes_away .expect("the read ends promptly in the process that is leaving") .expect("the read task finishes"); assert!(error.is_err()); + let withdrawn = next_text(&mut plugin).await; + assert_eq!(withdrawn["kind"], "devup-cancel", "{withdrawn}"); + assert_eq!(withdrawn["requestId"], job["requestId"]); eventually("the holder to drop the read nobody waits for", || { let state = state.clone(); async move { state.pending_reads() == 0 } diff --git a/crates/devup-mcp/src/lib.rs b/crates/devup-mcp/src/lib.rs index 954b2e61..f6ca1b15 100644 --- a/crates/devup-mcp/src/lib.rs +++ b/crates/devup-mcp/src/lib.rs @@ -337,9 +337,18 @@ pub fn self_check() -> SelfCheckReport { env_client_secret, env_client_name, ); + // The bridge stays shut: a self-check touches no network, and opening it + // would bind the plugin's port or reach into the process holding it. let server_ok = std::env::current_dir() .ok() - .and_then(|root| server::DevupServer::production_with_config(vec![root], figma_direct).ok()) + .and_then(|root| { + server::DevupServer::production_with_bridge( + vec![root], + figma_direct, + server::Bridge::Off, + ) + .ok() + }) .is_some(); SelfCheckReport { status: if credential_ok && server_ok { diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index 28ca3de8..e8496524 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -37,7 +37,7 @@ use devup_mcp_figma::{ AttachedFile, AuthStatus, BridgeIssue, BridgePathSnapshot, BridgePeer, BridgeRole, - ClientCredentialSource, DEFAULT_CLIENT_NAME, DirectPathSnapshot, TokenState, + ClientCredentialSource, DEFAULT_CLIENT_NAME, DirectPathSnapshot, PortOwner, TokenState, }; use serde::Serialize; use serde_json::{Value, json}; @@ -300,24 +300,41 @@ fn holder_text(bridge: &BridgePathSnapshot) -> String { .map_or_else(String::new, |host| format!(" ({})", describe_peer(host))) } +/// The process the operating system says holds the port, the way a person +/// finds it: its pid, and the executable, whose path shows which MCP client +/// installed it. Without that, how to look it up. +fn owner_text(owner: Option<&PortOwner>, port: &str) -> String { + match owner { + Some(owner) => { + let what = match (&owner.executable, &owner.name) { + (Some(executable), _) => format!(", {executable}"), + (None, Some(name)) => format!(", {name}"), + (None, None) => String::new(), + }; + format!("pid {}{what}", owner.pid) + } + None => find_the_holder(port), + } +} + /// The one step that would let this process use the bridge again. fn repair(bridge: &BridgePathSnapshot) -> String { let port = port_text(bridge); let holder = holder_text(bridge); match &bridge.issue { - Some(BridgeIssue::LegacyHost) => format!( - "The devup-mcp holding port {port} predates bridge sharing, so this process cannot read through it: restart (or update) the MCP client session that started it - {}. Once it exits, this process takes the port over by itself and the plugin reconnects to it.", - find_the_holder(&port) + Some(BridgeIssue::LegacyHost { owner }) => format!( + "The devup-mcp holding port {port} ({}) predates bridge sharing, so this process cannot read through it: restart (or update) the MCP client session that started it. Once it exits, this process takes the port over by itself and the plugin reconnects to it.", + owner_text(owner.as_ref(), &port) ), - Some(BridgeIssue::ForeignProgram { .. }) => format!( - "Stop the program holding port {port} - {}. The plugin's port is fixed by its manifest; once the port is free this process takes it over by itself.", - find_the_holder(&port) + Some(BridgeIssue::ForeignProgram { owner, .. }) => format!( + "Stop the program holding port {port} ({}). The plugin's port is fixed by its manifest; once the port is free this process takes it over by itself.", + owner_text(owner.as_ref(), &port) ), Some(BridgeIssue::IncompatibleProtocol { .. }) => format!( "The devup-mcp holding port {port}{holder} and this one are different releases that cannot relay to each other: restart the MCP client session whose devup-mcp is older so both run the same release." ), Some(BridgeIssue::AuthenticationFailed { .. }) => format!( - "Run every devup-mcp on this machine as the same user with the same home directory (they prove themselves to each other with a per-user secret kept there), or restart the MCP client session holding port {port}{holder}." + "Run every devup-mcp on this machine as the same user (they prove themselves to each other with a secret only that user can read), or restart the MCP client session holding port {port}{holder}." ), Some(BridgeIssue::SecretUnavailable { .. }) => format!( "Make the per-user relay secret readable and writable for this user (see paths.bridge.reason), or restart the MCP client session holding port {port}{holder} so this process can take the port over." @@ -453,6 +470,32 @@ fn bridge_path(bridge: Option<&BridgePathSnapshot>) -> Value { if let Some(issue) = &bridge.issue { path["issue"] = json!(issue.code()); } + match &bridge.issue { + // A devup-mcp from before sharing is still the port's devup-mcp, so it + // is the `host` - named by the operating system, since it cannot say + // its own version. + Some(BridgeIssue::LegacyHost { owner: Some(owner) }) => { + path["host"] = json!({ + "pid": owner.pid, + "version": null, + "buildId": null, + "name": owner.name, + "executable": owner.executable, + "thisProcess": false, + }); + } + // Something that is not a devup-mcp is no host; it is only in the way. + Some(BridgeIssue::ForeignProgram { + owner: Some(owner), .. + }) => { + path["holder"] = json!({ + "pid": owner.pid, + "name": owner.name, + "executable": owner.executable, + }); + } + _ => {} + } if let Some(previous) = &bridge.handover_from { path["handoverFrom"] = peer(previous, false); } @@ -513,13 +556,13 @@ fn bridge_reason(bridge: &BridgePathSnapshot) -> String { (BridgeRole::Connecting, _) => format!( "This process is still finding out which devup-mcp holds the bridge port {port}. Call status again in a moment." ), - (BridgeRole::Unavailable, Some(BridgeIssue::LegacyHost)) => format!( - "Port {port} is held by a devup-mcp built before the bridge could be shared: it serves the plugin on /plugin but has no relay endpoint, so only that process can use the plugin and this one cannot read through it. It cannot say which process it is; {}. To use the bridge here, restart (or update) the MCP client session that started it. This process keeps checking, and takes the port over by itself once that process exits.", - find_the_holder(&port) + (BridgeRole::Unavailable, Some(BridgeIssue::LegacyHost { owner })) => format!( + "Port {port} is held by a devup-mcp built before the bridge could be shared ({}): it serves the plugin on /plugin but has no relay endpoint, so only that process can use the plugin and this one cannot read through it. To use the bridge here, restart (or update) the MCP client session that started it. This process keeps checking, and takes the port over by itself once that process exits.", + owner_text(owner.as_ref(), &port) ), - (BridgeRole::Unavailable, Some(BridgeIssue::ForeignProgram { detail })) => format!( - "Port {port} is held by a program that is not a devup-mcp ({detail}), so the plugin cannot reach any devup-mcp on this machine. Stop that program - {}. This process keeps checking, and takes the port over by itself once it is free.", - find_the_holder(&port) + (BridgeRole::Unavailable, Some(BridgeIssue::ForeignProgram { detail, owner })) => format!( + "Port {port} is held by a program that is not a devup-mcp ({}; {detail}), so the plugin cannot reach any devup-mcp on this machine. Stop that program. This process keeps checking, and takes the port over by itself once it is free.", + owner_text(owner.as_ref(), &port) ), (BridgeRole::Unavailable, Some(BridgeIssue::IncompatibleProtocol { theirs, ours })) => { format!( @@ -528,7 +571,7 @@ fn bridge_reason(bridge: &BridgePathSnapshot) -> String { ) } (BridgeRole::Unavailable, Some(BridgeIssue::AuthenticationFailed { detail })) => format!( - "Port {port} is held by a process{holder} that could not be verified as this user's devup-mcp ({detail}). Relaying needs both processes to read the same per-user secret file, so a devup-mcp run as another user or with another home directory cannot share the bridge, and this process does not read through it." + "Port {port} is held by a process{holder} that could not be verified as this user's devup-mcp ({detail}). Relaying needs both processes to read the same secret, which only this user can read, so a devup-mcp run as another user - or sandboxed away from that secret - cannot share the bridge, and this process does not read through it." ), (BridgeRole::Unavailable, Some(BridgeIssue::SecretUnavailable { detail })) => format!( "Another process{holder} holds the bridge port {port}, but this process could not read or create the per-user relay secret it proves itself with ({detail}), so it cannot read through it." @@ -849,7 +892,7 @@ mod tests { Some(&BridgePathSnapshot { role: BridgeRole::Unavailable, host: None, - issue: Some(BridgeIssue::LegacyHost), + issue: Some(BridgeIssue::LegacyHost { owner: None }), ..bridge_with(vec![]) }), ); diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 2be8416a..9af9c660 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -243,7 +243,10 @@ impl Services { } } - fn production(figma_direct: crate::FigmaDirectConfig) -> Result { + fn production( + figma_direct: crate::FigmaDirectConfig, + bridge: Bridge, + ) -> Result { let mut oauth = OAuthManager::with_endpoint(FIGMA_ENDPOINT, KeyringCredentialStore)? .with_client_credential_store(Arc::new(KeyringClientCredentialStore)); if figma_direct.callback_port.is_some() { @@ -279,10 +282,13 @@ impl Services { // this process's own port, or through the devup-mcp that holds it. With // no plugin attached every call falls straight through to the remote // path, so opening the door costs nothing when nobody walks through. - let bridge = BridgeServer::from_env_with(BridgeOptions { - build_id: Some(crate::build_id().to_owned()), - secret_path: None, - }); + let bridge = match bridge { + Bridge::FromEnvironment => BridgeServer::from_env_with(BridgeOptions { + build_id: Some(crate::build_id().to_owned()), + secret_path: None, + }), + Bridge::Off => None, + }; let upstream: Arc = match bridge { Some(bridge) => Arc::new(FallbackUpstream::new( BridgeFigmaClient::new(bridge.state()).with_port(bridge.port()), @@ -346,16 +352,42 @@ impl DevupServer { roots: Vec, figma_direct: crate::FigmaDirectConfig, ) -> Result { - Self::with_output_roots(Services::production(figma_direct)?, roots) + Self::production_with_bridge(roots, figma_direct, Bridge::FromEnvironment) } + /// The production stack, with the Devup Bridge opened as `bridge` says. + pub fn production_with_bridge( + roots: Vec, + figma_direct: crate::FigmaDirectConfig, + bridge: Bridge, + ) -> Result { + Self::with_output_roots(Services::production(figma_direct, bridge)?, roots) + } + + /// The production stack for use inside another process - a test, an + /// embedder - so the Devup Bridge stays shut: the plugin's port belongs + /// to the devup-mcp sessions the machine is running. pub fn production_with_output_roots( roots: Vec, ) -> Result { - Self::production_with_config(roots, crate::FigmaDirectConfig::default()) + Self::production_with_bridge(roots, crate::FigmaDirectConfig::default(), Bridge::Off) } } +/// Whether a production server opens the Devup Bridge. +/// +/// Opening it binds the plugin's port, or reads through the devup-mcp that +/// holds it - which is what a serving process should do, and exactly what a +/// process that only checks its own configuration (`--self-check`) or a test +/// building the stack in-process should not: port 1993 belongs to whatever +/// sessions the machine runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bridge { + /// `DEVUP_FIGMA_BRIDGE_PORT` decides, as in a serving process. + FromEnvironment, + Off, +} + impl DevupServer { async fn start_operation( &self, @@ -547,11 +579,18 @@ impl DevupServer { return Ok(UpstreamResult { raw }); } + // Only a read bound for the metered path is paced. One the Devup Bridge + // plugin answers spends none of Figma's allowance, and holding it to + // that pace made a second export on the same server sit out most of a + // minute for an allowance it was not using. + let metered = self.services.upstream.is_metered(&call).await; let mut attempt = 1; loop { // Before the call, not after the refusal: a collection that paces // itself under the ceiling rarely has to be waited out at all. - self.services.pacer.acquire().await; + if metered { + self.services.pacer.acquire().await; + } // Rate-limit pacing is deliberate waiting, not a stalled upstream // read. Bound each actual attempt without cancelling the retry policy. let read = self.services.upstream.call_read_tool(call.clone()); diff --git a/crates/devup-mcp/tests/bridge_handover.rs b/crates/devup-mcp/tests/bridge_handover.rs index 56616bb5..b7b96984 100644 --- a/crates/devup-mcp/tests/bridge_handover.rs +++ b/crates/devup-mcp/tests/bridge_handover.rs @@ -1,19 +1,27 @@ //! Real devup-mcp processes sharing one bridge port, and passing it on. //! -//! Three stdio servers built from this target run on a port picked for the -//! test - never 1993, which belongs to whatever sessions this machine runs - -//! with their home directory pointed at a scratch one, so whatever they keep -//! per user is the test's own. A stand-in plugin attaches the way the real one -//! does: after its socket closes it waits two seconds and connects again -//! (`RETRY_MS` in plugin/src/ui.ts). +//! Stdio servers built from this target run on a port picked for the test - +//! never 1993, which belongs to whatever sessions this machine runs. Each has +//! a `HOME` of its own, the way MCP clients hand their servers different ones +//! (some point it at a sandbox), and they still have to find each other. The +//! Windows user profile, which the system sets per user, is one scratch +//! directory they share, so the relay secret kept there is the test's own; on +//! Unix the secret follows the user id rather than the environment. A stand-in +//! plugin attaches the way the real one does: it names its window with a +//! session id, and after its socket closes it waits two seconds and connects +//! again (`RETRY_MS` in plugin/src/ui.ts). //! //! Only url-less reads are made. They go to the attached plugin or nowhere, so -//! nothing here can reach the metered direct path or need a credential store. +//! nothing here can reach the metered direct path or need a credential store - +//! and nothing is held to the metered pace, which only that path's reads are. use std::{ path::{Path, PathBuf}, process::Stdio, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, time::{Duration, Instant}, }; @@ -40,18 +48,16 @@ struct Server { } impl Server { - async fn spawn(port: u16, home: &Path) -> anyhow::Result { + /// A devup-mcp the way an MCP client starts one: `home` is its own, + /// `profile` the user's. + async fn spawn(port: u16, home: &Path, profile: &Path) -> anyhow::Result { let mut child = Command::new(env!("CARGO_BIN_EXE_devup-mcp")) .current_dir(home) .env("DEVUP_FIGMA_BRIDGE_PORT", port.to_string()) .env("HOME", home) - .env("USERPROFILE", home) + .env("USERPROFILE", profile) .env("DEVUP_MCP_NO_UPDATE_CHECK", "1") .env("DEVUP_MCP_SKILLS_OFFLINE", "1") - // Every read is paced to Figma's metered allowance, bridge reads - // included, which would make this test wait out a minute between - // exports. It measures relaying, not pacing. - .env("DEVUP_FIGMA_CALLS_PER_MINUTE", "1000") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -137,7 +143,10 @@ impl Server { } /// A url-less search, answered only if this process reads the attached - /// plugin - through its own port or through the process holding it. + /// plugin - through its own port or through the process holding it. A + /// search has no `refresh`, so only a process's first one is sure to reach + /// the plugin: the plugin keeps its key across reconnects, and a later one + /// may be answered from this process's cache. async fn searches_through_the_bridge(&mut self) -> anyhow::Result { let (failed, output) = self .tool("devup_figma_search", json!({ "query": "syntheticframe" })) @@ -147,10 +156,24 @@ impl Server { && output["matches"][0]["nodeId"] == "1:2") } + /// A url-less export that goes all the way to the plugin, whatever this + /// process has cached. + async fn export(&mut self) -> anyhow::Result<(bool, Value)> { + self.tool( + "devup_figma_export", + json!({ "outputs": ["tsx"], "refresh": true }), + ) + .await + } + + /// Whether this process reads the attached plugin right now. + async fn reads_through_the_bridge(&mut self) -> anyhow::Result { + let (failed, output) = self.export().await?; + Ok(!failed && output["source"]["kind"] == "bridge") + } + async fn exports_through_the_bridge(&mut self) -> anyhow::Result { - let (failed, output) = self - .tool("devup_figma_export", json!({ "outputs": ["tsx"] })) - .await?; + let (failed, output) = self.export().await?; anyhow::ensure!(!failed, "{output}"); Ok(output) } @@ -226,59 +249,109 @@ fn script_answer(script: &str) -> Result { } } -/// The plugin as plugin/src/ui.ts behaves: connect, say hello, answer jobs, -/// and after the socket closes wait `RETRY_MS` before connecting again. Every -/// time a connection opens is recorded. -fn stand_in_plugin(port: u16) -> Arc>> { - let attached = Arc::new(Mutex::new(Vec::new())); - let log = attached.clone(); - tokio::spawn(async move { - loop { - if let Ok((mut socket, _)) = - connect_async(format!("ws://localhost:{port}/plugin")).await - { - log.lock().unwrap().push(Instant::now()); - let hello = json!({ - "kind": "hello", "fileKey": null, "fileName": "Landing", - "currentPage": { "id": "0:1", "name": "Page 1" }, - "selection": [{ "id": "1:2", "name": "Synthetic Frame", "type": "FRAME" }], - "selectionCount": 1, - }); - if socket - .send(Message::Text(hello.to_string().into())) - .await - .is_ok() +/// The plugin as plugin/src/ui.ts behaves: connect, say hello naming its +/// window, answer jobs, and after the socket closes wait `RETRY_MS` before +/// connecting again. Keyless, as in Dev Mode. +#[derive(Clone)] +struct StandIn { + /// When each connection opened. + attached: Arc>>, + /// Leave the next job unanswered - a read the plugin is still working on. + hold_next: Arc, + held: Arc, +} + +impl StandIn { + fn attach(port: u16) -> Self { + let plugin = Self { + attached: Arc::default(), + hold_next: Arc::default(), + held: Arc::default(), + }; + // One plugin window: the same name on every connection it makes. + let session = format!("{:032x}", rand::random::()); + let this = plugin.clone(); + tokio::spawn(async move { + loop { + // The real plugin has to say `localhost` (Figma refuses 127.0.0.1 + // in `allowedDomains`), and a browser tries both address families + // at once. A plain connect tries ::1 first, and Windows spends two + // seconds being refused there before trying 127.0.0.1 - time the + // plugin never loses, so it is left out of what this measures. + if let Ok((mut socket, _)) = + connect_async(format!("ws://127.0.0.1:{port}/plugin")).await { - while let Some(Ok(message)) = socket.next().await { - let Message::Text(text) = message else { - continue; - }; - let Ok(job) = serde_json::from_str::(&text) else { - continue; - }; - 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 - }), - }; - if socket - .send(Message::Text(answer.to_string().into())) - .await - .is_err() - { - break; + this.attached.lock().unwrap().push(Instant::now()); + let hello = json!({ + "kind": "hello", "sessionId": session, "fileKey": null, + "fileName": "Landing", + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [{ "id": "1:2", "name": "Synthetic Frame", "type": "FRAME" }], + "selectionCount": 1, + }); + if socket + .send(Message::Text(hello.to_string().into())) + .await + .is_ok() + { + while let Some(Ok(message)) = socket.next().await { + let Message::Text(text) = message else { + continue; + }; + let Ok(job) = serde_json::from_str::(&text) else { + continue; + }; + if job["kind"] != "devup-job" { + continue; + } + if this.hold_next.swap(false, Ordering::SeqCst) { + this.held.store(true, Ordering::SeqCst); + continue; + } + 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 + }), + }; + if socket + .send(Message::Text(answer.to_string().into())) + .await + .is_err() + { + break; + } } } } + tokio::time::sleep(PLUGIN_RETRY).await; } - tokio::time::sleep(PLUGIN_RETRY).await; + }); + plugin + } + + /// Keep the next read waiting, as a slow script does. + fn hold_next_read(&self) { + self.hold_next.store(true, Ordering::SeqCst); + } + + /// Until the held read has arrived. + async fn until_holding(&self) -> anyhow::Result<()> { + let started = Instant::now(); + while !self.held.load(Ordering::SeqCst) { + anyhow::ensure!( + started.elapsed() < DEADLINE, + "no read reached the plugin within {DEADLINE:?}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; } - }); - attached + Ok(()) + } } /// How long until the port accepts connections again. @@ -302,7 +375,7 @@ async fn until_all_read_through_the_bridge( let started = Instant::now(); 'retry: while started.elapsed() < DEADLINE { for server in servers.iter_mut() { - if !server.searches_through_the_bridge().await? { + if !server.reads_through_the_bridge().await? { tokio::time::sleep(Duration::from_millis(50)).await; continue 'retry; } @@ -315,14 +388,15 @@ async fn until_all_read_through_the_bridge( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn every_process_reads_through_the_bridge_and_the_port_passes_on_when_its_holder_exits() -> anyhow::Result<()> { - let home = scratch_home(); + let profile = scratch_home(); + let homes = [scratch_home(), scratch_home(), scratch_home()]; let port = free_port(); - let mut first = Server::spawn(port, &home).await?; + let mut first = Server::spawn(port, &homes[0], &profile).await?; until_listening(port).await?; - let mut second = Server::spawn(port, &home).await?; - let mut third = Server::spawn(port, &home).await?; - let plugin = stand_in_plugin(port); + let mut second = Server::spawn(port, &homes[1], &profile).await?; + let mut third = Server::spawn(port, &homes[2], &profile).await?; + let plugin = StandIn::attach(port); for (name, server) in [ ("the port's holder", &mut first), @@ -330,6 +404,10 @@ async fn every_process_reads_through_the_bridge_and_the_port_passes_on_when_its_ ("the third process", &mut third), ] { until_all_read_through_the_bridge(name, &mut [&mut *server]).await?; + assert!( + server.searches_through_the_bridge().await?, + "{name} searches through the bridge" + ); let output = server.exports_through_the_bridge().await?; assert_eq!(output["source"]["kind"], "bridge", "{name}: {output}"); assert_eq!(output["source"]["bridgePort"], port, "{name}: {output}"); @@ -342,7 +420,7 @@ async fn every_process_reads_through_the_bridge_and_the_port_passes_on_when_its_ } // The session that held the port ends. - let attached_before = plugin.lock().unwrap().len(); + let attached_before = plugin.attached.lock().unwrap().len(); let ended = Instant::now(); first.child.kill().await?; @@ -353,6 +431,7 @@ async fn every_process_reads_through_the_bridge_and_the_port_passes_on_when_its_ ) .await?; let reattached = plugin + .attached .lock() .unwrap() .get(attached_before) @@ -382,6 +461,58 @@ async fn every_process_reads_through_the_bridge_and_the_port_passes_on_when_its_ ); drop((second, third)); - let _ = std::fs::remove_dir_all(&home); + for directory in homes.iter().chain([&profile]) { + let _ = std::fs::remove_dir_all(directory); + } + Ok(()) +} + +/// A collection is under way through the port's holder when that session +/// ends, with the plugin still working on one of its reads. That read is +/// sent again once the plugin is back - here through the process that took +/// the port over - and the export finishes. It used to fail on the spot, and +/// the caller had to start the collection over. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_collection_under_way_when_the_holder_exits_finishes_through_the_next_holder() +-> anyhow::Result<()> { + let profile = scratch_home(); + let homes = [scratch_home(), scratch_home()]; + let port = free_port(); + + let mut holder = Server::spawn(port, &homes[0], &profile).await?; + until_listening(port).await?; + let mut reader = Server::spawn(port, &homes[1], &profile).await?; + let plugin = StandIn::attach(port); + until_all_read_through_the_bridge("the reading process", &mut [&mut reader]).await?; + + plugin.hold_next_read(); + let export = tokio::spawn(async move { + let output = reader.exports_through_the_bridge().await; + (reader, output) + }); + plugin.until_holding().await?; + let ended = Instant::now(); + holder.child.kill().await?; + + let (reader, output) = timeout(DEADLINE, export).await??; + let output = output?; + let finished = ended.elapsed(); + assert_eq!(output["source"]["kind"], "bridge", "{output}"); + assert!( + output["tsx"] + .as_str() + .is_some_and(|tsx| tsx.contains("SyntheticFrame")), + "{output}" + ); + eprintln!( + "collection across a handover measured: the export whose read was in flight when the \ + port's holder was killed finished {finished:?} later, through the process that took \ + the port over (the stand-in plugin retries every {PLUGIN_RETRY:?})" + ); + + drop(reader); + for directory in homes.iter().chain([&profile]) { + let _ = std::fs::remove_dir_all(directory); + } Ok(()) } diff --git a/crates/devup-mcp/tests/bridge_relay.rs b/crates/devup-mcp/tests/bridge_relay.rs index 59bd2381..1ae0d09e 100644 --- a/crates/devup-mcp/tests/bridge_relay.rs +++ b/crates/devup-mcp/tests/bridge_relay.rs @@ -377,16 +377,32 @@ async fn legacy_devup_mcp() -> u16 { port } -async fn status_of_a_process_on(port: u16) -> anyhow::Result { +/// A process that finds `port` held, once its first `status` has come back - +/// which has to be prompt, whoever holds the port. +async fn process_on(port: u16) -> anyhow::Result { let relay = second_process_on(port); let started = Instant::now(); - let status = status(&relay).await?; + status(&relay).await?; assert!( started.elapsed() < STATUS_LIMIT, "status took {:?}: it must not wait on the port's holder indefinitely", started.elapsed() ); - Ok(status) + Ok(relay) +} + +/// A holder that does not say who it is gets looked up in the operating +/// system after `status` has answered - that takes an external command - so +/// its name arrives a moment later. The status that names it, at `pid`. +async fn status_naming_the_holder(relay: &BridgeServer, pid: &str) -> anyhow::Result { + let deadline = Instant::now() + DEADLINE; + loop { + let status = status(relay).await?; + if status.pointer(pid).is_some_and(|pid| !pid.is_null()) || Instant::now() > deadline { + return Ok(status); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } } /// An older devup-mcp holds the port. It cannot be fixed from here, so the @@ -396,19 +412,27 @@ async fn status_of_a_process_on(port: u16) -> anyhow::Result { #[tokio::test] async fn a_devup_mcp_from_before_sharing_is_named_with_its_repair() -> anyhow::Result<()> { let port = legacy_devup_mcp().await; - let status = status_of_a_process_on(port).await?; + let relay = process_on(port).await?; + let status = status_naming_the_holder(&relay, "/paths/bridge/host/pid").await?; let bridge = &status["paths"]["bridge"]; assert_eq!(bridge["available"], false, "{bridge}"); assert_eq!(bridge["role"], "unavailable", "{bridge}"); assert_eq!(bridge["issue"], "legacy-host", "{bridge}"); - assert!( - bridge["host"].is_null(), - "its identity cannot be known: {bridge}" + // The holder cannot say who it is, so the operating system is asked + // which process listens on the port. Here that is this test process. + assert_eq!( + bridge["host"]["pid"], + std::process::id(), + "the process holding the port is named: {bridge}" ); let reason = bridge["reason"].as_str().expect("a reason"); assert!(reason.contains(&port.to_string()), "{reason}"); assert!(reason.contains("restart"), "{reason}"); + assert!( + reason.contains(&format!("pid {}", std::process::id())), + "{reason}" + ); let options = status["nextAction"]["options"] .as_array() @@ -443,13 +467,106 @@ async fn another_program_on_the_port_is_told_apart_from_devup_mcp() -> anyhow::R }); for port in [web_port, silent_port] { - let status = status_of_a_process_on(port).await?; + let relay = process_on(port).await?; + let status = status_naming_the_holder(&relay, "/paths/bridge/holder/pid").await?; let bridge = &status["paths"]["bridge"]; assert_eq!(bridge["available"], false, "{bridge}"); assert_eq!(bridge["role"], "unavailable", "{bridge}"); assert_eq!(bridge["issue"], "foreign-program", "{bridge}"); + assert!(bridge["host"].is_null(), "it is not a devup-mcp: {bridge}"); + assert_eq!( + bridge["holder"]["pid"], + std::process::id(), + "the program holding the port is named: {bridge}" + ); let reason = bridge["reason"].as_str().expect("a reason"); assert!(reason.contains("not a devup-mcp"), "{reason}"); } Ok(()) } + +/// One server, many calls: the pacer belongs to the server, so the calls that +/// share it have to share a session. +struct Session { + client: rmcp::service::RunningService, + task: tokio::task::JoinHandle>, +} + +impl Session { + async fn open(upstream: Arc) -> anyhow::Result { + let server = DevupServer::new(Services::new(Arc::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?; + Ok(Self { client, task }) + } + + async fn export(&self) -> anyhow::Result { + let arguments = json!({ "outputs": ["tsx"], "refresh": true }); + let mut result = self + .client + .call_tool( + CallToolRequestParams::new("devup_figma_export") + .with_arguments(arguments.as_object().cloned().unwrap()), + ) + .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 = self + .client + .call_tool( + CallToolRequestParams::new("devup_figma_export") + .with_arguments(json!({ "jobId": id }).as_object().cloned().unwrap()), + ) + .await?; + } + anyhow::ensure!(result.is_error != Some(true), "{result:?}"); + Ok(result.structured_content.unwrap_or_default()) + } + + async fn close(self) -> anyhow::Result<()> { + self.client.cancel().await?; + self.task.await??; + Ok(()) + } +} + +/// Figma meters reads made through its remote MCP, and every read used to be +/// held to that pace - eight a minute - including the ones the plugin +/// answers, which cost nothing. A second export on the same server then sat +/// out most of a minute for an allowance it was not spending. Only reads +/// bound for the metered path are paced now. +#[tokio::test] +async fn reads_through_the_bridge_are_not_held_to_the_metered_pace() -> anyhow::Result<()> { + let host = BridgeServer::start(0).expect("an ephemeral port is free"); + serve(attach(host.port()).await); + sees_one_plugin(&host).await; + let remote_calls = Arc::new(AtomicUsize::new(0)); + let session = Session::open(upstream(&host, &remote_calls)).await?; + + let started = Instant::now(); + // Well past the metered ceiling of eight reads a minute. + for _ in 0..10 { + let output = session.export().await?; + assert_eq!(output["source"]["kind"], "bridge", "{output}"); + } + let elapsed = started.elapsed(); + session.close().await?; + assert!( + elapsed < Duration::from_secs(40), + "ten bridge exports took {elapsed:?}: they were paced as if metered" + ); + assert_eq!(remote_calls.load(Ordering::SeqCst), 0); + Ok(()) +} diff --git a/plugin/README.md b/plugin/README.md index c9a66cdd..132786f6 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -33,6 +33,7 @@ Figma 데스크톱 앱에서 **Plugins → Development → Import plugin from ma cd plugin npm install npm run build # dist/ 를 다시 만든다 — 함께 커밋해야 한다 +node --test tests/withdraw.test.mjs # 커밋할 dist/code.js 가 취소를 지키는지 ``` `dist/` 는 의도적으로 커밋합니다. 받는 사람이 Node 없이 곧장 import 할 수 있게 @@ -100,17 +101,32 @@ MCP 클라이언트나 세션마다 devup-mcp 가 하나씩 뜨는 것은 정상 프로세스를 죽이거나 세션을 다시 띄울 필요가 없습니다. 이어받는 동안 status 는 `role: "connecting"` 으로 그 상태를 그대로 보고합니다. +그때 진행 중이던 수집도 끊기지 않습니다. 플러그인이 돌리고 있던 읽기는 새 호스트에 +다시 붙는 대로 다시 보내집니다(읽기는 문서를 바꾸지 않으므로 두 번 돌아도 해가 +없습니다). 파일 키를 보고하지 못하는 창(Dev Mode)도 이어서 찾을 수 있도록, +플러그인은 창을 열 때 한 번 정한 `sessionId` 를 `hello` 에 싣습니다 — 소켓이 바뀌어도 +같은 창이면 같은 이름입니다. + +devup-mcp 가 더는 기다리지 않는 읽기 — 요청한 프로세스가 떠났거나 시간이 다 됐다 — +에는 `devup-cancel` 이 옵니다. 차례를 기다리던 작업이면 돌리지 않고, 이미 돌고 있던 +작업이면 스크립트는 멈출 수 없으니 답만 보내지 않습니다. 이 메시지와 `sessionId` 를 +모르는 예전 빌드도 그대로 붙어 동작합니다. + 중계는 같은 사용자의 devup-mcp 끼리만 이어집니다. 두 프로세스는 그 사용자만 읽을 -수 있는 파일(Windows `%USERPROFILE%\AppData\Local\devup-mcp\bridge-relay.key`, -macOS `~/Library/Application Support/devup-mcp/`, Linux `~/.local/state/devup-mcp/`) -의 비밀값으로 서로를 증명하고, 브라우저 페이지(`Origin` 이 붙은 요청)는 중계에 -붙을 수 없습니다. +수 있는 비밀값(Windows `%USERPROFILE%\AppData\Local\devup-mcp\bridge-relay.key`, +macOS·Linux `/tmp/devup-mcp-/bridge-relay.key`)으로 서로를 증명합니다. Unix 는 +MCP 클라이언트마다 넘기는 `HOME` 이 달라도 같은 자리를 보도록 사용자 번호로 자리를 +정합니다. 브라우저 페이지는 붙을 수 없습니다 — 중계 문은 `Origin` 이 붙은 요청을 +모두, 플러그인 문은 Figma 플러그인 창(`Origin: null`)과 `figma.com` 이 아닌 요청을 +거절합니다. 이 기능 **이전의 devup-mcp 가 포트를 잡고 있으면** 그 프로세스만 플러그인을 씁니다. 나중에 뜬 새 devup-mcp 는 그 사실을 알아보고 status 의 `paths.bridge` 에 `issue: "legacy-host"` 와 할 일 — 그 devup-mcp 를 띄운 클라이언트를 재시작하거나 -갱신하기 — 을 적으며, 그 프로세스가 끝나면 스스로 포트를 이어받습니다. devup-mcp -가 아닌 프로그램이 포트를 잡은 경우는 `issue: "foreign-program"` 으로 따로 알립니다. +갱신하기 — 을 적으며, 그 프로세스가 끝나면 스스로 포트를 이어받습니다. 예전 +devup-mcp 는 자신을 밝히지 않으므로 운영체제에 물어 그 pid 와 실행 파일 경로를 +`host` 에 적습니다. devup-mcp 가 아닌 프로그램이 포트를 잡은 경우는 +`issue: "foreign-program"` 으로 따로 알리고, 그 프로그램을 `holder` 에 적습니다. **`ws://127.0.0.1` 은 쓸 수 없습니다.** Figma 는 `allowedDomains` 에 그 주소를 적으면 "유효한 URL 이 아니다"라며 **매니페스트 자체를 거부**해 플러그인이 실행되지 않습니다. diff --git a/plugin/dist/code.js b/plugin/dist/code.js index 18b7c6b4..dbc759d0 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,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 +(()=>{"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,l=null,o=new Set;async function s(){if(!a){a=!0;try{for(;i.length>0;){let e=i.shift();l=e.requestId;let t=await n(e).catch(t=>({kind:"devup-result",requestId:e.requestId,error:t instanceof Error?t.message:String(t)}));l=null,o.delete(e.requestId)||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=>{var r;let n;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}if("devup-job"===e.kind){i.push(e),s();return}"devup-cancel"===e.kind&&(r=e.requestId,(n=i.findIndex(e=>e.requestId===r))>=0?i.splice(n,1):l===r&&o.add(r))}}})(); \ No newline at end of file diff --git a/plugin/dist/ui.html b/plugin/dist/ui.html index 9e98ecf5..b601421e 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 f33d8321..aec3a072 100644 --- a/plugin/src/code.ts +++ b/plugin/src/code.ts @@ -17,6 +17,15 @@ interface JobMessage { params: Partial } +/** + * UI → 메인. devup-mcp 가 더는 기다리지 않는 작업 — 요청한 쪽이 떠났거나 시간이 + * 다 됐다. 이 메시지를 모르는 예전 빌드는 그냥 무시한다. + */ +interface CancelMessage { + kind: 'devup-cancel' + requestId: string +} + /** 메인 → UI. 성공이면 data, 실패면 error 중 하나만 채운다. */ interface ResultMessage { kind: 'devup-result' @@ -141,6 +150,10 @@ async function runJob(job: JobMessage): Promise { const queue: JobMessage[] = [] let draining = false +/** 지금 돌고 있는 작업, 그리고 돌던 중에 거둬진 작업. */ +let running: string | null = null +const withdrawn = new Set() + async function drain() { if (draining) return draining = true @@ -148,6 +161,7 @@ async function drain() { while (queue.length > 0) { // biome-ignore lint/style/noNonNullAssertion: length 를 확인하고 꺼낸다 const job = queue.shift()! + running = job.requestId const result = await runJob(job).catch( (error: unknown) => ({ @@ -156,6 +170,9 @@ async function drain() { error: error instanceof Error ? error.message : String(error), }) satisfies ResultMessage, ) + running = null + // 기다리는 쪽이 없는 답은 보내지 않는다. 큰 스냅샷이면 그만큼 소켓이 빈다. + if (withdrawn.delete(job.requestId)) continue figma.ui.postMessage(result) } } finally { @@ -163,6 +180,19 @@ async function drain() { } } +/** + * 거둬진 작업을 뺀다. 아직 차례를 기다리던 것이면 돌리지 않는다. 이미 돌고 있는 + * 것은 스크립트를 멈출 수 없으니 답만 보내지 않는다. + */ +function withdraw(requestId: string) { + const waiting = queue.findIndex((job) => job.requestId === requestId) + if (waiting >= 0) { + queue.splice(waiting, 1) + return + } + if (running === requestId) withdrawn.add(requestId) +} + figma.showUI(__html__, { width: 320, height: 220 }) // 읽기 스크립트도 페이지를 옮기므로 사람이 옮긴 것과 함께 이 이벤트로 온다. @@ -190,5 +220,10 @@ figma.ui.onmessage = (message: unknown) => { // onmessage 를 async 로 만들면 Figma 가 반환값을 기다리지 않아 예외가 조용히 // 사라진다. 큐에 넣고 배수는 따로 돌린다. void drain() + return + } + + if (msg.kind === 'devup-cancel') { + withdraw((message as CancelMessage).requestId) } } diff --git a/plugin/src/ui.ts b/plugin/src/ui.ts index 9e2a286c..0a4fab75 100644 --- a/plugin/src/ui.ts +++ b/plugin/src/ui.ts @@ -39,6 +39,19 @@ interface ResultMessage { const RETRY_MS = 2000 +/** + * 이 플러그인 창 하나를 가리키는 이름. 창이 열려 있는 동안 바뀌지 않는다. + * + * devup-mcp 는 파일 키를 보고하지 못하는 플러그인(Dev Mode)을 이 이름으로 부른다. + * 소켓이 끊겨 다시 붙어도 — 포트를 쥔 devup-mcp 가 바뀌었어도 — 같은 창이면 같은 + * 이름이라, 수집 도중에 연결이 바뀌어도 남은 읽기가 이 창을 다시 찾는다. + */ +const SESSION_ID = (() => { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +})() + let socket: WebSocket | null = null // `status` 는 window 전역(문자열)과 겹친다. let bridgeStatus: StatusMessage | null = null @@ -79,6 +92,7 @@ function connect() { ws.send( JSON.stringify({ kind: 'hello', + sessionId: SESSION_ID, fileKey: bridgeStatus?.fileKey ?? null, fileName: bridgeStatus?.fileName ?? '', currentPage: bridgeStatus?.currentPage ?? null, diff --git a/plugin/tests/withdraw.test.mjs b/plugin/tests/withdraw.test.mjs new file mode 100644 index 00000000..47bed836 --- /dev/null +++ b/plugin/tests/withdraw.test.mjs @@ -0,0 +1,84 @@ +// The committed bundle, `dist/code.js`, answering `devup-cancel`: devup-mcp +// sends it when nobody waits for a job any more - the process that asked went +// away, or the read timed out. A job still waiting its turn is not run, and +// one already running is not answered. + +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import vm from 'node:vm' + +const bundle = await readFile(new URL('../dist/code.js', import.meta.url), 'utf8') + +/** The plugin's main thread against a Figma that records what it is asked. */ +function plugin() { + const posted = [] + const looked = [] + const figma = { + showUI() {}, + on() {}, + ui: { postMessage: (message) => posted.push(message), onmessage: null }, + currentPage: { id: '0:1', name: 'Page 1', selection: [] }, + root: { name: 'Landing' }, + fileKey: null, + // The first thing the metadata script does. Nothing is found, so every + // job that runs answers with an error - which still is an answer. + getNodeByIdAsync: async (id) => { + looked.push(id) + return null + }, + } + vm.runInNewContext(bundle, { figma, __html__: '', console }) + return { posted, looked, send: (message) => figma.ui.onmessage(message) } +} + +const job = (requestId, nodeId) => ({ + kind: 'devup-job', + requestId, + script: 'metadata', + params: { nodeId }, +}) + +const cancel = (requestId) => ({ kind: 'devup-cancel', requestId }) + +/** Every job the messages started has finished. Nothing here waits on a timer. */ +const settled = () => new Promise((resolve) => setImmediate(resolve)) + +test('a job withdrawn while it waits its turn is never run', async () => { + const { posted, looked, send } = plugin() + send(job('a', '1:1')) + send(job('b', '2:2')) + send(cancel('b')) + await settled() + assert.deepEqual(looked, ['1:1']) + assert.deepEqual( + posted.map((message) => message.requestId), + ['a'], + ) +}) + +test('a job withdrawn while it runs is not answered, and the queue goes on', async () => { + const { posted, looked, send } = plugin() + send(job('a', '1:1')) + send(cancel('a')) + send(job('b', '2:2')) + await settled() + assert.deepEqual(looked, ['1:1', '2:2']) + assert.deepEqual( + posted.map((message) => message.requestId), + ['b'], + ) +}) + +test('withdrawing a job that already answered changes nothing', async () => { + const { posted, send } = plugin() + send(job('a', '1:1')) + await settled() + send(cancel('a')) + send(job('a2', '1:1')) + await settled() + assert.deepEqual( + posted.map((message) => message.requestId), + ['a', 'a2'], + ) +})