Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/bridge-plugin/src/__tests__/open-external.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from "vitest";
import { handleOpenExternal } from "../handlers/open-external";

describe("handleOpenExternal", () => {
it("calls figma.openExternal with the url when type is 'open-external' and url is a string", () => {
const openExternal = vi.fn();
const figmaRef = { openExternal } as unknown as PluginAPI;
const dispatched = handleOpenExternal(
{ type: "open-external", url: "https://example.com/pluginos.dxt" },
figmaRef
);
expect(dispatched).toBe(true);
expect(openExternal).toHaveBeenCalledTimes(1);
expect(openExternal).toHaveBeenCalledWith("https://example.com/pluginos.dxt");
});

it("does NOT call figma.openExternal when url is missing", () => {
const openExternal = vi.fn();
const figmaRef = { openExternal } as unknown as PluginAPI;
const dispatched = handleOpenExternal({ type: "open-external" }, figmaRef);
expect(dispatched).toBe(false);
expect(openExternal).not.toHaveBeenCalled();
});

it("does NOT call figma.openExternal when url is not a string", () => {
const openExternal = vi.fn();
const figmaRef = { openExternal } as unknown as PluginAPI;
const dispatched = handleOpenExternal({ type: "open-external", url: 42 }, figmaRef);
expect(dispatched).toBe(false);
expect(openExternal).not.toHaveBeenCalled();
});

it("does NOT call figma.openExternal when url is an empty string", () => {
const openExternal = vi.fn();
const figmaRef = { openExternal } as unknown as PluginAPI;
const dispatched = handleOpenExternal({ type: "open-external", url: "" }, figmaRef);
expect(dispatched).toBe(false);
expect(openExternal).not.toHaveBeenCalled();
});

it("does NOT call figma.openExternal when url is null", () => {
const openExternal = vi.fn();
const figmaRef = { openExternal } as unknown as PluginAPI;
const dispatched = handleOpenExternal({ type: "open-external", url: null }, figmaRef);
expect(dispatched).toBe(false);
expect(openExternal).not.toHaveBeenCalled();
});

it("returns false for unrelated message types", () => {
const openExternal = vi.fn();
const figmaRef = { openExternal } as unknown as PluginAPI;
const dispatched = handleOpenExternal({ type: "ws-message", payload: {} }, figmaRef);
expect(dispatched).toBe(false);
expect(openExternal).not.toHaveBeenCalled();
});
});
22 changes: 21 additions & 1 deletion packages/bridge-plugin/src/bootloader.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
<div class="card-title">Claude Desktop</div>
<div class="card-desc">One-click install. Download the PluginOS extension for Claude Desktop, double-click to install, then return here. No terminal, no JSON.</div>
<div class="card-actions">
<a class="btn btn-primary" id="btn-download-dxt" href="https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt" download>Download for Claude Desktop</a>
<button class="btn btn-primary" id="btn-download-dxt">Download for Claude Desktop</button>
<button class="btn" id="btn-copy-dxt-url">Copy link</button>
</div>
</div>

Expand Down Expand Up @@ -132,6 +133,7 @@
var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@0.4.1"] } } }, null, 2);
var TIER_1_RULES = "When working with Figma, always use PluginOS tools exclusively:\n- Use `list_operations` (pluginos) first to discover available Figma operations.\n- Use `run_operation` (pluginos) to execute them.\n- Use `execute_figma` (pluginos) only for one-off custom logic not covered by built-in ops.\n- Do NOT use `mcp__Figma__*` tools — they bypass the plugin and return raw, token-heavy data. PluginOS returns pre-summarized, structured results at ~230 tokens/call.\n- If PluginOS returns \"No plugin connected\", open the PluginOS Bridge plugin in Figma before retrying.\n\nAudit/lint/check operations default to `scope: \"selection\"`. Pass `scope: \"page\"` explicitly (and `confirm: true` for pages over 500 nodes) to scan the whole page. Responses carry `_hint` and `_next_hints` fields — respect them when deciding what to do next.";
var INSTALL_COMMAND = "/plugin marketplace add github:LSDimi/pluginos\n/plugin install pluginos";
var DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The DXT_URL constant is duplicated here and in packages/bridge-plugin/src/ui-entry.ts. This increases the risk of inconsistency if the URL needs to be updated in the future. Consider centralizing this value in a shared configuration or constants file that can be accessed by both the main UI and the bootloader.


