mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
+5
-5
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user