diff --git a/docs/plugins.md b/docs/plugins.md index cc97fa5d2e..2f4cd9a57d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts index fe818a22d1..8895f85acf 100644 --- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -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 Direct collision surface + {navigation.openBrowser ? "Browser available" : "Browser unavailable"} + Plugin documentation + openExternalUrl("https://example.com/")}>Open external plugin URL {navigation ? <> navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}>Open workspace from plugin navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}>Open agent from plugin @@ -196,6 +200,14 @@ async function runCommand(page: Page, title: string): Promise { 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(); diff --git a/packages/app/src/components/ui/external-link.tsx b/packages/app/src/components/ui/external-link.tsx index 02e48cc44f..2902678f60 100644 --- a/packages/app/src/components/ui/external-link.tsx +++ b/packages/app/src/components/ui/external-link.tsx @@ -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 = ( - {label} - + {children ?? label} + ); @@ -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", diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 859beb42b2..94f87d93ed 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -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"); diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 02695dc5fb..b58f82b372 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -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( diff --git a/packages/app/src/plugins/host-navigation-model.ts b/packages/app/src/plugins/host-navigation-model.ts new file mode 100644 index 0000000000..f651370a9d --- /dev/null +++ b/packages/app/src/plugins/host-navigation-model.ts @@ -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 { + 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, + }; +} diff --git a/packages/app/src/plugins/host-navigation.test.ts b/packages/app/src/plugins/host-navigation.test.ts new file mode 100644 index 0000000000..22538b3976 --- /dev/null +++ b/packages/app/src/plugins/host-navigation.test.ts @@ -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([]); + }); +}); diff --git a/packages/app/src/plugins/host-navigation.ts b/packages/app/src/plugins/host-navigation.ts index b0bf1536b8..5f30f014aa 100644 --- a/packages/app/src/plugins/host-navigation.ts +++ b/packages/app/src/plugins/host-navigation.ts @@ -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 { 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], ); } diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts index 644cdf7632..75ab40584c 100644 --- a/packages/app/src/plugins/react-native/ui.ts +++ b/packages/app/src/plugins/react-native/ui.ts @@ -8,3 +8,5 @@ export { SettingsInput, SettingsAction, } from "@/components/settings"; + +export { ExternalLink } from "@/components/ui/external-link"; diff --git a/packages/app/src/utils/http-url.ts b/packages/app/src/utils/http-url.ts new file mode 100644 index 0000000000..116c7eb8e4 --- /dev/null +++ b/packages/app/src/utils/http-url.ts @@ -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; + } +} diff --git a/packages/app/src/utils/open-external-url.test.ts b/packages/app/src/utils/open-external-url.test.ts new file mode 100644 index 0000000000..29566587df --- /dev/null +++ b/packages/app/src/utils/open-external-url.test.ts @@ -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(); + }, + ); +}); diff --git a/packages/app/src/utils/open-external-url.ts b/packages/app/src/utils/open-external-url.ts index 0225383d7f..a676987649 100644 --- a/packages/app/src/utils/open-external-url.ts +++ b/packages/app/src/utils/open-external-url.ts @@ -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 { + 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 { 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); } diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 40de9de6e2..6647af7b31 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -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.`, ); diff --git a/packages/desktop/e2e/plugin-links.electron.mjs b/packages/desktop/e2e/plugin-links.electron.mjs new file mode 100644 index 0000000000..4e1654c8b1 --- /dev/null +++ b/packages/desktop/e2e/plugin-links.electron.mjs @@ -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 + {result} + { window.open(${JSON.stringify(url)}, "_blank", "noopener,noreferrer"); }}>Old documentation workaround + { try { await openExternalUrl(${JSON.stringify(url)}); setResult("Opened externally"); } catch (error) { setResult(String(error)); } }}>Open externally + {ExternalLink ? Documentation link : null} + navigation.openBrowser({ url: ${JSON.stringify(url)}, workspaceId: ${JSON.stringify(remoteWorkspaceId)}, serverId: "plugin-links-remote" })}>Open remote workspace browser + {navigation.openBrowser ? "Browser available" : "Browser unavailable"} + navigation.openBrowser({ url: ${JSON.stringify(url)}, workspaceId: ${JSON.stringify(workspaceId)} })}>Open workspace browser + ; +} +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]); +} diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 8a1e16fe46..e1605e20f6 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -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; diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index a4d7c6e429..b6f0e342f7 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -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; diff --git a/packages/plugin/src/client/ui.ts b/packages/plugin/src/client/ui.ts index 608ed43b12..35abb0475d 100644 --- a/packages/plugin/src/client/ui.ts +++ b/packages/plugin/src/client/ui.ts @@ -54,3 +54,12 @@ export declare function SettingsSelect( ): ReactNode; export declare const SettingsInput: ComponentType; export declare const SettingsAction: ComponentType; + +export interface ExternalLinkProps { + href: string; + children: ReactNode; + accessibilityLabel?: string; + testID?: string; + onError?: (error: unknown) => void; +} +export declare const ExternalLink: ComponentType; diff --git a/plugin-examples/local-plugin/client/main.tsx b/plugin-examples/local-plugin/client/main.tsx index 2bd574dc26..749012e0a9 100644 --- a/plugin-examples/local-plugin/client/main.tsx +++ b/plugin-examples/local-plugin/client/main.tsx @@ -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 ( Workspace plugin panel + Plugin documentation + {openBrowser ? ( + + Read in workspace browser + + ) : null} {workspace?.name} {data?.handledBy ?? "The RPC has not run yet."} ` 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 { - if (Platform.OS === "web") { - window.open(url, "_blank", "noopener,noreferrer"); - return; - } - await Linking.openURL(url); +export function DocumentationLink() { + return Open documentation; } ``` -Do not add `/// ` 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`: + +```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 Open documentation; + } + + return ( + openBrowser({ url, workspaceId })}> + Open in workspace browser + + ); +} +``` + +`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.