Let plugins open external links and workspace browsers (#4972)

* feat(plugins): open external links and workspace browsers

* fix(plugins): refuse invalid links and unavailable browser workspaces

* docs(plugins): clarify external links and browser navigation
This commit is contained in:
Mohamed Boudra
2026-09-16 23:25:17 +02:00
committed by GitHub
parent a8a8da0472
commit c7db3c5a8c
19 changed files with 532 additions and 69 deletions
+5 -5
View File
@@ -283,11 +283,11 @@ when moved between hosts. Explorer configuration can create workspace-context pa
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel props expose
optional client-owned agent and workspace navigation; its absence is the compatibility gate for
older clients. Navigation targets the selected host unless the call includes another `serverId`.
Other navigation remains limited to registered global surfaces and workspace panels. Plugins do not
receive Expo Router or workspace-layout store access.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel navigation
belongs to the app; plugins do not receive Expo Router or workspace-layout store access.
See the public [navigation fields](../public-docs/plugins/v0.8/reference.md#surfaces-and-sidebar-items)
and [external links and workspace browsers](../public-docs/plugins/v0.8/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
## Lifecycle hooks
@@ -37,7 +37,8 @@ function pluginClientSource(input: {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon } from "@getpaseo/plugin/client/react-native";
import { useAgent, useWorkspace } from "@getpaseo/plugin/client";
import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client";
import { ExternalLink } from "@getpaseo/plugin/client/ui";
import { recordComposerOpen } from "./shared/rpc";
function WorkspacePanel({ workspaceId, host, layout }) {
@@ -56,6 +57,9 @@ function AgentPanel({ workspaceId, agentId, host, layout }) {
function DirectCollisionSurface({ navigation }) {
return <View>
<Text>Direct collision surface</Text>
<Text>{navigation.openBrowser ? "Browser available" : "Browser unavailable"}</Text>
<ExternalLink href="https://example.com/">Plugin documentation</ExternalLink>
<Pressable accessibilityRole="button" onPress={() => openExternalUrl("https://example.com/")}><Text>Open external plugin URL</Text></Pressable>
{navigation ? <>
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}><Text>Open workspace from plugin</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}><Text>Open agent from plugin</Text></Pressable>
@@ -196,6 +200,14 @@ async function runCommand(page: Page, title: string): Promise<void> {
await expect(panel).not.toBeVisible();
}
async function openPluginExternalTab(page: Page, role: "link" | "button", name: string) {
const opened = page.context().waitForEvent("page");
await page.getByRole(role, { name, exact: true }).click();
const tab = await opened;
await tab.waitForURL("https://example.com/");
await tab.close();
}
interface RemoteNavigationTarget {
serverId: string;
workspaceId: string;
@@ -326,6 +338,15 @@ test.describe("plugin workspace panels and Command Center", () => {
await capture(page, testInfo, "plugin-workspace-panel-wide");
});
await test.step("plugin links open browser tabs and in-app browsing is unavailable", async () => {
await runCommand(page, "Open direct collision surface");
await expect(page.getByText("Browser unavailable", { exact: true })).toBeVisible();
await openPluginExternalTab(page, "link", "Plugin documentation");
await openPluginExternalTab(page, "button", "Open external plugin URL");
await capture(page, testInfo, "plugin-external-links-web");
await page.getByTestId("plugin-surface-close").click();
});
await test.step("direct and sidebar routes preserve same-id contribution kind", async () => {
await runCommand(page, "Open direct collision surface");
await expect(page.getByText("Direct collision surface", { exact: true })).toBeVisible();
@@ -1,13 +1,16 @@
import { useCallback, type ReactNode } from "react";
import { Pressable, Text } from "react-native";
import { ArrowUpRight } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Theme } from "@/styles/theme";
import { openExternalUrl } from "@/utils/open-external-url";
interface ExternalLinkProps {
export interface ExternalLinkProps {
href: string;
label: string;
label?: string;
children?: ReactNode;
onError?: (error: unknown) => void;
tooltip?: ReactNode;
testID?: string;
accessibilityLabel?: string;
@@ -21,14 +24,15 @@ interface ExternalLinkProps {
export function ExternalLink({
href,
label,
children,
onError,
tooltip,
testID,
accessibilityLabel,
}: ExternalLinkProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
void openExternalUrl(href);
}, [href]);
void openExternalUrl(href).catch(onError ?? console.error);
}, [href, onError]);
const trigger = (
<Pressable
@@ -39,8 +43,8 @@ export function ExternalLink({
testID={testID}
style={styles.trigger}
>
<Text style={styles.label}>{label}</Text>
<ArrowUpRight size={12} color={theme.colors.foregroundMuted} />
<Text style={styles.label}>{children ?? label}</Text>
<ThemedArrowUpRight size={12} uniProps={iconColor} />
</Pressable>
);
@@ -58,6 +62,9 @@ export function ExternalLink({
);
}
const iconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedArrowUpRight = withUnistyles(ArrowUpRight);
const styles = StyleSheet.create((theme) => ({
trigger: {
flexDirection: "row",
+3 -1
View File
@@ -521,7 +521,9 @@ describe("evaluatePluginClientBundle", () => {
`(function(require) {
const shared = require("@getpaseo/plugin");
const client = require("@getpaseo/plugin/client");
for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace"]) {
const { ExternalLink } = require("@getpaseo/plugin/client/ui");
if (typeof ExternalLink !== "function") throw new Error("ExternalLink");
for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace", "openExternalUrl"]) {
if (name in shared || typeof client[name] !== "function") throw new Error(name);
}
if ("Icon" in shared || typeof shared.PluginAttachmentItemSchema.parse !== "function") throw new Error("shared exports");
+2
View File
@@ -1,4 +1,5 @@
import type { createPluginHosts } from "./hosts";
import { openExternalUrl } from "@/utils/open-external-url";
import * as pluginUiRuntime from "./react-native/ui";
import { useSettings } from "./settings/use-settings";
import * as pluginSharedRuntime from "@getpaseo/plugin";
@@ -379,6 +380,7 @@ export function runPluginClientBundle(
return {
...pluginClientRuntime,
useSettings,
openExternalUrl,
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
useHosts: () =>
React.useSyncExternalStore(
@@ -0,0 +1,42 @@
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
import type { NavigateToWorkspaceInput } from "@/stores/navigation-active-workspace-store";
import { isHttpUrl } from "@/utils/http-url";
interface HostNavigationOwner {
browserAvailable: boolean;
openAgent(input: { serverId: string; agentId: string }): void;
openWorkspace(input: NavigateToWorkspaceInput): void;
resolveWorkspace(input: { serverId: string; workspaceId: string }): string | null;
createBrowser(input: { initialUrl: string }): { browserId: string };
}
export function createPluginHostNavigation(
serverId: string,
owner: HostNavigationOwner,
): NonNullable<PluginSurfaceProps["navigation"]> {
return {
openAgent: ({ agentId, serverId: targetServerId }) =>
owner.openAgent({ serverId: targetServerId ?? serverId, agentId }),
openWorkspace: ({ workspaceId, serverId: targetServerId }) =>
owner.openWorkspace({ serverId: targetServerId ?? serverId, workspaceId }),
openBrowser: owner.browserAvailable
? ({ url, workspaceId, serverId: targetServerId }) => {
if (!isHttpUrl(url)) throw new Error("Only absolute HTTP(S) URLs are supported.");
if (!workspaceId.trim()) throw new Error("workspaceId is required.");
const destinationServerId = targetServerId ?? serverId;
const destinationWorkspaceId = owner.resolveWorkspace({
serverId: destinationServerId,
workspaceId,
});
if (!destinationWorkspaceId)
throw new Error("Workspace is unavailable on the requested host.");
const { browserId } = owner.createBrowser({ initialUrl: url });
owner.openWorkspace({
serverId: destinationServerId,
workspaceId: destinationWorkspaceId,
target: { kind: "browser", browserId },
});
}
: undefined,
};
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { createPluginHostNavigation } from "./host-navigation-model";
describe("plugin host navigation", () => {
function setup(electron = true) {
const destinations: unknown[] = [];
const browsers: string[] = [];
const workspaces = new Set(["selected:one", "remote:two"]);
const navigation = createPluginHostNavigation("selected", {
browserAvailable: electron,
resolveWorkspace: ({ serverId, workspaceId }) =>
workspaces.has(`${serverId}:${workspaceId}`) ? workspaceId : null,
openAgent: (input) => destinations.push(input),
openWorkspace: (input) => destinations.push(input),
createBrowser: ({ initialUrl }) => {
browsers.push(initialUrl);
return { browserId: `browser-${browsers.length}` };
},
});
return { navigation, destinations, browsers, workspaces };
}
it("creates and focuses a local browser in the selected or explicit host workspace", () => {
const { navigation, destinations, browsers } = setup();
navigation.openBrowser!({ url: "https://example.com/one", workspaceId: "one" });
navigation.openBrowser!({
url: "https://example.com/two",
workspaceId: "two",
serverId: "remote",
});
expect(browsers).toEqual(["https://example.com/one", "https://example.com/two"]);
expect(destinations).toEqual([
{
serverId: "selected",
workspaceId: "one",
target: { kind: "browser", browserId: "browser-1" },
},
{
serverId: "remote",
workspaceId: "two",
target: { kind: "browser", browserId: "browser-2" },
},
]);
});
it("refuses unknown or removed workspaces before creating browser records", () => {
const { navigation, destinations, browsers, workspaces } = setup();
expect(() =>
navigation.openBrowser!({ url: "https://example.com", workspaceId: "missing" }),
).toThrow("Workspace is unavailable");
expect(() =>
navigation.openBrowser!({
url: "https://example.com",
workspaceId: "one",
serverId: "unknown",
}),
).toThrow("Workspace is unavailable");
workspaces.delete("selected:one");
expect(() =>
navigation.openBrowser!({ url: "https://example.com", workspaceId: "one" }),
).toThrow("Workspace is unavailable");
expect(destinations).toEqual([]);
expect(browsers).toEqual([]);
});
it("exposes no browser capability outside Electron and creates no tabs", () => {
const { navigation, destinations, browsers } = setup(false);
expect(navigation.openBrowser).toBeUndefined();
expect(destinations).toEqual([]);
expect(browsers).toEqual([]);
});
it.each(["javascript:alert(1)", "file:///tmp/file", "/relative", "invalid"])(
"rejects %s before creating a browser",
(url) => {
const { navigation, browsers, destinations } = setup();
expect(() => navigation.openBrowser!({ url, workspaceId: "one" })).toThrow("HTTP(S)");
expect(browsers).toEqual([]);
expect(destinations).toEqual([]);
},
);
it("rejects an empty workspace before creating a browser", () => {
const { navigation, browsers } = setup();
expect(() => navigation.openBrowser!({ url: "https://example.com", workspaceId: "" })).toThrow(
"workspaceId",
);
expect(browsers).toEqual([]);
});
});
+18 -6
View File
@@ -1,18 +1,30 @@
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
import { useSessionStore } from "@/stores/session-store";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import { useMemo } from "react";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { getIsElectron } from "@/constants/platform";
import { createWorkspaceBrowser } from "@/desktop/browser/store";
import { createPluginHostNavigation } from "./host-navigation-model";
export function usePluginHostNavigation(
serverId: string,
): NonNullable<PluginSurfaceProps["navigation"]> {
return useMemo(
() => ({
openAgent: ({ agentId, serverId: targetServerId }) =>
navigateToAgent({ serverId: targetServerId ?? serverId, agentId }),
openWorkspace: ({ workspaceId, serverId: targetServerId }) =>
navigateToWorkspace({ serverId: targetServerId ?? serverId, workspaceId }),
}),
() =>
createPluginHostNavigation(serverId, {
browserAvailable: getIsElectron(),
openAgent: navigateToAgent,
openWorkspace: navigateToWorkspace,
createBrowser: createWorkspaceBrowser,
resolveWorkspace: ({ serverId: targetServerId, workspaceId }) =>
resolveWorkspaceMapKeyByIdentity({
workspaces: useSessionStore.getState().sessions[targetServerId]?.workspaces,
workspaceId,
}),
}),
[serverId],
);
}
@@ -8,3 +8,5 @@ export {
SettingsInput,
SettingsAction,
} from "@/components/settings";
export { ExternalLink } from "@/components/ui/external-link";
+9
View File
@@ -0,0 +1,9 @@
/** Shared allowlist for links passed to an OS opener or a workspace browser. */
export function isHttpUrl(url: string): boolean {
try {
const protocol = new URL(url).protocol;
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { openExternalUrl } from "./open-external-url";
describe("external URL refusal", () => {
it.each(["mailto:person@example.com", "javascript:alert(1)", "/relative", "invalid"])(
"safely ignores %s for fire-and-forget callers",
async (url) => {
await expect(openExternalUrl(url)).resolves.toBeUndefined();
},
);
});
+2 -20
View File
@@ -2,17 +2,10 @@ import * as Linking from "expo-linking";
import { getDesktopHost } from "@/desktop/host";
import { isWeb } from "@/constants/platform";
const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:"]);
function isAllowedExternalUrl(url: string): boolean {
try {
return ALLOWED_EXTERNAL_URL_PROTOCOLS.has(new URL(url).protocol);
} catch {
return false;
}
}
import { isHttpUrl } from "./http-url";
export async function openExternalUrl(url: string): Promise<void> {
if (!isHttpUrl(url)) return;
if (isWeb) {
const opener = getDesktopHost()?.opener?.openUrl;
if (typeof opener === "function") {
@@ -20,20 +13,9 @@ export async function openExternalUrl(url: string): Promise<void> {
return;
}
// Mirror the Electron opener's scheme allowlist: forge-supplied URLs
// (check target_url, job links) reach this sink, so refuse anything that
// is not plain http(s) instead of handing it to window.open.
if (!isAllowedExternalUrl(url)) {
return;
}
window.open(url, "_blank", "noopener,noreferrer");
return;
}
if (!isAllowedExternalUrl(url)) {
return;
}
await Linking.openURL(url);
}
+56 -2
View File
@@ -15,6 +15,8 @@ import { chromium } from "playwright";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const desktopDir = path.resolve(scriptDir, "..");
const rootDir = path.resolve(desktopDir, "../..");
@@ -1021,7 +1023,8 @@ async function main() {
const workspaceRoot = path.join(runtimeDir, "workspaces");
fs.mkdirSync(paseoHome, { recursive: true });
const [daemonPort, expoPort, cdpPort, inspectorPort] = await Promise.all([
const [daemonPort, expoPort, cdpPort, inspectorPort, remotePort] = await Promise.all([
reservePort(),
reservePort(),
reservePort(),
reservePort(),
@@ -1030,13 +1033,28 @@ async function main() {
const listen = `127.0.0.1:${daemonPort}`;
seedPaseoHome(paseoHome, listen, workspaceRoot);
const target = await startTargetPage();
seedPluginLinks(paseoHome, workspaceIds[0], target.url, workspaceIds[1]);
const remoteHome = path.join(runtimeDir, "remote-home");
seedPaseoHome(remoteHome, `127.0.0.1:${remotePort}`, path.join(runtimeDir, "remote-workspaces"));
const children = [];
let browser = null;
let client = null;
try {
// Observe the real Electron shell handoff without launching a user's browser.
const openerDirectory = path.join(runtimeDir, "url-handler");
const externalOpenLog = path.join(artifactDir, "external-opens.txt");
fs.mkdirSync(openerDirectory, { recursive: true });
fs.writeFileSync(externalOpenLog, "");
fs.writeFileSync(
path.join(openerDirectory, "xdg-open"),
'#!/bin/sh\nprintf "%s\\n" "$1" >> "$PASEO_TEST_EXTERNAL_OPEN_LOG"\n',
{ mode: 0o755 },
);
const commonEnv = {
...process.env,
PATH: `${openerDirectory}${path.delimiter}${process.env.PATH}`,
PASEO_TEST_EXTERNAL_OPEN_LOG: externalOpenLog,
PASEO_HOME: paseoHome,
PASEO_LISTEN: listen,
PASEO_DAEMON_ENDPOINT: `localhost:${daemonPort}`,
@@ -1057,6 +1075,25 @@ async function main() {
children.push(daemon.child);
await waitForPort(daemonPort, "daemon", daemon);
const remoteDaemon = spawnLogged(
"remote-daemon",
process.execPath,
["--import", "tsx", path.join(rootDir, "packages/server/scripts/dev-runner.ts")],
{
cwd: rootDir,
env: {
...commonEnv,
PASEO_HOME: remoteHome,
PASEO_LISTEN: `127.0.0.1:${remotePort}`,
PASEO_SERVER_ID: "plugin-links-remote",
PASEO_NODE_ENV: "development",
},
},
artifactDir,
);
children.push(remoteDaemon.child);
await waitForPort(remotePort, "remote daemon", remoteDaemon);
const desktopArgs = [
process.execPath,
devRunner,
@@ -1092,6 +1129,22 @@ async function main() {
const page = await waitForAppPage(browser, expoPort);
const status = await waitForDesktopStatus(page);
const checkPluginLinks = () =>
runPluginLinksRegression({
page,
remotePort,
workspaceId: workspaceIds[0],
remoteWorkspaceId: workspaceIds[1],
url: target.url,
artifactDir,
externalOpenLog: process.platform === "linux" ? externalOpenLog : null,
});
if (process.env.PASEO_DESKTOP_PLUGIN_LINKS_ONLY === "1") {
const pluginLinks = await checkPluginLinks();
writeJson(path.join(artifactDir, "result.json"), { pluginLinks });
console.log("Plugin external links and workspace browser passed.");
return;
}
const settingsMemory = await runSettingsMemoryRegression(page);
if (process.env.PASEO_DESKTOP_SETTINGS_MEMORY_ONLY === "1") {
writeJson(path.join(artifactDir, "result.json"), { settingsMemory });
@@ -1119,7 +1172,8 @@ async function main() {
callerAgentId,
artifactDir,
});
writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory });
const pluginLinks = await checkPluginLinks();
writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks });
console.log(
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
);
@@ -0,0 +1,122 @@
import fs from "node:fs";
import path from "node:path";
import { expect } from "@playwright/test";
export function seedPluginLinks(paseoHome, workspaceId, url, remoteWorkspaceId) {
const directory = path.join(paseoHome, "link-plugin");
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "link-check", requirements: { paseo: ">=0.8.0" } }),
);
fs.writeFileSync(
path.join(directory, "index.client.tsx"),
`
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { openExternalUrl } from "@getpaseo/plugin/client";
import { ExternalLink } from "@getpaseo/plugin/client/ui";
function Links({ navigation }) {
const [result, setResult] = useState("Ready");
return <View>
<Text>{result}</Text>
<Pressable accessibilityRole="button" onPress={() => { window.open(${JSON.stringify(url)}, "_blank", "noopener,noreferrer"); }}><Text>Old documentation workaround</Text></Pressable>
<Pressable accessibilityRole="button" onPress={async () => { try { await openExternalUrl(${JSON.stringify(url)}); setResult("Opened externally"); } catch (error) { setResult(String(error)); } }}><Text>Open externally</Text></Pressable>
{ExternalLink ? <ExternalLink href={${JSON.stringify(url)}}>Documentation link</ExternalLink> : null}
<Pressable accessibilityRole="button" onPress={() => navigation.openBrowser({ url: ${JSON.stringify(url)}, workspaceId: ${JSON.stringify(remoteWorkspaceId)}, serverId: "plugin-links-remote" })}><Text>Open remote workspace browser</Text></Pressable>
<Text>{navigation.openBrowser ? "Browser available" : "Browser unavailable"}</Text>
<Pressable accessibilityRole="button" onPress={() => navigation.openBrowser({ url: ${JSON.stringify(url)}, workspaceId: ${JSON.stringify(workspaceId)} })}><Text>Open workspace browser</Text></Pressable>
</View>;
}
export default function(client) { client.addSurface("main", Links); client.addSidebarItem({ id: "links", title: "Plugin links QA", icon: "Link", surface: "main" }); return () => {}; }
`,
);
const configPath = path.join(paseoHome, "config.json");
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
config.pluginsEnabled = true;
config.plugins = { "link-check": { source: "directory", path: directory, enabled: true } };
fs.writeFileSync(configPath, JSON.stringify(config));
}
export async function runPluginLinksRegression({
page,
remotePort,
workspaceId,
remoteWorkspaceId,
url,
artifactDir,
externalOpenLog,
}) {
const pluginEntry = page.getByRole("button", { name: "Plugin links QA", exact: true });
await expect(pluginEntry).toBeVisible({ timeout: 90_000 });
await page.evaluate((port) => {
const key = "@paseo:daemon-registry";
const registry = JSON.parse(localStorage.getItem(key));
const endpoint = `127.0.0.1:${port}`;
const connection = { id: `direct:${endpoint}`, type: "directTcp", endpoint };
const now = new Date().toISOString();
registry.push({
serverId: "plugin-links-remote",
label: "Remote browser host",
connections: [connection],
preferredConnectionId: connection.id,
createdAt: now,
updatedAt: now,
});
localStorage.setItem(key, JSON.stringify(registry));
}, remotePort);
await page.reload();
await pluginEntry.click();
await expect(
page.getByRole("button", { name: "Old documentation workaround", exact: true }),
).toBeVisible({ timeout: 60_000 });
const oldPopup = page.waitForEvent("popup");
await page.getByRole("button", { name: "Old documentation workaround", exact: true }).click();
const popup = await oldPopup;
await popup.waitForLoadState();
await popup.screenshot({ path: path.join(artifactDir, "plugin-old-popup.png") });
await popup.close();
const popups = [];
page.on("popup", (openedPopup) => popups.push(openedPopup));
await page.getByRole("button", { name: "Open externally", exact: true }).click();
await expect(page.getByText("Opened externally", { exact: true })).toBeVisible();
await page.getByRole("link", { name: "Documentation link", exact: true }).click();
if (externalOpenLog) {
await expect
.poll(() => fs.readFileSync(externalOpenLog, "utf8").trim().split("\n"))
.toEqual([url, url]);
}
await expect(page.getByText("Browser available", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Open workspace browser", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
await expectPresentedBrowser(page, url);
expect(popups).toHaveLength(0);
await page.screenshot({ path: path.join(artifactDir, "plugin-workspace-browser.png") });
await pluginEntry.click();
await page.getByRole("button", { name: "Open remote workspace browser", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/h/plugin-links-remote/workspace/${remoteWorkspaceId}`));
await expectPresentedBrowser(page, url);
await page.screenshot({ path: path.join(artifactDir, "plugin-remote-workspace-browser.png") });
return {
remoteWorkspaceId,
oldWorkaroundOpenedPopup: true,
newExternalPopups: popups.length,
workspaceId,
url,
};
}
async function expectPresentedBrowser(page, url) {
await expect
.poll(() =>
page
.locator("webview")
.evaluateAll((views) =>
views
.filter((view) => view.parentElement?.getAttribute("aria-hidden") === "false")
.map((view) => view.getURL()),
),
)
.toEqual([url]);
}
+6
View File
@@ -33,6 +33,12 @@ export interface PluginHostProps {
interface PluginNavigableHostProps extends PluginHostProps {
/** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */
readonly navigation?: {
/** Present only on Electron. The browser runs locally; serverId selects workspace ownership. */
readonly openBrowser?: (input: {
readonly url: string;
readonly workspaceId: string;
readonly serverId?: string;
}) => void;
readonly openAgent: (input: { readonly agentId: string; readonly serverId?: string }) => void;
readonly openWorkspace: (input: {
readonly workspaceId: string;
+2
View File
@@ -56,3 +56,5 @@ export interface PluginHostSummary {
export declare function useHosts(): readonly PluginHostSummary[];
/** Borrow an online host's API under this installation's lifetime. */
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */
export declare function openExternalUrl(url: string): Promise<void>;
+9
View File
@@ -54,3 +54,12 @@ export declare function SettingsSelect<Value extends string>(
): ReactNode;
export declare const SettingsInput: ComponentType<SettingsInputProps>;
export declare const SettingsAction: ComponentType<SettingsActionProps>;
export interface ExternalLinkProps {
href: string;
children: ReactNode;
accessibilityLabel?: string;
testID?: string;
onError?: (error: unknown) => void;
}
export declare const ExternalLink: ComponentType<ExternalLinkProps>;
+18 -1
View File
@@ -8,6 +8,7 @@ import {
} from "@getpaseo/plugin/client";
import { useCallback, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { ExternalLink } from "@getpaseo/plugin/client/ui";
import { incrementRpc } from "../shared/increment";
export function contributeClient(client: PluginClientContext) {
@@ -69,7 +70,12 @@ export function contributeClient(client: PluginClientContext) {
};
}
export function ExamplePanel({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
export function ExamplePanel({
theme,
layout,
workspaceId,
navigation,
}: PluginWorkspacePanelProps) {
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
const callIncrement = useRpc(incrementRpc);
const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement });
@@ -94,9 +100,20 @@ export function ExamplePanel({ theme, layout, workspaceId }: PluginWorkspacePane
mutate({ value });
}, [mutate, value]);
const openBrowser = navigation?.openBrowser;
const handleOpenBrowser = useCallback(() => {
openBrowser?.({ url: "https://paseo.sh/docs/plugins", workspaceId });
}, [openBrowser, workspaceId]);
return (
<View style={styles.screen}>
<Text style={styles.title}>Workspace plugin panel</Text>
<ExternalLink href="https://paseo.sh/docs/plugins">Plugin documentation</ExternalLink>
{openBrowser ? (
<Pressable accessibilityRole="button" onPress={handleOpenBrowser} style={styles.button}>
<Text style={styles.buttonText}>Read in workspace browser</Text>
</Pressable>
) : null}
<Text style={styles.detail}>{workspace?.name}</Text>
<Text style={styles.detail}>{data?.handledBy ?? "The RPC has not run yet."}</Text>
<Pressable
+98 -25
View File
@@ -151,32 +151,105 @@ in your browser and crashes on a phone is the most common plugin bug. The rules:
| `onPress` | `onClick`, `onMouseEnter`, or other DOM handlers |
| `Linking`, `Clipboard`-style React Native APIs | `window`, `document`, `localStorage`, `navigator`, `location` in components |
The scaffold's `tsconfig.json` omits the DOM library, so `document` and `window` are type errors
everywhere by default. The one place browser APIs are allowed is `client/web.ts`. It declares the
narrow shape of each global it uses, gates every export on `Platform.OS`, and gives native the
alternative:
The scaffold's `tsconfig.json` omits the DOM library. Keep DOM globals out of cross-platform
components; do not add `/// <reference lib="dom" />` or `"DOM"` to `lib`.
`layout.platform` carries the same value as React Native's `Platform.OS` for rendering decisions.
`client/web.ts`:
### External links and workspace browsers
```ts
import { Linking, Platform } from "react-native";
Use `ExternalLink` to open documentation outside Paseo:
// This plugin typechecks without the DOM library. Declare only what this module uses.
declare const window: { open(url: string, target: string, features: string): unknown };
```tsx
import { ExternalLink } from "@getpaseo/plugin/client/ui";
export async function openExternal(url: string): Promise<void> {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
await Linking.openURL(url);
export function DocumentationLink() {
return <ExternalLink href="https://paseo.sh/docs">Open documentation</ExternalLink>;
}
```
Do not add `/// <reference lib="dom" />` or `"DOM"` to `lib`; either one turns DOM types back on
for the whole project and hides the next mistake. Components import `openExternal` and never touch
`window` themselves. `layout.platform` on surface and panel props carries the same value as
`Platform.OS` for rendering decisions.
The component has accessible link semantics and uses the same opener as
`openExternalUrl(url: string): Promise<void>`:
```ts
import { openExternalUrl } from "@getpaseo/plugin/client";
export async function openDocumentation() {
await openExternalUrl("https://paseo.sh/docs");
}
```
Call the function directly from a user interaction so the browser permits a new tab.
| Platform | External links | `navigation.openBrowser` |
| ------------- | ---------------------------------- | ------------------------------------------------ |
| Electron | System browser | Available; creates a local workspace browser tab |
| Browser web | New tab with `noopener,noreferrer` | `undefined` |
| iOS / Android | OS URL handler | `undefined` |
#### ExternalLink props
| Prop | Required | Behavior / default |
| ------------------------------- | -------- | ------------------------------------------------------ |
| `href: string` | Yes | Absolute HTTP(S) destination |
| `children: ReactNode` | Yes | Link text or inline React Native content |
| `accessibilityLabel: string` | No | Overrides the accessible name derived from the content |
| `testID: string` | No | Test identifier; unset by default |
| `onError(error: unknown): void` | No | Receives opening errors; defaults to logging them |
#### Open a workspace browser
Use `navigation.openBrowser` from a surface or panel. Check availability before rendering
the action. This workspace panel chooses an external link on other platforms:
```tsx
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
import { ExternalLink } from "@getpaseo/plugin/client/ui";
import { Pressable, Text } from "react-native";
export function DocumentationPanel({ navigation, workspaceId, theme }: PluginWorkspacePanelProps) {
const openBrowser = navigation?.openBrowser;
const url = "https://paseo.sh/docs";
if (!openBrowser) {
return <ExternalLink href={url}>Open documentation</ExternalLink>;
}
return (
<Pressable accessibilityRole="button" onPress={() => openBrowser({ url, workspaceId })}>
<Text style={{ color: theme.colors.foreground }}>Open in workspace browser</Text>
</Pressable>
);
}
```
`navigation.openBrowser({ url, workspaceId, serverId? }): void` creates and focuses a new tab.
It never opens externally as an automatic fallback.
| Option | Required | Behavior / default |
| --------------------- | -------- | ----------------------------------------------------------------- |
| `url: string` | Yes | Absolute HTTP(S) destination |
| `workspaceId: string` | Yes | Workspace already present in the target host's app workspace list |
| `serverId: string` | No | Defaults to the surface or panel's selected host |
To target another host, pass its ID with that host's workspace ID:
```ts
openBrowser({ url, workspaceId: remoteWorkspaceId, serverId: remoteServerId });
```
`serverId` selects workspace ownership. The page runs on your local desktop, including
for remote workspaces; `localhost` URLs refer to that desktop.
#### Errors and refusal
| Condition | Result |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| External URL is malformed or uses a non-HTTP(S) scheme | Ignored; `openExternalUrl` resolves without opening anything |
| OS opener fails | `openExternalUrl` rejects; `ExternalLink` calls `onError` or logs the error |
| Browser blocks a new external tab | Cannot be distinguished from a successful `noopener` open |
| In-app browser URL is malformed or uses a non-HTTP(S) scheme | Throws `Only absolute HTTP(S) URLs are supported.` before creating a tab |
| In-app browser workspace ID is empty | Throws `workspaceId is required.` before creating a tab |
| Target host/workspace is unknown or its workspace list has not loaded | Throws `Workspace is unavailable on the requested host.` before creating a tab |
Use the [settings API](#settings-screens) for typed host-scoped persistence across clients.
Use `openSettings`, `openSurface`, and `openPanel` for your own registered contributions.
@@ -614,12 +687,12 @@ export default function contribute(client: PluginClientContext) {
`PluginSurfaceProps` contains:
| Field | Meaning |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `theme` | Typed `PluginTheme` color tokens for the active Paseo theme. |
| `host` | Selected host `id` and display `label`. |
| `layout` | `compact` and the `ios`, `android`, or `web` platform. |
| `navigation` | Optional client navigation. `openAgent({ agentId, serverId? })` and `openWorkspace({ workspaceId, serverId? })` open targets on `serverId`, or on the selected host when omitted. |
| Field | Meaning |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `theme` | Typed `PluginTheme` color tokens for the active Paseo theme. |
| `host` | Selected host `id` and display `label`. |
| `layout` | `compact` and the `ios`, `android`, or `web` platform. |
| `navigation` | Optional client navigation. `openAgent({ agentId, serverId? })` and `openWorkspace({ workspaceId, serverId? })` open targets on `serverId`, or on the selected host when omitted. `openBrowser({ url, workspaceId, serverId? })` is available only on Electron; see [links and browsers](#external-links-and-workspace-browsers). |
Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body.