diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index d562dc2..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-env node */ -require('@rushstack/eslint-patch/modern-module-resolution') - -module.exports = { - root: true, - plugins: ['perfectionist'], - extends: [ - 'plugin:vue/vue3-essential', - 'eslint:recommended', - '@vue/eslint-config-typescript', - '@vue/eslint-config-prettier/skip-formatting' - ], - parserOptions: { - ecmaVersion: 'latest' - }, - rules: { - 'vue/multi-word-component-names': 'off', - 'perfectionist/sort-imports': 'error' - } -} diff --git a/CHANGELOG.md b/CHANGELOG.md index bc99a2b..d23a770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ - - # Changelog +## 0.14.0 + +- Added MCP server support to let agents/IDEs pull code, structure, and screenshots from your current Figma selection. + ## 0.13.1 - Fixed select component style under dark mode. diff --git a/README.md b/README.md index d541fd5..7adf364 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,47 @@ Current available plugins: +## MCP server + +TemPad Dev ships an [MCP](https://modelcontextprotocol.io/) server so agents/IDEs can pull code and context directly from the node you have selected in Figma. With the TemPad Dev panel open and MCP enabled, the server exposes: + +- `get_code`: High-fidelity JSX/Vue + TailwindCSS code output by default, plus attached assets and the codegen preset/config used. +- `get_structure`: A structural outline (ids, types, geometry) for the current selection. +- `get_screenshot`: A PNG capture with a `resourceUri` and direct HTTP download URL. +- `tempad-assets` resource template (`asset://tempad/{hash}`) for any binaries returned by the tools above. + +### Setup guide + +1. Requirements: Node.js 18+ and TemPad Dev running in a Figma tab. +2. In TemPad Dev, open **Preferences → MCP server** and toggle **Enable MCP server**. +3. Use the quick actions in Preferences to install/connect, or add the server manually to your MCP client as a stdio command: + + ```json + { + "mcpServers": { + "TemPad Dev": { + "command": "npx", + "args": ["-y", "@tempad-dev/mcp@latest"] + } + } + } + ``` + + For CLI-style installers, the equivalent commands are `claude mcp add --transport stdio "TemPad Dev" -- npx -y @tempad-dev/mcp@latest` or `codex mcp add "TemPad Dev" -- npx -y @tempad-dev/mcp@latest`. + +4. Keep the TemPad Dev tab active while using MCP. If you have multiple Figma files (and thus multiple TemPad Dev extensions) open, click the MCP badge in the TemPad Dev panel to activate the correct file for your agent. + +### Configuration + +Optional environment variables for `@tempad-dev/mcp`: + +- `TEMPAD_MCP_TOOL_TIMEOUT` (default `15000`): Tool call timeout in milliseconds. +- `TEMPAD_MCP_AUTO_ACTIVATE_GRACE` (default `1500`): Delay before auto-activating the sole connected extension. +- `TEMPAD_MCP_MAX_ASSET_BYTES` (default `8388608`): Maximum upload size for captured assets/screenshots (bytes). +- `TEMPAD_MCP_RUNTIME_DIR` (default `${TMPDIR}/tempad-dev/run`): Where the hub stores its socket/lock files. +- `TEMPAD_MCP_LOG_DIR` (default `${TMPDIR}/tempad-dev/log`): Where MCP logs are written. +- `TEMPAD_MCP_ASSET_DIR` (default `${TMPDIR}/tempad-dev/assets`): Storage for exported assets referenced by `resourceUri`. +

Inspect TemPad component code