function wireCopy(btnId, text) {
var btn = document.getElementById(btnId);
Expand Down Expand Up @@ -160,9 +162,27 @@
} else { fallback(); }
});
}
(function wireDownloadDxt() {
var btn = document.getElementById("btn-download-dxt");
if (!btn) return;
btn.addEventListener("click", function() {
parent.postMessage(
{ pluginMessage: { type: "open-external", url: DXT_URL } },
"*"
);
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "✓ Opening in browser…";
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
Comment on lines +173 to +179

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This logic for updating the button text and class on click is duplicated from the wireCopy function's success handler (lines 143-146 in the full file). To improve maintainability and reduce redundancy, consider extracting this into a reusable helper function like flashButton(btn, message) that can be used by both wireCopy and wireDownloadDxt.

});
})();
wireCopy("btn-copy-install", INSTALL_COMMAND);
wireCopy("btn-copy-mcp-cursor", MCP_CONFIG_JSON);
wireCopy("btn-copy-rules-cursor", TIER_1_RULES);
wireCopy("btn-copy-dxt-url", DXT_URL);

function tryBootload() {
setDot("dot-yellow");
Expand Down
5 changes: 5 additions & 0 deletions packages/bridge-plugin/src/code.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getOperation, listOperations } from "./operations/index";
import { createOperationContext } from "./operations/context";
import { safeSerialize } from "./utils/serializer";
import { handleOpenExternal } from "./handlers/open-external";

// Show the UI (which handles WebSocket)
figma.showUI(__html__, { width: 320, height: 480, themeColors: true });
Expand Down Expand Up @@ -29,6 +30,10 @@ figma.ui.onmessage = async (msg: any) => {
return;
}

if (handleOpenExternal(msg, figma)) {
return;
}

if (msg.type === "ws-connected") {
sendFileStatus();
return;
Expand Down
21 changes: 21 additions & 0 deletions packages/bridge-plugin/src/handlers/open-external.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Handle `{ type: "open-external", url: string }` messages from the plugin UI.
*
* Why this exists: Figma renders plugin UIs in a sandboxed iframe. Clicking an
* <a href="..." download> navigates the iframe itself and blanks the plugin
* view — the `download` attribute is ignored. The Figma-standard way to open
* an external URL is `figma.openExternal(url)`, which opens the user's
* default browser without touching the iframe.
*
* Returns true when the message was dispatched, false otherwise — lets the
* caller chain handlers without re-checking `msg.type`.
*/
export function handleOpenExternal(
msg: { type?: string; url?: unknown; [key: string]: unknown },
figmaRef: Pick<PluginAPI, "openExternal">
): boolean {
if (msg.type !== "open-external") return false;
if (typeof msg.url !== "string" || msg.url.length === 0) return false;
figmaRef.openExternal(msg.url);
return true;
}
14 changes: 14 additions & 0 deletions packages/bridge-plugin/src/ui-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const MCP_CONFIG_JSON = `{
const INSTALL_COMMAND = `/plugin marketplace add github:LSDimi/pluginos
/plugin install pluginos`;

const DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";

const PORT_MIN = 9500;
const PORT_MAX = 9510;
const RECONNECT_DELAY = 3000;
Expand Down Expand Up @@ -286,6 +288,18 @@ document
copyToClipboard(TIER_1_RULES, e.currentTarget as HTMLButtonElement)
);

document.getElementById("btn-download-dxt")!.addEventListener("click", (e) => {
e.preventDefault();
parent.postMessage({ pluginMessage: { type: "open-external", url: DXT_URL } }, "*");
flashCopied(e.currentTarget as HTMLButtonElement, "✓ Opening in browser…");
});

document
.getElementById("btn-copy-dxt-url")!
.addEventListener("click", (e) =>
copyToClipboard(DXT_URL, e.currentTarget as HTMLButtonElement, "✓ Link copied")
);

// Forward messages from code.js (plugin sandbox) to WebSocket
window.onmessage = (event: MessageEvent) => {
const msg = event.data.pluginMessage;
Expand Down
3 changes: 2 additions & 1 deletion packages/bridge-plugin/src/ui.html
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@
<div class="card-title">Claude Desktop</div>
<div class="card-desc">One-click install. Download the PluginOS extension for Claude Desktop, double-click to install, then return here. No terminal, no JSON.</div>
<div class="card-actions">
<a class="btn btn-primary" id="btn-download-dxt" href="https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt" download>Download for Claude Desktop</a>
<button class="btn btn-primary" id="btn-download-dxt">Download for Claude Desktop</button>
<button class="btn" id="btn-copy-dxt-url">Copy link</button>
</div>
</div>

Expand Down
Loading