diff --git a/build/readme.ts b/build/readme.ts index 129e188..a05ba68 100644 --- a/build/readme.ts +++ b/build/readme.ts @@ -44,7 +44,7 @@ function getDomain(url: string) { try { const { hostname } = new URL(url) return hostname - } catch (error) { + } catch { console.error('Invalid URL:', url) return null } diff --git a/codegen/codegen.ts b/codegen/codegen.ts deleted file mode 100644 index fbb73ca..0000000 --- a/codegen/codegen.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { RequestPayload, ResponsePayload, CodeBlock } from '@/types/codegen' -import type { Plugin } from '@/types/plugin' - -import { serializeComponent } from '@/utils/component' -import { serializeCSS } from '@/utils/css' -import { evaluate } from '@/utils/module' - -import type { RequestMessage, ResponseMessage } from './worker' - -import safe from './safe' - -type Request = RequestMessage -type Response = ResponseMessage - -const IMPORT_RE = /^\s*import\s+(([^'"\n]+|'[^']*'|"[^"]*")|\s*\(\s*[^)]*\s*\))/gm - -const postMessage = globalThis.postMessage - -globalThis.onmessage = async ({ data }: MessageEvent) => { - const { id, payload } = data - const codeBlocks: CodeBlock[] = [] - - const { style, component, options, pluginCode } = payload - let plugin = null - - try { - if (pluginCode) { - if (IMPORT_RE.test(pluginCode)) { - throw new Error('`import` is not allowed in plugins.') - } - - const exports = await evaluate(pluginCode) - plugin = (exports.default || exports.plugin) as Plugin - } - } catch (e) { - console.error(e) - const message: Response = { - id, - error: e - } - postMessage(message) - return - } - - const { - component: componentOptions, - css: cssOptions, - js: jsOptions, - ...rest - } = plugin?.code ?? {} - - if (componentOptions && component) { - const { lang, transformComponent } = componentOptions - const componentCode = serializeComponent(component, { lang }, { transformComponent }) - if (componentCode) { - codeBlocks.push({ - name: 'component', - title: componentOptions?.title ?? 'Component', - lang: componentOptions?.lang ?? 'jsx', - code: componentCode - }) - } - } - - if (cssOptions !== false) { - const cssCode = serializeCSS(style, options, cssOptions) - if (cssCode) { - codeBlocks.push({ - name: 'css', - title: cssOptions?.title ?? 'CSS', - lang: cssOptions?.lang ?? 'css', - code: cssCode - }) - } - } - - if (jsOptions !== false) { - const jsCode = serializeCSS(style, { ...options, toJS: true }, jsOptions) - if (jsCode) { - codeBlocks.push({ - name: 'js', - title: jsOptions?.title ?? 'JavaScript', - lang: jsOptions?.lang ?? 'js', - code: jsCode - }) - } - } - - codeBlocks.push( - ...Object.keys(rest) - .map((name) => { - const extraOptions = rest[name] - if (extraOptions === false) { - return null - } - - const code = serializeCSS(style, options, extraOptions) - if (!code) { - return null - } - return { - name, - title: extraOptions.title ?? name, - lang: extraOptions.lang ?? 'css', - code - } - }) - .filter((item): item is CodeBlock => item != null) - ) - - const message: Response = { - id, - payload: { codeBlocks, pluginName: plugin?.name } - } - postMessage(message) -} - -// Only expose the necessary APIs to plugins -Object.getOwnPropertyNames(globalThis) - .filter((key) => !safe.has(key)) - .forEach((key) => { - // @ts-ignore - globalThis[key] = undefined - }) - -Object.defineProperties(globalThis, { - name: { value: 'codegen', writable: false, configurable: false }, - onmessage: { value: undefined, writable: false, configurable: false }, - onmessageerror: { value: undefined, writable: false, configurable: false }, - postMessage: { value: undefined, writable: false, configurable: false } -}) diff --git a/codegen/requester.ts b/codegen/requester.ts new file mode 100644 index 0000000..18d927a --- /dev/null +++ b/codegen/requester.ts @@ -0,0 +1,67 @@ +let id = 0 + +export type RequestMessage = { + id: number + payload: T +} + +export type ResponseMessage = { + id: number + payload?: T + error?: unknown +} + +type PendingRequest = { + resolve: (result: T) => void + reject: (reason?: unknown) => void +} + +const pending = new Map() + +type WorkerClass = { + // Bundler-provided worker classes expose a zero-arg constructor + new (): Worker +} + +type WorkerRequester = (payload: T) => Promise + +const cache = new WeakMap() + +export function createWorkerRequester(Worker: WorkerClass) { + if (cache.has(Worker)) { + return cache.get(Worker) as WorkerRequester + } + + const worker = new Worker() + + worker.onmessage = ({ data }: MessageEvent>) => { + const { id, payload, error } = data + + const request = pending.get(id) + if (request) { + if (error) { + request.reject(error) + } else { + request.resolve(payload) + } + pending.delete(id) + } + } + + const request: WorkerRequester = function (payload: T): Promise { + return new Promise((resolve, reject) => { + pending.set(id, { + resolve: (result) => resolve(result as U), + reject + }) + + const message: RequestMessage = { id, payload } + worker.postMessage(message) + id++ + }) + } + + cache.set(Worker, request) + + return request +} diff --git a/codegen/worker.ts b/codegen/worker.ts index a88049e..58d0477 100644 --- a/codegen/worker.ts +++ b/codegen/worker.ts @@ -1,61 +1,159 @@ -let id = 0 +import type { RequestPayload, ResponsePayload, CodeBlock } from '@/types/codegen' +import type { DevComponent, Plugin } from '@/types/plugin' -export type RequestMessage = { - id: number - payload: T -} +import { serializeComponent, stringifyComponent } from '@/utils/component' +import { serializeCSS } from '@/utils/css' +import { evaluate } from '@/utils/module' +import { stringify } from '@/utils/string' -export type ResponseMessage = { - id: number - payload?: T - error?: unknown -} +import type { RequestMessage, ResponseMessage } from './requester' -type PendingRequest = { - resolve: (result: T) => void - reject: (reason?: unknown) => void -} +import safe from './safe' -const pending = new Map>() +type Request = RequestMessage +type Response = ResponseMessage -type WorkerClass = { - new (...args: any[]): Worker -} +const IMPORT_RE = /^\s*import\s+(([^'"\n]+|'[^']*'|"[^"]*")|\s*\(\s*[^)]*\s*\))/gm + +const postMessage = globalThis.postMessage + +globalThis.onmessage = async ({ data }: MessageEvent) => { + const { id, payload } = data + const codeBlocks: CodeBlock[] = [] -const cache = new Map Promise>() + const { style, component, options, pluginCode } = payload + let plugin = null + let devComponent: DevComponent | null = null -export function createWorkerRequester(Worker: WorkerClass) { - if (cache.has(Worker)) { - return cache.get(Worker) as (payload: T) => Promise + try { + if (pluginCode) { + if (IMPORT_RE.test(pluginCode)) { + throw new Error('`import` is not allowed in plugins.') + } + + const exports = await evaluate(pluginCode) + plugin = (exports.default || exports.plugin) as Plugin + } + } catch (e) { + console.error(e) + const message: Response = { + id, + error: e + } + postMessage(message) + return } - const worker = new Worker() + const { + component: componentOptions, + css: cssOptions, + js: jsOptions, + ...rest + } = plugin?.code ?? {} - worker.onmessage = ({ data }: MessageEvent>) => { - const { id, payload, error } = data + if (componentOptions && component) { + const { lang, transformComponent } = componentOptions + let componentCode = '' - const request = pending.get(id) - if (request) { - if (error) { - request.reject(error) - } else { - request.resolve(payload) + if (typeof transformComponent === 'function') { + const result = transformComponent({ component }) + if (typeof result === 'string') { + componentCode = result + } else if (result) { + devComponent = result + componentCode = stringifyComponent(result, lang ?? 'jsx') } - pending.delete(id) + } else { + componentCode = serializeComponent(component, { lang }, { transformComponent }) + } + + if (componentCode) { + codeBlocks.push({ + name: 'component', + title: componentOptions?.title ?? 'Component', + lang: componentOptions?.lang ?? 'jsx', + code: componentCode + }) } } - const request = function(payload: T): Promise { - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }) + if (cssOptions !== false) { + const cssCode = serializeCSS(style, options, cssOptions) + if (cssCode) { + codeBlocks.push({ + name: 'css', + title: cssOptions?.title ?? 'CSS', + lang: cssOptions?.lang ?? 'css', + code: cssCode + }) + } + } - const message: RequestMessage = { id, payload } - worker.postMessage(message) - id++ - }) + if (jsOptions !== false) { + const jsCode = serializeCSS(style, { ...options, toJS: true }, jsOptions) + if (jsCode) { + codeBlocks.push({ + name: 'js', + title: jsOptions?.title ?? 'JavaScript', + lang: jsOptions?.lang ?? 'js', + code: jsCode + }) + } + } + + codeBlocks.push( + ...Object.keys(rest) + .map((name) => { + const extraOptions = rest[name] + if (extraOptions === false) { + return null + } + + const code = serializeCSS(style, options, extraOptions) + if (!code) { + return null + } + return { + name, + title: extraOptions.title ?? name, + lang: extraOptions.lang ?? 'css', + code + } + }) + .filter((item): item is CodeBlock => item != null) + ) + + const message: Response = { + id, + payload: { + codeBlocks, + pluginName: plugin?.name, + ...(payload.returnDevComponent && devComponent ? { devComponent } : {}) + } } - cache.set(Worker, request) + const safe = JSON.parse( + JSON.stringify(message, (_, v) => { + if (typeof v === 'function') return stringify(v) + return v + }) + ) - return request + postMessage(safe) } + +// Only expose the necessary APIs to plugins +Object.getOwnPropertyNames(globalThis) + .filter((key) => !safe.has(key)) + .forEach((key) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + globalThis[key] = undefined + }) + +Object.defineProperties(globalThis, { + name: { value: 'codegen', writable: false, configurable: false }, + onmessage: { value: undefined, writable: false, configurable: false }, + onmessageerror: { value: undefined, writable: false, configurable: false }, + postMessage: { value: undefined, writable: false, configurable: false } +}) diff --git a/components/Badge.vue b/components/Badge.vue index 6375d6d..3bff115 100644 --- a/components/Badge.vue +++ b/components/Badge.vue @@ -1,11 +1,18 @@ @@ -13,17 +20,36 @@ defineProps<{ diff --git a/components/Code.vue b/components/Code.vue index 4cb88fb..c1154cf 100644 --- a/components/Code.vue +++ b/components/Code.vue @@ -181,6 +181,12 @@ function handleClick(event: MouseEvent) { .tp-code-content { padding: 0 8px 8px; overflow-x: auto; + user-select: text; + cursor: text; +} + +.tp-code-content :deep(.os-scrollbar) { + cursor: default; } .tp-code-content:has(.os-scrollbar-visible) { diff --git a/components/Panel.vue b/components/Panel.vue index c637a26..5cbeb08 100644 --- a/components/Panel.vue +++ b/components/Panel.vue @@ -1,8 +1,9 @@ + + + + diff --git a/components/Select.vue b/components/Select.vue index 5db1f2e..af13a5f 100644 --- a/components/Select.vue +++ b/components/Select.vue @@ -1,9 +1,10 @@ + + + + diff --git a/components/sections/MetaSection.vue b/components/sections/MetaSection.vue index d55d456..e9ca971 100644 --- a/components/sections/MetaSection.vue +++ b/components/sections/MetaSection.vue @@ -2,11 +2,11 @@ import Badge from '@/components/Badge.vue' import Copyable from '@/components/Copyable.vue' import IconButton from '@/components/IconButton.vue' +import Link from '@/components/icons/Link.vue' import Select from '@/components/icons/Select.vue' import Section from '@/components/Section.vue' import { useDevResourceLinks } from '@/composables/dev-resources' import { selection, selectedNode, selectedTemPadComponent } from '@/ui/state' -import Link from '@/components/icons/Link.vue' const title = computed(() => { const nodes = selection.value diff --git a/components/sections/PrefSection.vue b/components/sections/PrefSection.vue index 0597295..5636ca6 100644 --- a/components/sections/PrefSection.vue +++ b/components/sections/PrefSection.vue @@ -1,10 +1,11 @@ @@ -90,6 +88,15 @@ const cssUnitOptions: SelectOption[] = [ --tp-section-padding-bottom: 0; } +.tp-pref-mcp, +.tp-pref-plugins { + margin-top: 12px; + margin-left: -12px; + margin-right: -12px; + --tp-section-padding-top: 12px; + border-top: 1px solid var(--color-border); +} + .tp-pref-field + .tp-pref-field { margin-top: 8px; } @@ -98,10 +105,6 @@ const cssUnitOptions: SelectOption[] = [ width: 80px; } -.tp-pref-plugins { - margin-top: 8px; -} - label { cursor: default; } diff --git a/composables/copy.ts b/composables/copy.ts index 865305f..1d85a9d 100644 --- a/composables/copy.ts +++ b/composables/copy.ts @@ -1,17 +1,26 @@ -import { useToast } from '@/composables' import { useClipboard } from '@vueuse/core' +import { useToast } from '@/composables' + type CopySource = HTMLElement | string | null | undefined -export function useCopy(content?: MaybeRefOrGetter) { - const { copy } = useClipboard() +type UseCopyOptions = { + message?: MaybeRefOrGetter +} + +export function useCopy(content?: MaybeRefOrGetter, options?: UseCopyOptions) { + const { copy: copyToClipboard } = useClipboard() const { show } = useToast() - return (source?: CopySource) => { + return (source?: CopySource, message?: string) => { try { const value = toValue(source ?? content) - copy(typeof value === 'string' ? value : value?.dataset?.copy || value?.textContent || '') - show('Copied to clipboard') + copyToClipboard( + typeof value === 'string' ? value : value?.dataset?.copy || value?.textContent || '' + ) + const resolvedMessage = + message ?? (options?.message ? toValue(options.message) : 'Copied to clipboard') + show(resolvedMessage) } catch (e) { console.error(e) } diff --git a/composables/deep-link.ts b/composables/deep-link.ts new file mode 100644 index 0000000..2835300 --- /dev/null +++ b/composables/deep-link.ts @@ -0,0 +1,86 @@ +import { useEventListener, useTimeoutFn } from '@vueuse/core' +import { onScopeDispose } from 'vue' + +import { useToast } from './toast' + +type DeepLinkGuardOptions = { + timeout?: number + message?: string + fallbackDeepLink?: string +} + +const DEFAULT_TIMEOUT_MS = 300 +const DEFAULT_MESSAGE = 'No response detected. Please install the client first.' + +/** + * Guard a deep link attempt: if the page does not blur / hide within the timeout, + * show a toast indicating the client might not be installed. + */ +export function useDeepLinkGuard(defaultOptions: DeepLinkGuardOptions = {}) { + const { show } = useToast() + + let activeCleanup: (() => void) | null = null + + onScopeDispose(() => { + if (activeCleanup) { + activeCleanup() + } + }) + + const guardDeepLink = (deepLink: string, options?: DeepLinkGuardOptions) => { + // Cancel any previous pending check to avoid duplicate toasts. + if (activeCleanup) { + activeCleanup() + } + + const timeout = options?.timeout ?? defaultOptions.timeout ?? DEFAULT_TIMEOUT_MS + const message = options?.message ?? defaultOptions.message ?? DEFAULT_MESSAGE + const fallbackDeepLink = + options?.fallbackDeepLink ?? defaultOptions.fallbackDeepLink ?? undefined + + let cleaned = false + let fallbackUsed = false + const disposers: Array<() => void> = [] + + const { start, stop } = useTimeoutFn( + () => { + cleanup() + if (!fallbackUsed && fallbackDeepLink) { + fallbackUsed = true + guardDeepLink(fallbackDeepLink, { ...options, fallbackDeepLink: undefined }) + return + } + show(message) + }, + timeout, + { immediate: false } + ) + + const cleanup = () => { + if (cleaned) return + cleaned = true + stop() + disposers.forEach((dispose) => dispose()) + disposers.length = 0 + if (activeCleanup === cleanup) { + activeCleanup = null + } + } + + disposers.push(useEventListener(window, 'blur', cleanup, { once: true })) + disposers.push(useEventListener(window, 'pagehide', cleanup, { once: true })) + disposers.push( + useEventListener(document, 'visibilitychange', () => { + if (document.visibilityState === 'hidden') { + cleanup() + } + }) + ) + + activeCleanup = cleanup + start() + window.open(deepLink, '_self') + } + + return guardDeepLink +} diff --git a/composables/dev-resources.ts b/composables/dev-resources.ts index 37bc39e..16a27ae 100644 --- a/composables/dev-resources.ts +++ b/composables/dev-resources.ts @@ -1,5 +1,3 @@ -import { SelectionNode } from '@/ui/state' - const devResourcesCache = reactive>(new Map()) const inflightDevResources = new Map>() @@ -28,9 +26,7 @@ async function getFavicon(url: string) { } } -export function useDevResourceLinks( - nodeSource: MaybeRefOrGetter -) { +export function useDevResourceLinks(nodeSource: MaybeRefOrGetter) { const nodeRef = computed(() => toValue(nodeSource) ?? null) const links = computed(() => { @@ -75,7 +71,7 @@ export function useDevResourceLinks( return links } -function ensureDevResources(node: SelectionNode) { +function ensureDevResources(node: SceneNode) { if (inflightDevResources.has(node.id)) { return } @@ -127,7 +123,7 @@ function toLink(name: string, url: string, inherited: boolean = false): DevResou } } -function getDocumentationLinks(node: SelectionNode): readonly DocumentationLink[] { +function getDocumentationLinks(node: SceneNode): readonly DocumentationLink[] { if (node.type !== 'INSTANCE' && node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') { return [] } diff --git a/composables/index.ts b/composables/index.ts index cf658ed..254f7ef 100644 --- a/composables/index.ts +++ b/composables/index.ts @@ -1,4 +1,5 @@ export * from './copy' +export * from './deep-link' export * from './input' export * from './key-lock' export * from './selection' diff --git a/composables/mcp.ts b/composables/mcp.ts new file mode 100644 index 0000000..5623f85 --- /dev/null +++ b/composables/mcp.ts @@ -0,0 +1,311 @@ +import { + createSharedComposable, + useDocumentVisibility, + useEventListener, + useIdle, + useTimeoutFn, + useWindowFocus +} from '@vueuse/core' +import { computed, shallowRef, watch } from 'vue' + +import type { McpToolArgs, McpToolName, MCPHandlers } from '@/mcp/runtime' + +import { parseMessageToExtension } from '@/mcp-server/src/protocol' +import { resetUploadedAssets, setAssetServerUrl } from '@/mcp/assets' +import { MCP_TOOL_HANDLERS } from '@/mcp/runtime' +import { MCP_PORT_CANDIDATES } from '@/mcp/shared/constants' +import { setMcpSocket } from '@/mcp/transport' +import { options, runtimeMode } from '@/ui/state' + +const RECONNECT_DELAY_MS = 3000 +const IDLE_TIMEOUT_MS = 10000 + +export type McpStatus = 'disabled' | 'connecting' | 'connected' | 'error' + +function getPortCandidates(lastSuccessfulPort: number | null): number[] { + if (lastSuccessfulPort && MCP_PORT_CANDIDATES.includes(lastSuccessfulPort)) { + return [lastSuccessfulPort, ...MCP_PORT_CANDIDATES.filter((p) => p !== lastSuccessfulPort)] + } + return MCP_PORT_CANDIDATES +} + +export const useMcp = createSharedComposable(() => { + const status = shallowRef('disabled') + const port = shallowRef(null) + const count = shallowRef(0) + const activeId = shallowRef(null) + const selfId = shallowRef(null) + const errorMessage = shallowRef(null) + const socket = shallowRef(null) + + let lastSuccessfulPort: number | null = null + let isConnecting = false + const documentVisibility = useDocumentVisibility() + const { idle } = useIdle(IDLE_TIMEOUT_MS) + const focused = useWindowFocus() + + const isWindowActive = computed(() => { + return documentVisibility.value === 'visible' && !idle.value && focused.value + }) + + const { start: startReconnectTimer, stop: stopReconnectTimer } = useTimeoutFn( + () => { + connect() + }, + RECONNECT_DELAY_MS, + { immediate: false } + ) + + function resetState() { + count.value = 0 + activeId.value = null + selfId.value = null + port.value = null + } + + function cleanupSocket() { + if (socket.value) { + try { + socket.value.close() + } catch (error) { + console.warn('[tempad-dev] Failed to close socket:', error) + } + } + socket.value = null + setMcpSocket(null) + } + + function scheduleReconnect() { + stopReconnectTimer() + if (options.value.mcpOn) { + startReconnectTimer() + } + } + + function openWebSocket(targetPort: number): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${targetPort}`) + const handleOpen = () => { + ws.removeEventListener('error', handleError) + resolve(ws) + } + const handleError = (event: Event) => { + ws.removeEventListener('open', handleOpen) + ws.close() + const err = event instanceof ErrorEvent ? event.error : null + reject(err ?? new Error('WebSocket connection failed')) + } + ws.addEventListener('open', handleOpen, { once: true }) + ws.addEventListener('error', handleError, { once: true }) + }) + } + + async function connect() { + if ( + runtimeMode.value !== 'standard' || + !options.value.mcpOn || + isConnecting || + socket.value || + !isWindowActive.value + ) { + return + } + + isConnecting = true + stopReconnectTimer() + status.value = 'connecting' + + const candidates = getPortCandidates(lastSuccessfulPort) + + for (const candidatePort of candidates) { + try { + const ws = await openWebSocket(candidatePort) + if (!options.value.mcpOn) { + ws.close() + break + } + lastSuccessfulPort = candidatePort + port.value = candidatePort + errorMessage.value = null + socket.value = ws + setMcpSocket(ws) + break + } catch (err) { + errorMessage.value = + err instanceof Error ? err.message : 'Failed to connect to MCP WebSocket server' + } + } + + if (!socket.value && options.value.mcpOn) { + scheduleReconnect() + } + + isConnecting = false + } + + async function handleMessage(event: MessageEvent) { + const payload = typeof event.data === 'string' ? event.data : '' + const message = parseMessageToExtension(payload) + + if (!message) { + errorMessage.value = 'Received malformed message from MCP server' + return + } + + if (message.type === 'registered') { + selfId.value = message.id + return + } + + if (message.type === 'state') { + activeId.value = message.activeId + count.value = message.count + port.value = message.port + setAssetServerUrl(message.assetServerUrl) + status.value = 'connected' + errorMessage.value = null + return + } + + if (message.type === 'toolCall') { + const { name, args } = message.payload + if (!isMcpToolName(name)) { + throw new Error(`No handler registered for tool "${name}".`) + } + await processToolCall(message.id, name, args as McpToolArgs | undefined) + } + } + + function handleClose(event: CloseEvent) { + if (event.wasClean === false) { + errorMessage.value = 'MCP connection closed unexpectedly' + } + socket.value = null + setMcpSocket(null) + resetState() + resetUploadedAssets() + if (!options.value.mcpOn) { + status.value = 'disabled' + return + } + status.value = 'connecting' + scheduleReconnect() + } + + function handleError(event: Event) { + const message = event instanceof ErrorEvent ? event.message : 'MCP connection error' + errorMessage.value = message + } + + useEventListener(socket, 'message', (event: MessageEvent) => handleMessage(event)) + useEventListener(socket, 'close', (event: CloseEvent) => handleClose(event)) + useEventListener(socket, 'error', handleError) + + function start() { + status.value = 'connecting' + resetState() + stopReconnectTimer() + connect() + } + + function stop() { + stopReconnectTimer() + cleanupSocket() + resetState() + status.value = 'disabled' + errorMessage.value = null + } + + watch( + () => options.value.mcpOn, + (enabled) => { + if (enabled && runtimeMode.value === 'standard') { + start() + } else { + stop() + } + }, + { immediate: true } + ) + + watch(isWindowActive, (active) => { + if (active) { + if ( + runtimeMode.value === 'standard' && + options.value.mcpOn && + !socket.value && + !isConnecting + ) { + console.log('[tempad-dev] MCP connection polling resumed.') + connect() + } + } else { + if (options.value.mcpOn && !socket.value) { + console.log('[tempad-dev] MCP connection polling paused.') + stopReconnectTimer() + } + } + }) + + const selfActive = computed(() => !!selfId.value && selfId.value === activeId.value) + + function activate() { + if (socket.value?.readyState === WebSocket.OPEN) { + console.log('Activating MCP connection...') + socket.value.send(JSON.stringify({ type: 'activate' })) + } + } + + function isMcpToolName(name: string): name is McpToolName { + return name in MCP_TOOL_HANDLERS + } + + function getHandler(name: N): MCPHandlers[N] { + return MCP_TOOL_HANDLERS[name] + } + + async function processToolCall( + req: string, + name: N, + args: McpToolArgs | undefined + ) { + const handler = getHandler(name) + const currentSocket = socket.value + if (!currentSocket || currentSocket.readyState !== WebSocket.OPEN) { + return + } + + try { + if (!handler) { + throw new Error(`No handler registered for tool "${name}".`) + } + const result = await handler(args as McpToolArgs) + currentSocket.send( + JSON.stringify({ + type: 'toolResult', + id: req, + payload: result + }) + ) + } catch (error: unknown) { + currentSocket.send( + JSON.stringify({ + type: 'toolResult', + id: req, + error: error instanceof Error ? error.message : (error ?? 'Unknown error') + }) + ) + } + } + + return { + status, + port, + count, + activeId, + selfId, + selfActive, + errorMessage, + activate + } +}) diff --git a/composables/plugin.ts b/composables/plugin.ts index b0e79f6..6bc9b8a 100644 --- a/composables/plugin.ts +++ b/composables/plugin.ts @@ -20,7 +20,7 @@ async function getRegisteredPluginSource(source: string, signal?: AbortSignal) { name: string url: string }[] - } catch (e) { + } catch { pluginList = SNAPSHOT_PLUGINS } diff --git a/composables/selection.ts b/composables/selection.ts index 0405365..065cb05 100644 --- a/composables/selection.ts +++ b/composables/selection.ts @@ -1,8 +1,8 @@ -import { selection } from '@/ui/state' +import { selection, runtimeMode } from '@/ui/state' import { getCanvas, getLeftPanel } from '@/utils' -function syncSelection() { - if (!window.figma) { +export function syncSelection() { + if (!window.figma?.currentPage) { selection.value = [] return } @@ -39,4 +39,6 @@ export function useSelection() { objectsPanel.removeEventListener('click', handleClick, options) window.removeEventListener('keydown', handleKeyDown, options) }) + + watch(runtimeMode, () => syncSelection()) } diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 9ad9e31..5f4c9db 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,5 +1,6 @@ -import { RULES_URL } from '@/rewrite/shared' import rules from '@/public/rules/figma.json' +import { RULES_URL } from '@/rewrite/shared' + import type { Rules } from '../types/rewrite' const SYNC_ALARM = 'sync-rules' diff --git a/entrypoints/figma.ts b/entrypoints/figma.ts index fe8682d..61b187d 100644 --- a/entrypoints/figma.ts +++ b/entrypoints/figma.ts @@ -45,6 +45,7 @@ export default defineUnlistedScript(() => { if (desc) { Object.defineProperty(document, 'currentScript', desc) } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (document as any).currentScript } } diff --git a/entrypoints/rewrite.content.ts b/entrypoints/rewrite.content.ts index a19ea24..69c92eb 100644 --- a/entrypoints/rewrite.content.ts +++ b/entrypoints/rewrite.content.ts @@ -1,6 +1,7 @@ import rules from '@/public/rules/figma.json' -import { applyGroups, RULES_URL, REWRITE_RULE_ID } from '@/rewrite/shared' import { GROUPS } from '@/rewrite/config' +import { applyGroups, RULES_URL, REWRITE_RULE_ID } from '@/rewrite/shared' + import type { BlobHandle, CacheEntry, Rules } from '../types/rewrite' export default defineContentScript({ @@ -69,7 +70,9 @@ export default defineContentScript({ if (entry.ref <= 0) { try { URL.revokeObjectURL(entry.url) - } catch {} + } catch { + // noop + } blobCache.delete(src) } } @@ -122,7 +125,9 @@ export default defineContentScript({ script.addEventListener('load', release, { once: true }) script.addEventListener('error', release, { once: true }) script.src = url - } catch {} + } catch { + // noop + } normalizedInsert(parent, script, before) } diff --git a/entrypoints/ui/App.vue b/entrypoints/ui/App.vue index 156bd36..fff2a89 100644 --- a/entrypoints/ui/App.vue +++ b/entrypoints/ui/App.vue @@ -1,4 +1,5 @@